Automations engine: multi-channel messaging + dashboard
THE STAR OF THE SHOW — the automation engine is now visible. ## New: Unified Messaging Layer (src/lib/messaging.ts) Channel waterfall: WhatsApp → SMS → Email - sendToDonor() routes to best available channel - Respects donor consent flags (whatsappOptIn, emailOptIn) - Falls back automatically if primary channel fails - Every attempt logged to AnalyticsEvent for dashboard ## New: Email Integration (src/lib/email.ts) Bring-your-own-key: charity pastes their Resend or SendGrid API key - Resend: free 3,000 emails/month - SendGrid: free 100/day - Messages come from THEIR domain (donations@mymosque.org) - Plain text auto-converted to clean HTML ## New: SMS Integration (src/lib/sms.ts) Bring-your-own-key: charity pastes their Twilio credentials - Pay-as-you-go (~3p per SMS) - UK number normalization (07xxx → +447xxx) - Reaches donors without WhatsApp or email ## New: /dashboard/automations — the visible engine A. Dark hero stats: Messages this week per channel + delivery rate B. Live channels: WhatsApp/Email/SMS with status, features, stats C. The Pipeline: visual 4-step automation sequence - What triggers, what's sent, which channels, waterfall explanation D. Scheduled reminders: upcoming messages with timing E. Message feed: recent messages with channel icon, status, time ## New: /api/messaging/status — dashboard data endpoint Returns channels, stats (7 day), history (50 recent), pending reminders ## New: /api/messaging/test — send test message to admin ## Schema: 8 new Organization columns emailProvider, emailApiKey, emailFromAddress, emailFromName smsProvider, smsAccountSid, smsAuthToken, smsFromNumber ## Settings: 2 new channel rows in the checklist - Email: provider selector (Resend/SendGrid) + API key + from address - SMS: Twilio credentials + from number Both follow the same checklist expand/collapse pattern ## Nav: Automations added between Money and Reports Home → Collect → Money → Automations → Reports → Settings ## Stats tracking Messages logged as AnalyticsEvent: message.whatsapp.receipt.sent message.email.reminder_1.failed message.sms.reminder_2.sent Donor PII masked in logs (last 4 digits of phone, email obfuscated)
This commit is contained in:
397
pledge-now-pay-later/package-lock.json
generated
397
pledge-now-pay-later/package-lock.json
generated
@@ -28,9 +28,11 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"resend": "^6.9.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.4.0",
|
"stripe": "^20.4.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"twilio": "^5.12.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1788,6 +1790,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2493,6 +2501,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
@@ -2756,6 +2776,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -2791,6 +2817,17 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -2854,6 +2891,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -2953,7 +2996,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -2967,7 +3009,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -3197,6 +3238,18 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -3342,11 +3395,16 @@
|
|||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -3427,6 +3485,15 @@
|
|||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/denque": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
@@ -3500,7 +3567,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -3518,6 +3584,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
@@ -3617,7 +3692,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3627,7 +3701,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3665,7 +3738,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -3678,7 +3750,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4311,6 +4382,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -4386,6 +4463,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -4418,6 +4515,22 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
@@ -4444,7 +4557,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -4513,7 +4625,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -4544,7 +4655,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -4702,7 +4812,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4792,7 +4901,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4805,7 +4913,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -4821,7 +4928,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4846,6 +4952,19 @@
|
|||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -5496,6 +5615,28 @@
|
|||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -5512,6 +5653,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5598,6 +5760,42 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -5605,6 +5803,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
@@ -5658,7 +5862,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5688,6 +5891,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -5725,7 +5949,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -6039,7 +6262,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6537,6 +6759,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -6874,6 +7102,12 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -6917,6 +7151,21 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7079,6 +7328,27 @@
|
|||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz",
|
||||||
|
"integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.3",
|
||||||
|
"svix": "1.84.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -7223,6 +7493,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -7273,6 +7563,13 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scmp": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
|
||||||
|
"deprecated": "Just use Node.js's crypto.timingSafeEqual()",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -7414,7 +7711,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7434,7 +7730,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7451,7 +7746,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7470,7 +7764,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7532,6 +7825,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@@ -7881,6 +8184,29 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.84.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
|
||||||
|
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svix/node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
@@ -8090,6 +8416,24 @@
|
|||||||
"fsevents": "~2.3.3"
|
"fsevents": "~2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/twilio": {
|
||||||
|
"version": "5.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz",
|
||||||
|
"integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.0",
|
||||||
|
"dayjs": "^1.11.9",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"qs": "^6.14.1",
|
||||||
|
"scmp": "^2.1.0",
|
||||||
|
"xmlbuilder": "^13.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -8537,6 +8881,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "13.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
|
||||||
|
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -29,9 +29,11 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"resend": "^6.9.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.4.0",
|
"stripe": "^20.4.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"twilio": "^5.12.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ model Organization {
|
|||||||
gcEnvironment String @default("sandbox")
|
gcEnvironment String @default("sandbox")
|
||||||
stripeSecretKey String?
|
stripeSecretKey String?
|
||||||
stripeWebhookSecret String?
|
stripeWebhookSecret String?
|
||||||
|
emailProvider String? // resend, sendgrid, smtp
|
||||||
|
emailApiKey String?
|
||||||
|
emailFromAddress String? // e.g. donations@mymosque.org
|
||||||
|
emailFromName String? // e.g. "Al Furqan Mosque"
|
||||||
|
smsProvider String? // twilio
|
||||||
|
smsAccountSid String?
|
||||||
|
smsAuthToken String?
|
||||||
|
smsFromNumber String? // e.g. +447123456789
|
||||||
whatsappConnected Boolean @default(false)
|
whatsappConnected Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
31
pledge-now-pay-later/src/app/api/messaging/status/route.ts
Normal file
31
pledge-now-pay-later/src/app/api/messaging/status/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getOrgChannels, getChannelStats, getMessageHistory, getPendingReminders } from "@/lib/messaging"
|
||||||
|
import { getOrgId } from "@/lib/session"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/messaging/status
|
||||||
|
*
|
||||||
|
* Returns everything the Automations dashboard needs:
|
||||||
|
* - Channel availability (which channels are live)
|
||||||
|
* - Stats (messages sent/failed per channel, last 7 days)
|
||||||
|
* - Message history (recent 50)
|
||||||
|
* - Pending reminders (next 20 scheduled)
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
||||||
|
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||||
|
|
||||||
|
const [channels, stats, history, pending] = await Promise.all([
|
||||||
|
getOrgChannels(orgId),
|
||||||
|
getChannelStats(orgId, 7),
|
||||||
|
getMessageHistory(orgId, 50),
|
||||||
|
getPendingReminders(orgId, 20),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ channels, stats, history, pending })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Messaging status error:", error)
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pledge-now-pay-later/src/app/api/messaging/test/route.ts
Normal file
55
pledge-now-pay-later/src/app/api/messaging/test/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { sendToDonor, getOrgChannels } from "@/lib/messaging"
|
||||||
|
import { getUser } from "@/lib/session"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/messaging/test
|
||||||
|
*
|
||||||
|
* Send a test message to the admin's own phone/email
|
||||||
|
* to verify channel configuration.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { channel } = body // "whatsapp", "email", "sms", or "auto"
|
||||||
|
|
||||||
|
const channels = await getOrgChannels(user.orgId)
|
||||||
|
|
||||||
|
// Determine test destination
|
||||||
|
const testPhone = body.phone || ""
|
||||||
|
const testEmail = body.email || user.email
|
||||||
|
|
||||||
|
const testMsg = `🧪 Test from ${user.orgName || "PNPL"}\n\nThis is a test message to confirm your ${channel || "messaging"} integration is working.\n\nIf you received this, you're all set! 🎉`
|
||||||
|
|
||||||
|
const result = await sendToDonor({
|
||||||
|
donorPhone: channel === "email" ? null : testPhone,
|
||||||
|
donorEmail: channel === "sms" || channel === "whatsapp" ? null : testEmail,
|
||||||
|
donorName: user.name || "Test",
|
||||||
|
whatsappOptIn: true,
|
||||||
|
emailOptIn: true,
|
||||||
|
messageType: "test",
|
||||||
|
subject: `Test message from ${user.orgName || "PNPL"}`,
|
||||||
|
whatsappText: testMsg,
|
||||||
|
emailText: testMsg,
|
||||||
|
smsText: `Test from ${user.orgName || "PNPL"}: Your messaging integration is working! 🎉`,
|
||||||
|
orgId: user.orgId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
channel: result.channel,
|
||||||
|
error: result.error,
|
||||||
|
channelsAvailable: {
|
||||||
|
whatsapp: channels.whatsapp,
|
||||||
|
email: !!channels.email,
|
||||||
|
sms: !!channels.sms,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test message error:", error)
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ export async function GET(request: NextRequest) {
|
|||||||
gcEnvironment: org.gcEnvironment,
|
gcEnvironment: org.gcEnvironment,
|
||||||
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
||||||
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
||||||
|
emailProvider: org.emailProvider || "",
|
||||||
|
emailApiKey: org.emailApiKey ? "••••••••" : "",
|
||||||
|
emailFromAddress: org.emailFromAddress || "",
|
||||||
|
emailFromName: org.emailFromName || "",
|
||||||
|
smsProvider: org.smsProvider || "",
|
||||||
|
smsAccountSid: org.smsAccountSid ? "••••••••" : "",
|
||||||
|
smsAuthToken: org.smsAuthToken ? "••••••••" : "",
|
||||||
|
smsFromNumber: org.smsFromNumber || "",
|
||||||
orgType: org.orgType || "charity",
|
orgType: org.orgType || "charity",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,7 +95,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"]
|
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret", "emailProvider", "emailApiKey", "emailFromAddress", "emailFromName", "smsProvider", "smsAccountSid", "smsAuthToken", "smsFromNumber"]
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const data: Record<string, any> = {}
|
const data: Record<string, any> = {}
|
||||||
for (const key of stringKeys) {
|
for (const key of stringKeys) {
|
||||||
|
|||||||
426
pledge-now-pay-later/src/app/dashboard/automations/page.tsx
Normal file
426
pledge-now-pay-later/src/app/dashboard/automations/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { formatPence } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Loader2, MessageCircle, Mail, Smartphone, Check, X, Clock,
|
||||||
|
ChevronDown, Send, Zap, AlertTriangle, Radio
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /dashboard/automations — "Is it working? What's being sent?"
|
||||||
|
*
|
||||||
|
* THIS IS THE STAR OF THE SHOW.
|
||||||
|
*
|
||||||
|
* The entire product is an automation engine. Without it, PNPL is
|
||||||
|
* just a spreadsheet. This page makes the engine VISIBLE.
|
||||||
|
*
|
||||||
|
* Aaisha's questions:
|
||||||
|
* 1. "Are messages actually being sent?" → Live channel status
|
||||||
|
* 2. "What did Ahmed receive?" → Message feed with previews
|
||||||
|
* 3. "What happens after someone pledges?" → Visual pipeline
|
||||||
|
* 4. "Is everything working?" → Delivery stats
|
||||||
|
* 5. "What's coming up next?" → Scheduled reminders
|
||||||
|
*
|
||||||
|
* The page has 4 sections:
|
||||||
|
* A. Hero stats bar (dark) — messages this week, delivery rate
|
||||||
|
* B. Live channels — which pipes are active
|
||||||
|
* C. The Pipeline — visual "what happens after a pledge"
|
||||||
|
* D. Message feed — recent messages with status
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ChannelStatus {
|
||||||
|
whatsapp: boolean
|
||||||
|
email: { provider: string; fromAddress: string } | null
|
||||||
|
sms: { provider: string; fromNumber: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelStats {
|
||||||
|
whatsapp: { sent: number; failed: number }
|
||||||
|
email: { sent: number; failed: number }
|
||||||
|
sms: { sent: number; failed: number }
|
||||||
|
total: number
|
||||||
|
deliveryRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageEntry {
|
||||||
|
id: string; channel: string; messageType: string
|
||||||
|
donorName: string | null; success: boolean
|
||||||
|
error: string | null; createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingReminder {
|
||||||
|
id: string; donorName: string | null; amountPence: number
|
||||||
|
step: number; channel: string; scheduledAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_ICONS: Record<string, typeof MessageCircle> = {
|
||||||
|
whatsapp: MessageCircle, email: Mail, sms: Smartphone,
|
||||||
|
}
|
||||||
|
const CHANNEL_COLORS: Record<string, string> = {
|
||||||
|
whatsapp: "#25D366", email: "#1E40AF", sms: "#F59E0B",
|
||||||
|
}
|
||||||
|
const MSG_LABELS: Record<string, string> = {
|
||||||
|
receipt: "Receipt",
|
||||||
|
reminder_1: "Reminder 1",
|
||||||
|
reminder_2: "Reminder 2",
|
||||||
|
reminder_3: "Reminder 3",
|
||||||
|
reminder_4: "Final reminder",
|
||||||
|
overdue_notice: "Overdue notice",
|
||||||
|
payment_confirmed: "Payment confirmed",
|
||||||
|
test: "Test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIPELINE_STEPS = [
|
||||||
|
{
|
||||||
|
trigger: "Someone pledges",
|
||||||
|
title: "Receipt",
|
||||||
|
desc: "Bank details, reference, confirmation",
|
||||||
|
timing: "Instantly",
|
||||||
|
messageType: "receipt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: "Day 2",
|
||||||
|
title: "Gentle reminder",
|
||||||
|
desc: "\"Just a quick reminder about your pledge...\"",
|
||||||
|
timing: "If not paid",
|
||||||
|
messageType: "reminder_1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: "Day 7",
|
||||||
|
title: "Impact nudge",
|
||||||
|
desc: "\"Your £X helps fund...\" — why it matters",
|
||||||
|
timing: "If not paid",
|
||||||
|
messageType: "reminder_2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: "Day 14",
|
||||||
|
title: "Final reminder",
|
||||||
|
desc: "\"This is our final message\" — reply PAID/CANCEL",
|
||||||
|
timing: "Marks overdue if no response",
|
||||||
|
messageType: "reminder_3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AutomationsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [channels, setChannels] = useState<ChannelStatus | null>(null)
|
||||||
|
const [stats, setStats] = useState<ChannelStats | null>(null)
|
||||||
|
const [history, setHistory] = useState<MessageEntry[]>([])
|
||||||
|
const [pending, setPending] = useState<PendingReminder[]>([])
|
||||||
|
const [pipelineOpen, setPipelineOpen] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/messaging/status")
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.channels) setChannels(data.channels)
|
||||||
|
if (data.stats) setStats(data.stats)
|
||||||
|
if (data.history) setHistory(data.history)
|
||||||
|
if (data.pending) setPending(data.pending)
|
||||||
|
} catch { /* */ }
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
|
|
||||||
|
const activeChannels = [
|
||||||
|
channels?.whatsapp ? "WhatsApp" : null,
|
||||||
|
channels?.email ? "Email" : null,
|
||||||
|
channels?.sms ? "SMS" : null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const noChannels = activeChannels.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Pledge follow-up engine</p>
|
||||||
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Automations</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── A. Hero stats (dark) ── */}
|
||||||
|
<div className="bg-[#111827] p-5">
|
||||||
|
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Last 7 days</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-700">
|
||||||
|
{[
|
||||||
|
{ value: String(stats?.whatsapp.sent || 0), label: "WhatsApp", color: "text-[#25D366]" },
|
||||||
|
{ value: String(stats?.email.sent || 0), label: "Email", color: "text-[#60A5FA]" },
|
||||||
|
{ value: String(stats?.sms.sent || 0), label: "SMS", color: "text-[#FBBF24]" },
|
||||||
|
{ value: String(stats?.total || 0), label: "Total messages", color: "text-white" },
|
||||||
|
{ value: `${stats?.deliveryRate || 0}%`, label: "Delivered", color: (stats?.deliveryRate || 0) >= 90 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="bg-[#111827] p-4">
|
||||||
|
<p className={`text-2xl font-black tracking-tight ${s.color}`}>{s.value}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── B. Live channels ── */}
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827]">Channels</h2>
|
||||||
|
<Link href="/dashboard/settings" className="text-[10px] font-bold text-[#1E40AF] hover:underline">
|
||||||
|
Configure in Settings →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noChannels ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-[#F59E0B] mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-bold text-[#111827]">No channels connected</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Connect WhatsApp, Email, or SMS in Settings to start sending automatic receipts and reminders.</p>
|
||||||
|
<Link href="/dashboard/settings" className="inline-block mt-3 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
|
||||||
|
Go to Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{/* WhatsApp */}
|
||||||
|
<ChannelRow
|
||||||
|
name="WhatsApp"
|
||||||
|
icon={MessageCircle}
|
||||||
|
color="#25D366"
|
||||||
|
active={!!channels?.whatsapp}
|
||||||
|
detail={channels?.whatsapp ? "Connected · Receipts, reminders, chatbot" : "Not connected"}
|
||||||
|
stats={stats?.whatsapp}
|
||||||
|
features={["Instant receipts", "4-step reminders", "2-way chatbot (PAID/HELP/CANCEL)"]}
|
||||||
|
/>
|
||||||
|
{/* Email */}
|
||||||
|
<ChannelRow
|
||||||
|
name="Email"
|
||||||
|
icon={Mail}
|
||||||
|
color="#1E40AF"
|
||||||
|
active={!!channels?.email}
|
||||||
|
detail={channels?.email ? `${channels.email.provider} · ${channels.email.fromAddress}` : "Not configured — add your Resend or SendGrid key in Settings"}
|
||||||
|
stats={stats?.email}
|
||||||
|
features={["HTML receipts", "Reminder emails", "Works for donors without WhatsApp"]}
|
||||||
|
/>
|
||||||
|
{/* SMS */}
|
||||||
|
<ChannelRow
|
||||||
|
name="SMS"
|
||||||
|
icon={Smartphone}
|
||||||
|
color="#F59E0B"
|
||||||
|
active={!!channels?.sms}
|
||||||
|
detail={channels?.sms ? `${channels.sms.provider} · ${channels.sms.fromNumber}` : "Not configured — add your Twilio credentials in Settings"}
|
||||||
|
stats={stats?.sms}
|
||||||
|
features={["Text reminders", "Reaches everyone", "Fallback for no-WhatsApp donors"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── C. The Pipeline — what happens after a pledge ── */}
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setPipelineOpen(!pipelineOpen)}
|
||||||
|
className="w-full px-5 py-4 flex items-center justify-between hover:bg-[#F9FAFB] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="h-4 w-4 text-[#1E40AF]" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827]">What happens after someone pledges</h2>
|
||||||
|
<p className="text-[10px] text-gray-500">4-step automated sequence · {activeChannels.join(" → ") || "no channels"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${pipelineOpen ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pipelineOpen && (
|
||||||
|
<div className="px-5 pb-5 space-y-0">
|
||||||
|
{PIPELINE_STEPS.map((step, i) => (
|
||||||
|
<div key={i} className="flex gap-4">
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className={`w-8 h-8 flex items-center justify-center shrink-0 ${i === 0 ? "bg-[#1E40AF] text-white" : "bg-gray-100 text-gray-500"}`}>
|
||||||
|
<span className="text-[10px] font-black">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
{i < PIPELINE_STEPS.length - 1 && (
|
||||||
|
<div className="w-px h-full bg-gray-200 min-h-[40px]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="pb-6 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">{step.trigger}</span>
|
||||||
|
<span className="text-[9px] text-gray-400">{step.timing}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-[#111827]">{step.title}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{step.desc}</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
{activeChannels.map((ch, ci) => {
|
||||||
|
const key = ch!.toLowerCase() as keyof typeof CHANNEL_COLORS
|
||||||
|
return (
|
||||||
|
<span key={ci} className="text-[8px] font-bold px-1.5 py-0.5" style={{ backgroundColor: CHANNEL_COLORS[key] + "15", color: CHANNEL_COLORS[key] }}>
|
||||||
|
{ch}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{activeChannels.length > 1 && (
|
||||||
|
<span className="text-[8px] text-gray-400">waterfall</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-l-2 border-gray-200 pl-4 ml-[15px] py-3 text-xs text-gray-400">
|
||||||
|
Messages try <strong className="text-gray-600">WhatsApp first</strong>, then fall back to SMS, then Email. Donors who reply <code className="bg-gray-100 px-1 text-[10px] font-mono">PAID</code> skip all future reminders.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── D. Upcoming reminders ── */}
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
|
||||||
|
<Clock className="h-4 w-4 text-gray-400" /> Scheduled ({pending.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{pending.slice(0, 8).map(r => {
|
||||||
|
const when = new Date(r.scheduledAt)
|
||||||
|
const isToday = when.toDateString() === new Date().toDateString()
|
||||||
|
const isTomorrow = when.toDateString() === new Date(Date.now() + 86400000).toDateString()
|
||||||
|
const label = isToday ? "Today" : isTomorrow ? "Tomorrow" : when.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||||
|
const stepLabels = ["Receipt", "Reminder 1", "Reminder 2", "Final"]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="px-5 py-2.5 flex items-center gap-3">
|
||||||
|
<div className={`w-1.5 h-1.5 shrink-0 ${isToday ? "bg-[#F59E0B]" : "bg-gray-300"}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-[#111827] truncate">
|
||||||
|
<strong>{r.donorName || "Anonymous"}</strong> · {formatPence(r.amountPence)} · {stepLabels[r.step] || `Step ${r.step}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-bold shrink-0 ${isToday ? "text-[#F59E0B]" : "text-gray-400"}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── E. Recent messages ── */}
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
|
||||||
|
<Send className="h-4 w-4 text-gray-400" /> Recent messages
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Send className="h-6 w-6 text-gray-200 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400">No messages sent yet. Messages will appear here when donors start pledging.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-50 max-h-[400px] overflow-y-auto">
|
||||||
|
{history.map(msg => {
|
||||||
|
const ChannelIcon = CHANNEL_ICONS[msg.channel] || Mail
|
||||||
|
const color = CHANNEL_COLORS[msg.channel] || "#999"
|
||||||
|
const ago = getTimeAgo(new Date(msg.createdAt))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="px-5 py-2.5 flex items-center gap-3">
|
||||||
|
{/* Channel icon */}
|
||||||
|
<div className="w-6 h-6 flex items-center justify-center shrink-0" style={{ backgroundColor: color + "15" }}>
|
||||||
|
<ChannelIcon className="h-3 w-3" style={{ color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-[#111827] truncate">
|
||||||
|
<strong>{msg.donorName || "Anonymous"}</strong>
|
||||||
|
<span className="text-gray-400 mx-1">·</span>
|
||||||
|
{MSG_LABELS[msg.messageType] || msg.messageType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{msg.success ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-[#16A34A] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span title={msg.error || "Failed"}><X className="h-3.5 w-3.5 text-[#DC2626] shrink-0" /></span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<span className="text-[10px] text-gray-400 shrink-0 w-16 text-right">{ago}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Channel row ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChannelRow({ name, icon: Icon, color, active, detail, stats, features }: {
|
||||||
|
name: string; icon: typeof MessageCircle; color: string
|
||||||
|
active: boolean; detail: string
|
||||||
|
stats?: { sent: number; failed: number }
|
||||||
|
features: string[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center shrink-0" style={{ backgroundColor: active ? color + "15" : "#F3F4F6" }}>
|
||||||
|
<Icon className="h-4 w-4" style={{ color: active ? color : "#9CA3AF" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">{name}</p>
|
||||||
|
{active && (
|
||||||
|
<span className="text-[8px] font-bold px-1 py-0.5 flex items-center gap-0.5" style={{ backgroundColor: color + "15", color }}>
|
||||||
|
<Radio className="h-2 w-2" /> Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 truncate">{detail}</p>
|
||||||
|
</div>
|
||||||
|
{stats && active && (
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm font-black text-[#111827]">{stats.sent}</p>
|
||||||
|
<p className="text-[9px] text-gray-400">sent this week</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5 ml-11">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<span key={i} className="text-[9px] text-gray-500 bg-[#F9FAFB] px-2 py-1 flex items-center gap-1">
|
||||||
|
<Check className="h-2.5 w-2.5 text-[#16A34A]" /> {f}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getTimeAgo(date: Date): string {
|
||||||
|
const s = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||||
|
if (s < 60) return "just now"
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||||||
|
if (s < 604800) return `${Math.floor(s / 86400)}d ago`
|
||||||
|
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Link from "next/link"
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useSession, signOut } from "next-auth/react"
|
import { useSession, signOut } from "next-auth/react"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users } from "lucide-react"
|
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users, Zap } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +17,7 @@ const adminNavItems = [
|
|||||||
{ href: "/dashboard", label: "Home", icon: Home },
|
{ href: "/dashboard", label: "Home", icon: Home },
|
||||||
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
||||||
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
||||||
|
{ href: "/dashboard/automations", label: "Automations", icon: Zap },
|
||||||
{ href: "/dashboard/reports", label: "Reports", icon: FileText },
|
{ href: "/dashboard/reports", label: "Reports", icon: FileText },
|
||||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||||
]
|
]
|
||||||
@@ -44,6 +45,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
if (href === "/dashboard/community") return pathname === "/dashboard/community" || pathname === "/dashboard"
|
if (href === "/dashboard/community") return pathname === "/dashboard/community" || pathname === "/dashboard"
|
||||||
if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events")
|
if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events")
|
||||||
if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile")
|
if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile")
|
||||||
|
if (href === "/dashboard/automations") return pathname.startsWith("/dashboard/automations")
|
||||||
if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports")
|
if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports")
|
||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
||||||
Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy,
|
Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy,
|
||||||
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight,
|
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight,
|
||||||
Zap, Pencil
|
Zap, Pencil, Mail
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +48,8 @@ interface OrgSettings {
|
|||||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||||
stripeSecretKey: string; stripeWebhookSecret: string
|
stripeSecretKey: string; stripeWebhookSecret: string
|
||||||
|
emailProvider: string; emailApiKey: string; emailFromAddress: string; emailFromName: string
|
||||||
|
smsProvider: string; smsAccountSid: string; smsAuthToken: string; smsFromNumber: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamMember {
|
interface TeamMember {
|
||||||
@@ -364,6 +366,85 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* ▸ Email ─────────────────────────────── */}
|
||||||
|
<SettingRow
|
||||||
|
configured={!!settings.emailApiKey}
|
||||||
|
icon={<Mail className={`h-4 w-4 ${settings.emailApiKey ? "text-[#1E40AF]" : "text-gray-400"}`} />}
|
||||||
|
title="Email"
|
||||||
|
summary={settings.emailApiKey ? `${settings.emailProvider || "Resend"} · ${settings.emailFromAddress}` : "Send receipts and reminders by email"}
|
||||||
|
isOpen={open === "email"}
|
||||||
|
onToggle={() => toggle("email")}
|
||||||
|
optional
|
||||||
|
saving={saving === "email"}
|
||||||
|
saved={saved === "email"}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-l-2 border-[#1E40AF] pl-3 text-xs text-gray-500">
|
||||||
|
Send receipts and reminders to donors who don't have WhatsApp. Connect your <strong className="text-gray-700">own email provider</strong> — messages come from your domain.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-2">Provider</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["resend", "sendgrid"].map(p => (
|
||||||
|
<button key={p} onClick={() => update("emailProvider", p)} className={`px-4 py-2 text-xs font-bold border-2 transition-colors ${(settings.emailProvider || "resend") === p ? "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
|
||||||
|
{p === "resend" ? "Resend" : "SendGrid"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(settings.emailProvider || "resend") === "resend" && !settings.emailApiKey && (
|
||||||
|
<p className="text-[10px] text-gray-400 mt-2">Free: 3,000 emails/month at <a href="https://resend.com" target="_blank" rel="noopener noreferrer" className="text-[#1E40AF] font-bold hover:underline">resend.com</a></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Field label="API key" value={settings.emailApiKey || ""} onChange={v => update("emailApiKey", v)} placeholder={settings.emailProvider === "sendgrid" ? "SG.xxxxx" : "re_xxxxx"} type="password" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="From address" value={settings.emailFromAddress || ""} onChange={v => update("emailFromAddress", v)} placeholder="donations@mymosque.org" />
|
||||||
|
<Field label="From name" value={settings.emailFromName || ""} onChange={v => update("emailFromName", v)} placeholder="Al Furqan Mosque" />
|
||||||
|
</div>
|
||||||
|
<SaveRow
|
||||||
|
section="email" saving={saving} saved={saved}
|
||||||
|
onSave={() => save("email", { emailProvider: settings.emailProvider || "resend", emailApiKey: settings.emailApiKey || "", emailFromAddress: settings.emailFromAddress || "", emailFromName: settings.emailFromName || "" })}
|
||||||
|
onCancel={() => setOpen(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* ▸ SMS ──────────────────────────────── */}
|
||||||
|
<SettingRow
|
||||||
|
configured={!!settings.smsAccountSid}
|
||||||
|
icon={<Smartphone className={`h-4 w-4 ${settings.smsAccountSid ? "text-[#F59E0B]" : "text-gray-400"}`} />}
|
||||||
|
title="SMS"
|
||||||
|
summary={settings.smsAccountSid ? `Twilio · ${settings.smsFromNumber}` : "Text reminders for donors without WhatsApp"}
|
||||||
|
isOpen={open === "sms"}
|
||||||
|
onToggle={() => toggle("sms")}
|
||||||
|
optional
|
||||||
|
saving={saving === "sms"}
|
||||||
|
saved={saved === "sms"}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-l-2 border-[#F59E0B] pl-3 text-xs text-gray-500">
|
||||||
|
Send SMS reminders via <strong className="text-gray-700">Twilio</strong>. Reaches donors who don't have WhatsApp and haven't provided an email. Pay-as-you-go (~3p per SMS).
|
||||||
|
</div>
|
||||||
|
{!settings.smsAccountSid && (
|
||||||
|
<div className="bg-[#F9FAFB] border border-gray-100 p-3">
|
||||||
|
<p className="text-[10px] font-bold text-[#111827] mb-1">Get your Twilio credentials</p>
|
||||||
|
<ol className="text-[10px] text-gray-500 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Sign up at <a href="https://www.twilio.com" target="_blank" rel="noopener noreferrer" className="text-[#F59E0B] font-bold hover:underline">twilio.com</a></li>
|
||||||
|
<li>Copy your <strong className="text-gray-700">Account SID</strong> and <strong className="text-gray-700">Auth Token</strong> from the dashboard</li>
|
||||||
|
<li>Buy a phone number (or use the trial number)</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Field label="Account SID" value={settings.smsAccountSid || ""} onChange={v => update("smsAccountSid", v)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||||
|
<Field label="Auth token" value={settings.smsAuthToken || ""} onChange={v => update("smsAuthToken", v)} placeholder="Your auth token" type="password" />
|
||||||
|
<Field label="From number" value={settings.smsFromNumber || ""} onChange={v => update("smsFromNumber", v)} placeholder="+447123456789" />
|
||||||
|
<SaveRow
|
||||||
|
section="sms" saving={saving} saved={saved}
|
||||||
|
onSave={() => save("sms", { smsProvider: "twilio", smsAccountSid: settings.smsAccountSid || "", smsAuthToken: settings.smsAuthToken || "", smsFromNumber: settings.smsFromNumber || "" })}
|
||||||
|
onCancel={() => setOpen(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{/* ▸ Team ─────────────────────────────── */}
|
{/* ▸ Team ─────────────────────────────── */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TeamRow
|
<TeamRow
|
||||||
|
|||||||
113
pledge-now-pay-later/src/lib/email.ts
Normal file
113
pledge-now-pay-later/src/lib/email.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Email sending — per-org, bring your own key.
|
||||||
|
*
|
||||||
|
* Supported providers:
|
||||||
|
* - Resend (recommended — free tier: 3,000 emails/month)
|
||||||
|
* - SendGrid
|
||||||
|
*
|
||||||
|
* Each charity pastes their API key in Settings.
|
||||||
|
* Emails come from THEIR domain (e.g. donations@mymosque.org).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Resend } from "resend"
|
||||||
|
|
||||||
|
interface EmailConfig {
|
||||||
|
provider: string
|
||||||
|
apiKey: string
|
||||||
|
fromAddress: string
|
||||||
|
fromName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
success: boolean
|
||||||
|
messageId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email using the org's configured provider.
|
||||||
|
*/
|
||||||
|
export async function sendEmail(
|
||||||
|
config: EmailConfig,
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
text: string,
|
||||||
|
html?: string
|
||||||
|
): Promise<SendResult> {
|
||||||
|
if (!config.apiKey || !config.fromAddress) {
|
||||||
|
return { success: false, error: "Email not configured" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (config.provider === "resend") {
|
||||||
|
return await sendViaResend(config, to, subject, text, html)
|
||||||
|
}
|
||||||
|
if (config.provider === "sendgrid") {
|
||||||
|
return await sendViaSendGrid(config, to, subject, text, html)
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unknown provider: ${config.provider}` }
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[EMAIL]", err)
|
||||||
|
return { success: false, error: String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaResend(
|
||||||
|
config: EmailConfig, to: string, subject: string, text: string, html?: string
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const resend = new Resend(config.apiKey)
|
||||||
|
const result = await resend.emails.send({
|
||||||
|
from: `${config.fromName} <${config.fromAddress}>`,
|
||||||
|
to: [to],
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html: html || textToHtml(text),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { success: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
return { success: true, messageId: result.data?.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaSendGrid(
|
||||||
|
config: EmailConfig, to: string, subject: string, text: string, html?: string
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${config.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
personalizations: [{ to: [{ email: to }] }],
|
||||||
|
from: { email: config.fromAddress, name: config.fromName },
|
||||||
|
subject,
|
||||||
|
content: [
|
||||||
|
{ type: "text/plain", value: text },
|
||||||
|
{ type: "text/html", value: html || textToHtml(text) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok || res.status === 202) {
|
||||||
|
return { success: true, messageId: res.headers.get("x-message-id") || undefined }
|
||||||
|
}
|
||||||
|
const err = await res.text().catch(() => "Unknown error")
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert plain text to simple HTML email.
|
||||||
|
*/
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
const escaped = text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\n/g, "<br>")
|
||||||
|
.replace(/\*(.+?)\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/`(.+?)`/g, "<code style='background:#f3f4f6;padding:2px 6px;font-family:monospace'>$1</code>")
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;line-height:1.6;color:#111827;max-width:500px;margin:0 auto;padding:20px}code{background:#f3f4f6;padding:2px 6px;font-family:monospace}</style></head><body>${escaped}</body></html>`
|
||||||
|
}
|
||||||
307
pledge-now-pay-later/src/lib/messaging.ts
Normal file
307
pledge-now-pay-later/src/lib/messaging.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Unified Messaging Layer
|
||||||
|
*
|
||||||
|
* The BRAIN of the automation engine. Routes messages to donors
|
||||||
|
* across channels with intelligent fallback:
|
||||||
|
*
|
||||||
|
* WhatsApp (free, instant, 2-way)
|
||||||
|
* ↓ fallback
|
||||||
|
* SMS (paid, instant, 1-way)
|
||||||
|
* ↓ fallback
|
||||||
|
* Email (free/cheap, delayed, 1-way)
|
||||||
|
*
|
||||||
|
* Each message attempt is logged to AnalyticsEvent for the
|
||||||
|
* Automations dashboard to display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp"
|
||||||
|
import { sendEmail } from "@/lib/email"
|
||||||
|
import { sendSms } from "@/lib/sms"
|
||||||
|
import prisma from "@/lib/prisma"
|
||||||
|
|
||||||
|
export type Channel = "whatsapp" | "sms" | "email"
|
||||||
|
export type MessageType =
|
||||||
|
| "receipt"
|
||||||
|
| "reminder_1"
|
||||||
|
| "reminder_2"
|
||||||
|
| "reminder_3"
|
||||||
|
| "reminder_4"
|
||||||
|
| "overdue_notice"
|
||||||
|
| "payment_confirmed"
|
||||||
|
| "test"
|
||||||
|
|
||||||
|
interface OrgChannels {
|
||||||
|
whatsapp: boolean
|
||||||
|
email: { provider: string; apiKey: string; fromAddress: string; fromName: string } | null
|
||||||
|
sms: { provider: string; accountSid: string; authToken: string; fromNumber: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendOptions {
|
||||||
|
donorPhone?: string | null
|
||||||
|
donorEmail?: string | null
|
||||||
|
donorName?: string | null
|
||||||
|
whatsappOptIn?: boolean
|
||||||
|
emailOptIn?: boolean
|
||||||
|
messageType: MessageType
|
||||||
|
subject?: string
|
||||||
|
whatsappText: string
|
||||||
|
emailText: string
|
||||||
|
smsText: string
|
||||||
|
orgId: string
|
||||||
|
pledgeId?: string
|
||||||
|
eventId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
channel: Channel | "none"
|
||||||
|
success: boolean
|
||||||
|
messageId?: string
|
||||||
|
error?: string
|
||||||
|
fallbackAttempted?: Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the org's configured channels.
|
||||||
|
*/
|
||||||
|
export async function getOrgChannels(orgId: string): Promise<OrgChannels> {
|
||||||
|
if (!prisma) return { whatsapp: false, email: null, sms: null }
|
||||||
|
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { id: orgId },
|
||||||
|
select: {
|
||||||
|
whatsappConnected: true,
|
||||||
|
emailProvider: true, emailApiKey: true, emailFromAddress: true, emailFromName: true, name: true,
|
||||||
|
smsProvider: true, smsAccountSid: true, smsAuthToken: true, smsFromNumber: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!org) return { whatsapp: false, email: null, sms: null }
|
||||||
|
|
||||||
|
const waReady = org.whatsappConnected && await isWhatsAppReady()
|
||||||
|
|
||||||
|
return {
|
||||||
|
whatsapp: waReady,
|
||||||
|
email: org.emailProvider && org.emailApiKey && org.emailFromAddress
|
||||||
|
? { provider: org.emailProvider, apiKey: org.emailApiKey, fromAddress: org.emailFromAddress, fromName: org.emailFromName || org.name }
|
||||||
|
: null,
|
||||||
|
sms: org.smsProvider && org.smsAccountSid && org.smsAuthToken && org.smsFromNumber
|
||||||
|
? { provider: org.smsProvider, accountSid: org.smsAccountSid, authToken: org.smsAuthToken, fromNumber: org.smsFromNumber }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a donor via the best available channel.
|
||||||
|
*
|
||||||
|
* Priority: WhatsApp → SMS → Email
|
||||||
|
* Respects donor consent flags.
|
||||||
|
* Logs every attempt to AnalyticsEvent.
|
||||||
|
*/
|
||||||
|
export async function sendToDonor(opts: SendOptions): Promise<SendResult> {
|
||||||
|
const channels = await getOrgChannels(opts.orgId)
|
||||||
|
|
||||||
|
// Build priority list based on what's available AND what the donor has
|
||||||
|
const attempts: Array<{ channel: Channel; canSend: boolean }> = [
|
||||||
|
{
|
||||||
|
channel: "whatsapp",
|
||||||
|
canSend: channels.whatsapp && !!opts.donorPhone && opts.whatsappOptIn !== false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: "sms",
|
||||||
|
canSend: !!channels.sms && !!opts.donorPhone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: "email",
|
||||||
|
canSend: !!channels.email && !!opts.donorEmail && opts.emailOptIn !== false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let result: SendResult = { channel: "none", success: false, error: "No channel available" }
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
if (!attempt.canSend) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (attempt.channel === "whatsapp") {
|
||||||
|
const wa = await sendWhatsAppMessage(opts.donorPhone!, opts.whatsappText)
|
||||||
|
result = { channel: "whatsapp", success: wa.success, messageId: wa.messageId, error: wa.error }
|
||||||
|
} else if (attempt.channel === "sms" && channels.sms) {
|
||||||
|
const smsResult = await sendSms(channels.sms, opts.donorPhone!, opts.smsText)
|
||||||
|
result = { channel: "sms", success: smsResult.success, messageId: smsResult.messageId, error: smsResult.error }
|
||||||
|
} else if (attempt.channel === "email" && channels.email) {
|
||||||
|
const emailResult = await sendEmail(
|
||||||
|
channels.email,
|
||||||
|
opts.donorEmail!,
|
||||||
|
opts.subject || "Pledge update",
|
||||||
|
opts.emailText
|
||||||
|
)
|
||||||
|
result = { channel: "email", success: emailResult.success, messageId: emailResult.messageId, error: emailResult.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the attempt
|
||||||
|
await logMessage(opts, attempt.channel, result.success, result.error)
|
||||||
|
|
||||||
|
if (result.success) return result
|
||||||
|
|
||||||
|
// Failed — try next channel (add fallback note)
|
||||||
|
result.fallbackAttempted = attempt.channel
|
||||||
|
} catch (err) {
|
||||||
|
await logMessage(opts, attempt.channel, false, String(err))
|
||||||
|
result = { channel: attempt.channel, success: false, error: String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All channels exhausted
|
||||||
|
if (!result.success) {
|
||||||
|
await logMessage(opts, "none" as Channel, false, "All channels exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message attempt to AnalyticsEvent.
|
||||||
|
*/
|
||||||
|
async function logMessage(
|
||||||
|
opts: SendOptions,
|
||||||
|
channel: Channel | string,
|
||||||
|
success: boolean,
|
||||||
|
error?: string
|
||||||
|
) {
|
||||||
|
if (!prisma) return
|
||||||
|
try {
|
||||||
|
await prisma.analyticsEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: `message.${channel}.${opts.messageType}.${success ? "sent" : "failed"}`,
|
||||||
|
pledgeId: opts.pledgeId || null,
|
||||||
|
eventId: opts.eventId || null,
|
||||||
|
metadata: {
|
||||||
|
channel,
|
||||||
|
messageType: opts.messageType,
|
||||||
|
donorName: opts.donorName,
|
||||||
|
donorPhone: opts.donorPhone ? opts.donorPhone.slice(-4) : null, // privacy: last 4 digits
|
||||||
|
donorEmail: opts.donorEmail ? opts.donorEmail.replace(/(.{2}).*@/, "$1***@") : null,
|
||||||
|
success,
|
||||||
|
error: error || undefined,
|
||||||
|
orgId: opts.orgId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[MESSAGING] Log failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message history for the automations dashboard.
|
||||||
|
*/
|
||||||
|
export async function getMessageHistory(orgId: string, limit = 50): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
channel: string
|
||||||
|
messageType: string
|
||||||
|
donorName: string | null
|
||||||
|
success: boolean
|
||||||
|
error: string | null
|
||||||
|
createdAt: Date
|
||||||
|
}>> {
|
||||||
|
if (!prisma) return []
|
||||||
|
|
||||||
|
const events = await prisma.analyticsEvent.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: { startsWith: "message." },
|
||||||
|
metadata: { path: ["orgId"], equals: orgId },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return events.map(e => {
|
||||||
|
const m = e.metadata as Record<string, unknown> || {}
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
channel: String(m.channel || "unknown"),
|
||||||
|
messageType: String(m.messageType || "unknown"),
|
||||||
|
donorName: m.donorName as string | null,
|
||||||
|
success: m.success === true,
|
||||||
|
error: m.error as string | null,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channel stats for the automations dashboard.
|
||||||
|
*/
|
||||||
|
export async function getChannelStats(orgId: string, days = 7): Promise<{
|
||||||
|
whatsapp: { sent: number; failed: number }
|
||||||
|
email: { sent: number; failed: number }
|
||||||
|
sms: { sent: number; failed: number }
|
||||||
|
total: number
|
||||||
|
deliveryRate: number
|
||||||
|
}> {
|
||||||
|
if (!prisma) return { whatsapp: { sent: 0, failed: 0 }, email: { sent: 0, failed: 0 }, sms: { sent: 0, failed: 0 }, total: 0, deliveryRate: 0 }
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - days * 86400000)
|
||||||
|
|
||||||
|
const events = await prisma.analyticsEvent.findMany({
|
||||||
|
where: {
|
||||||
|
eventType: { startsWith: "message." },
|
||||||
|
metadata: { path: ["orgId"], equals: orgId },
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
select: { eventType: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = { whatsapp: { sent: 0, failed: 0 }, email: { sent: 0, failed: 0 }, sms: { sent: 0, failed: 0 } }
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
const parts = e.eventType.split(".")
|
||||||
|
const channel = parts[1] as keyof typeof stats
|
||||||
|
const status = parts[3]
|
||||||
|
if (stats[channel]) {
|
||||||
|
if (status === "sent") stats[channel].sent++
|
||||||
|
else if (status === "failed") stats[channel].failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = stats.whatsapp.sent + stats.email.sent + stats.sms.sent + stats.whatsapp.failed + stats.email.failed + stats.sms.failed
|
||||||
|
const sent = stats.whatsapp.sent + stats.email.sent + stats.sms.sent
|
||||||
|
const deliveryRate = total > 0 ? Math.round((sent / total) * 100) : 0
|
||||||
|
|
||||||
|
return { ...stats, total, deliveryRate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled/pending reminders for the automations dashboard.
|
||||||
|
*/
|
||||||
|
export async function getPendingReminders(orgId: string, limit = 20): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
donorName: string | null
|
||||||
|
amountPence: number
|
||||||
|
step: number
|
||||||
|
channel: string
|
||||||
|
scheduledAt: Date
|
||||||
|
}>> {
|
||||||
|
if (!prisma) return []
|
||||||
|
|
||||||
|
const reminders = await prisma.reminder.findMany({
|
||||||
|
where: {
|
||||||
|
status: "pending",
|
||||||
|
pledge: { organizationId: orgId, status: { notIn: ["paid", "cancelled"] } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
pledge: { select: { donorName: true, amountPence: true } },
|
||||||
|
},
|
||||||
|
orderBy: { scheduledAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return reminders.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
donorName: r.pledge.donorName,
|
||||||
|
amountPence: r.pledge.amountPence,
|
||||||
|
step: r.step,
|
||||||
|
channel: r.channel,
|
||||||
|
scheduledAt: r.scheduledAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
77
pledge-now-pay-later/src/lib/sms.ts
Normal file
77
pledge-now-pay-later/src/lib/sms.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* SMS sending — per-org, bring your own key.
|
||||||
|
*
|
||||||
|
* Supported providers:
|
||||||
|
* - Twilio
|
||||||
|
*
|
||||||
|
* Each charity pastes their Twilio credentials in Settings.
|
||||||
|
* SMS comes from THEIR number.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SmsConfig {
|
||||||
|
provider: string
|
||||||
|
accountSid: string
|
||||||
|
authToken: string
|
||||||
|
fromNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
success: boolean
|
||||||
|
messageId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an SMS using the org's configured provider.
|
||||||
|
*/
|
||||||
|
export async function sendSms(
|
||||||
|
config: SmsConfig,
|
||||||
|
to: string,
|
||||||
|
body: string
|
||||||
|
): Promise<SendResult> {
|
||||||
|
if (!config.accountSid || !config.authToken || !config.fromNumber) {
|
||||||
|
return { success: false, error: "SMS not configured" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (config.provider === "twilio") {
|
||||||
|
return await sendViaTwilio(config, to, body)
|
||||||
|
}
|
||||||
|
return { success: false, error: `Unknown provider: ${config.provider}` }
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[SMS]", err)
|
||||||
|
return { success: false, error: String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaTwilio(
|
||||||
|
config: SmsConfig, to: string, body: string
|
||||||
|
): Promise<SendResult> {
|
||||||
|
// Normalize UK number
|
||||||
|
let phone = to.replace(/[\s\-\(\)]/g, "")
|
||||||
|
if (phone.startsWith("0")) phone = "+44" + phone.slice(1)
|
||||||
|
if (!phone.startsWith("+")) phone = "+" + phone
|
||||||
|
|
||||||
|
const url = `https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`
|
||||||
|
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64")
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Basic ${auth}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
To: phone,
|
||||||
|
From: config.fromNumber,
|
||||||
|
Body: body,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.sid) {
|
||||||
|
return { success: true, messageId: data.sid }
|
||||||
|
}
|
||||||
|
return { success: false, error: data.message || "Twilio error" }
|
||||||
|
}
|
||||||
30
temp_files/v4/fix_tabs.py
Normal file
30
temp_files/v4/fix_tabs.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE = '/home/forge/app.charityright.org.uk'
|
||||||
|
|
||||||
|
pages = {
|
||||||
|
'app/Filament/Resources/CustomerResource/Pages/ListCustomers.php': 'all',
|
||||||
|
'app/Filament/Resources/DonationResource/Pages/ListDonations.php': 'today',
|
||||||
|
'app/Filament/Resources/AppealResource/Pages/ListAppeals.php': 'live',
|
||||||
|
'app/Filament/Resources/ApprovalQueueResource/Pages/ListApprovalQueues.php': 'pending',
|
||||||
|
'app/Filament/Resources/ScheduledGivingDonationResource/Pages/ListScheduledGivingDonations.php': 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
for rel_path, default_tab in pages.items():
|
||||||
|
path = os.path.join(BASE, rel_path)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
c = f.read()
|
||||||
|
|
||||||
|
if 'getDefaultActiveTab' not in c:
|
||||||
|
method = "\n public function getDefaultActiveTab(): string | int | null\n {\n return '" + default_tab + "';\n }\n"
|
||||||
|
last_brace = c.rstrip().rfind('}')
|
||||||
|
c = c[:last_brace] + method + '}\n'
|
||||||
|
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(c)
|
||||||
|
print('Added default tab "' + default_tab + '" to ' + os.path.basename(path))
|
||||||
|
else:
|
||||||
|
print('Already has default tab: ' + os.path.basename(path))
|
||||||
|
|
||||||
|
print('Done')
|
||||||
Reference in New Issue
Block a user