diff --git a/.env.example b/.env.example index 6369a1d8..b7261c27 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,14 @@ NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key" # DISCORD_CLIENT_SECRET="your-discord-client-secret" # DISCORD_BOT_TOKEN="your-discord-bot-token" # DISCORD_GUILD_ID="your-discord-guild-id" + +# Optional: Email notifications via Resend +# RESEND_API_KEY="re_..." +# EMAIL_FROM="Mesh Multisig " +# EMAIL_REPLY_TO="support@your-domain.example" +# Optional: override notification email links. Required for localhost links when +# using `next start` locally (NODE_ENV is production). Defaults to +# http://localhost:3000 in `next dev` and the production site URL otherwise. +# NOTIFICATION_LINK_BASE_URL="http://localhost:3000" +# NOTIFICATION_DRAIN_SECRET="your-notification-drain-secret" +# NOTIFICATIONS_EMAIL_ENABLED="false" diff --git a/.github/workflows/trpc-integration-tests.yml b/.github/workflows/trpc-integration-tests.yml index bc7b2381..8a75c1f5 100644 --- a/.github/workflows/trpc-integration-tests.yml +++ b/.github/workflows/trpc-integration-tests.yml @@ -13,7 +13,7 @@ on: jobs: trpc-tests: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 services: postgres: @@ -46,8 +46,14 @@ jobs: node-version: 22 cache: npm + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + + - name: Generate Prisma client + run: npx prisma generate - name: Run database migrations run: npx prisma migrate deploy diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c6e8ca3d..bb06dec0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,7 +14,7 @@ jobs: unit-tests: name: Transaction builder unit tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout repository @@ -25,8 +25,14 @@ jobs: with: node-version: 22 + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + + - name: Generate Prisma client + run: npx prisma generate - name: Run transaction builder tests run: npm run test:ci -- --testPathPatterns="src/__tests__/tx-builders" diff --git a/.gitignore b/.gitignore index 2c85f24b..870d4384 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local +.env.playwright # bot ref client config (contains secret) scripts/bot-ref/bot-config.json diff --git a/Dockerfile.ci b/Dockerfile.ci index 2389dea2..827ab8e8 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -24,7 +24,10 @@ ENV NODE_OPTIONS=--experimental-wasm-modules COPY package.json package-lock.json* ./ COPY prisma ./prisma COPY prisma.config.ts ./ -RUN npm ci +RUN npm install -g "$(node -p 'require("./package.json").packageManager')" \ + && npm --version +RUN npm ci --ignore-scripts +RUN npx prisma generate # Copy full source for containerized CI runs. COPY . . diff --git a/docs/notification-center-plan.md b/docs/notification-center-plan.md new file mode 100644 index 00000000..1ad88681 --- /dev/null +++ b/docs/notification-center-plan.md @@ -0,0 +1,446 @@ +# Notification Center Implementation Plan + +## Goal + +Build a reusable notification center that can send email notifications when a wallet signer needs to act, starting with "signature required" notifications for pending multisig transactions and signable datum payloads. + +The first channel will be email via Resend. A signer only receives email if they have provided and verified an email address, so notification delivery is opt-in and does not block wallet or transaction creation. + +## Current Codebase Context + +- Wallet signer data is currently stored as parallel arrays on `Wallet` and `NewWallet` in `prisma/schema.prisma`: `signersAddresses`, `signersStakeKeys`, `signersDRepKeys`, and `signersDescriptions`. +- User records in `User` are keyed by wallet `address` and currently include `stakeAddress`, `drepKeyHash`, optional `nostrKey`, and `discordId`, but no email field. +- Pending transaction creation happens in multiple places: + - `src/hooks/useTransaction.ts` for in-app transaction creation. + - `src/server/api/routers/transactions.ts` for tRPC create/import. + - `src/lib/server/createPendingMultisigTransaction.ts` for server/API/bot/proxy transaction builders. + - `src/pages/api/v1/addTransaction.ts` for external transaction submission. +- Pending datum/signable creation happens through: + - `src/server/api/routers/signable.ts`. + - `src/pages/api/v1/submitDatum.ts`. +- Existing reminders are Discord-only and client-triggered: + - `src/components/pages/wallet/transactions/transaction-card.tsx`. + - `src/components/pages/wallet/signing/signable-card.tsx`. + - `src/components/pages/wallet/new-transaction/index.tsx`. +- Existing observability uses append-only `AuditLog`; notification delivery should follow the same audit-friendly posture. + +## Design Principles + +- Reusable first: notification orchestration should not know about Resend directly. +- Server-owned delivery: do not send transactional notifications from React components. +- Non-blocking: transaction/signable creation should succeed even if notification dispatch fails. +- Idempotent: one event-recipient-channel combination should not send duplicate emails. +- Consent-aware: only verified or explicit opt-in email addresses receive messages. +- Email-client realistic: HTML emails should include a plain text fallback and avoid decorative assets in the first version. + +## Phase 1: Email Identity and Preferences + +### Data model + +Add signer notification metadata without adding more parallel arrays to `Wallet`. + +Recommended Prisma models: + +```prisma +model SignerNotificationProfile { + id String @id @default(cuid()) + address String @unique + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([emailNormalized]) +} + +model NotificationPreference { + id String @id @default(cuid()) + address String + eventType String + channel String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([address, eventType, channel]) + @@index([address]) +} +``` + +Optional later model if wallet-specific routing is needed: + +```prisma +model WalletSignerNotificationSetting { + walletId String + signerAddress String + eventType String + channel String + enabled Boolean @default(true) + + @@id([walletId, signerAddress, eventType, channel]) + @@index([signerAddress]) + @@index([walletId]) +} +``` + +Why not add `signersEmails String[]`: + +- The existing signer arrays already need index alignment. Adding another parallel array would make wallet updates and imports more fragile. +- Email belongs to a signer identity and can be reused across wallets. +- Verification, opt-in, and unsubscribe state do not belong in `Wallet`. + +### User model relationship + +Do not rely only on `User.email`. + +`User` is only present for onboarded wallet users. Some signers can exist in wallet arrays before they have joined or created a user record. `SignerNotificationProfile` lets the notification center resolve email by signer address even before a full `User` profile exists. + +If desired, add `email` and `emailVerifiedAt` to `User` too, but treat `SignerNotificationProfile` as the delivery source of truth and keep it synchronized when the current user changes email. + +### Email verification + +Add a simple verification token model: + +```prisma +model EmailVerificationToken { + id String @id @default(cuid()) + address String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([address]) + @@index([expiresAt]) +} +``` + +Flow: + +1. Signer enters an email in their user/profile page or during invite acceptance. +2. Server validates and normalizes it. +3. Server stores a pending token hash and sends a verification email. +4. Clicking `/api/notifications/email/verify?token=...` sets `emailVerifiedAt`. +5. Only verified emails are eligible for signature-required notifications. + +## Phase 2: Notification Outbox + +Add an outbox so notification creation and notification delivery are separate concerns. + +Recommended Prisma model: + +```prisma +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} +``` + +Postgres notes: + +- Keep `idempotencyKey` unique so retries and concurrent trigger paths cannot duplicate sends. +- Index `status, nextAttemptAt` because the worker/drain endpoint will repeatedly query pending rows. +- Index recipient, wallet, and resource columns because the notification center UI will filter by those fields. +- Consider a partial index for pending rows in a SQL migration if Prisma does not express the exact desired index well: + +```sql +create index "NotificationDelivery_pending_idx" +on "NotificationDelivery" ("nextAttemptAt", "createdAt") +where "status" in ('pending', 'retrying'); +``` + +## Phase 3: Notification Library Layout + +Create a reusable module under `src/lib/notifications/`. + +Recommended structure: + +```text +src/lib/notifications/ + center.ts + events.ts + recipients.ts + outbox.ts + templates/ + shared.ts + signatureRequired.ts + verifyEmail.ts + channels/ + email/ + resend.ts + types.ts + worker.ts +``` + +Responsibilities: + +- `events.ts`: event names, payload types, and resource metadata. +- `recipients.ts`: resolve signer addresses to verified email profiles and preferences. +- `outbox.ts`: create idempotent `NotificationDelivery` rows. +- `center.ts`: public API used by wallet, transaction, signable, and future features. +- `templates/*`: pure functions that return `{ subject, html, text }`. +- `channels/email/resend.ts`: the only place that imports the Resend SDK. +- `worker.ts`: drains pending deliveries, handles retry/backoff, and records provider responses. + +Public API sketch: + +```ts +await notificationCenter.enqueueSignatureRequired({ + walletId, + walletName, + resourceType: "transaction", + resourceId: transaction.id, + requiredSignerAddresses, + alreadySignedAddresses, + createdByAddress, + actionUrl, + description, +}); +``` + +The reusable lower-level API should support future features: + +```ts +await notificationCenter.enqueue({ + eventType: "signature.required", + channel: "email", + recipientAddress, + resourceType, + resourceId, + walletId, + payload, +}); +``` + +## Phase 4: Resend Integration + +Install: + +```bash +npm install resend +``` + +Add server env vars in `src/env.js` and `.env.example`: + +```text +RESEND_API_KEY= +EMAIL_FROM="Mesh Multisig " +EMAIL_REPLY_TO= +NOTIFICATION_DRAIN_SECRET= +``` + +Implementation details: + +- Use `new Resend(env.RESEND_API_KEY)` inside `src/lib/notifications/channels/email/resend.ts`. +- Send with `from`, `to`, `subject`, `html`, and `text`. +- Store Resend's returned message id on `NotificationDelivery.providerMessageId`. +- Use outbox-level idempotency before provider calls. If the Resend SDK supports passing an HTTP idempotency header in the current version at implementation time, also pass the delivery idempotency key to Resend. +- Tag messages when possible with stable ASCII tags such as `event:signature_required`, `resource:transaction`, and `wallet:` for provider-side filtering. + +## Phase 5: Signature Required Triggering + +### Recipient resolver + +For each wallet action, compute: + +```ts +requiredSignerAddresses = + wallet.signersAddresses + .filter((address) => !signedAddresses.includes(address)) + .filter((address) => !rejectedAddresses.includes(address)) + .filter((address) => address !== createdByAddress); +``` + +Notes: + +- For `type === "any"` or `numRequiredSigners === 1`, there may be no pending notification because the transaction can submit immediately. +- For `atLeast`, notify unsigned signers until the threshold is met. After threshold completion and submission, do not send further reminders. +- For `all`, notify every unsigned signer. + +### Hook points + +Add notification enqueue calls after a pending row is successfully created. + +Primary hook points: + +- `src/lib/server/createPendingMultisigTransaction.ts`: notify for server-built pending transactions used by bot, proxy, staking, governance, and v1 flows. +- `src/server/api/routers/transactions.ts`: notify for tRPC-created and imported pending transactions. Longer term, consider routing all creation through the server helper so this is not duplicated. +- `src/pages/api/v1/addTransaction.ts`: if it does not route through the helper in every pending path, add notification enqueue after DB create. +- `src/server/api/routers/signable.ts`: notify for `createSignable` when `state === 0`. +- `src/pages/api/v1/submitDatum.ts`: notify for API-created `Signable` rows. + +Avoid adding new email logic to: + +- `src/components/pages/wallet/new-transaction/index.tsx` +- `src/components/pages/wallet/transactions/transaction-card.tsx` +- `src/components/pages/wallet/signing/signable-card.tsx` + +Those should eventually call a server "send reminder" mutation or endpoint if manual reminders remain. + +## Phase 6: Notification Center UI + +Add a notification/preferences section to `src/pages/user/index.tsx`. + +Minimum UI: + +- Email address field. +- Verification status. +- "Send verification email" action. +- Channel preferences: + - Signature required for transactions. + - Signature required for datum/signable payloads. +- Opt out action. + +Add a wallet-level notification center later under wallet info if needed: + +- Show which signers have email enabled without revealing full emails to other signers. +- Show delivery status for recent notifications: + - pending + - sent + - retrying + - failed + - skipped-no-email + - skipped-not-verified + - skipped-opted-out + +## Phase 7: Email Template Design + +Use HTML emails with table-safe layout and inline styles. + +Template requirements: + +- `subject`: clear and action-oriented, for example `Signature required: `. +- `text`: plain text fallback with wallet name, action link, and why the user is receiving it. +- `html`: branded transactional email with: + - wallet name + - resource type + - transaction/signable description if present + - signer progress, for example `1 of 3 signatures collected` + - CTA button to open `/wallets//transactions` or `/wallets//signing` + - unsubscribe/preferences link + +Keep the first version simple: + +- Use HTML and inline CSS only. +- Do not include animated SVGs or embedded SVG markup. +- Do not include JavaScript or external decorative assets. +- Keep all critical CTA content as readable HTML text/buttons. +- Test in Gmail, Apple Mail, and Outlook before production rollout. + +Suggested template files: + +```text +src/lib/notifications/templates/signatureRequired.ts +src/lib/notifications/templates/verifyEmail.ts +src/lib/notifications/templates/shared.ts +``` + +## Phase 8: Delivery Worker + +Start simple: + +- After enqueueing notifications, call a best-effort `drainNotificationOutbox({ limit: 10 })` server-side. +- Add `src/pages/api/notifications/drain.ts` protected by `NOTIFICATION_DRAIN_SECRET`. +- Configure a scheduled job later to call the drain endpoint every few minutes. + +Retry behavior: + +- Attempt 1 immediately. +- Retry after 5 minutes, 30 minutes, then 2 hours. +- Mark `failed` after a small max attempt count, for example 5. +- Store short error strings only; do not store provider secrets or full request bodies. + +## Phase 9: Manual Reminders + +Replace Discord-only client reminders with a server endpoint/mutation: + +```ts +api.notification.sendSignatureReminder.useMutation(...) +``` + +Rules: + +- Caller must be a wallet signer or owner. +- Recipient must be a wallet signer. +- Recipient must still need to sign. +- Apply rate limits per `walletId + resourceId + recipientAddress`. +- Enqueue `signature.reminder` using the same channel/template stack. + +Keep Discord as optional later channel if desired, but route it through the same notification center rather than calling `sendDiscordMessage` directly from components. + +## Phase 10: Testing + +Unit tests: + +- recipient resolution excludes already signed, rejected, and creator addresses. +- unverified email is skipped. +- opted-out signer is skipped. +- idempotency key prevents duplicate delivery rows. +- email templates escape dynamic values and include text fallback. + +Integration tests: + +- pending tRPC transaction creates notification deliveries. +- server-built pending transaction through `createPendingMultisigTransaction` creates notification deliveries. +- `submitDatum` creates signable notification deliveries. +- drain worker calls the Resend adapter once per pending delivery and records message ids. +- Resend failure moves row to retrying without breaking transaction creation. + +Manual QA: + +- Create a 2-of-3 wallet with one signer email verified and one missing email. +- Create a pending transaction. +- Confirm one email delivery and one skipped/no-email outcome. +- Sign with another signer until threshold is met. +- Confirm no more signature-required notifications are created. +- Open the email in Gmail, Apple Mail, and Outlook. + +## Phase 11: Rollout + +1. Add schema and env validation. +2. Add profile/preference UI and email verification. +3. Add outbox and Resend adapter behind feature flag `NOTIFICATIONS_EMAIL_ENABLED`. +4. Add transaction/signable enqueue hooks. +5. Enable drain endpoint in staging. +6. Send test notifications from staging domain. +7. Enable production for verified internal/test signers. +8. Remove or migrate client-side Discord reminder calls after email path is stable. + +## Open Questions + +- Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves? +- Should emails be global per signer address or configurable per wallet? +- Should notification history be visible to all wallet signers or only to the recipient/current user? +- What production sending domain should be verified in Resend? +- Should Discord remain as a supported channel after email launches? + +## External References + +- Resend Node.js quickstart: https://resend.com/docs/send-with-nodejs +- Resend Send Email API: https://resend.com/docs/api-reference/emails/send-email diff --git a/package-lock.json b/package-lock.json index 002958a7..c5a59011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", @@ -6775,6 +6776,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -12420,6 +12427,12 @@ "dev": true, "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/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -19634,6 +19647,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -20896,6 +20915,27 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/resend": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -21563,6 +21603,16 @@ "node": ">=8" } }, + "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/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", diff --git a/package.json b/package.json index c2c0c07c..808a7d37 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", diff --git a/prisma/migrations/20260617070000_add_notification_center/migration.sql b/prisma/migrations/20260617070000_add_notification_center/migration.sql new file mode 100644 index 00000000..9b7fb7f0 --- /dev/null +++ b/prisma/migrations/20260617070000_add_notification_center/migration.sql @@ -0,0 +1,97 @@ +-- CreateTable +CREATE TABLE "WalletSignerNotificationSetting" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "email" TEXT, + "emailNormalized" TEXT, + "emailVerifiedAt" TIMESTAMP(3), + "emailOptIn" BOOLEAN NOT NULL DEFAULT true, + "notifyTransactionSignatures" BOOLEAN NOT NULL DEFAULT true, + "notifySignableSignatures" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WalletSignerNotificationSetting_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmailVerificationToken" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "emailNormalized" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationDelivery" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "recipientAddress" TEXT NOT NULL, + "recipientEmail" TEXT, + "resourceType" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "walletId" TEXT, + "idempotencyKey" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "provider" TEXT, + "providerMessageId" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSignerNotificationSetting_walletId_signerAddress_key" ON "WalletSignerNotificationSetting"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_walletId_idx" ON "WalletSignerNotificationSetting"("walletId"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_signerAddress_idx" ON "WalletSignerNotificationSetting"("signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_emailNormalized_idx" ON "WalletSignerNotificationSetting"("emailNormalized"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailVerificationToken_tokenHash_key" ON "EmailVerificationToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_walletId_signerAddress_idx" ON "EmailVerificationToken"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_expiresAt_idx" ON "EmailVerificationToken"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationDelivery_idempotencyKey_key" ON "NotificationDelivery"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_status_nextAttemptAt_idx" ON "NotificationDelivery"("status", "nextAttemptAt"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_recipientAddress_idx" ON "NotificationDelivery"("recipientAddress"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_walletId_idx" ON "NotificationDelivery"("walletId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_resourceType_resourceId_idx" ON "NotificationDelivery"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_pending_idx" +ON "NotificationDelivery" ("nextAttemptAt", "createdAt") +WHERE "status" IN ('pending', 'retrying'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f753e5b2..5bbc49bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -211,6 +211,67 @@ model Contact { @@index([address]) } +model WalletSignerNotificationSetting { + id String @id @default(cuid()) + walletId String + signerAddress String + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + notifyTransactionSignatures Boolean @default(true) + notifySignableSignatures Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([walletId, signerAddress]) + @@index([walletId]) + @@index([signerAddress]) + @@index([emailNormalized]) +} + +model EmailVerificationToken { + id String @id @default(cuid()) + walletId String + signerAddress String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([walletId, signerAddress]) + @@index([expiresAt]) +} + +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} + model BotKey { id String @id @default(cuid()) ownerAddress String // Human creator diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 00000000..ac8803ef --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { + NOTIFICATION_STATUS_SKIPPED_DISABLED, + NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, +} from "@/lib/notifications/events"; +import { resolveSignatureRecipients } from "@/lib/notifications/recipients"; +import { renderSignatureRequiredEmail } from "@/lib/notifications/templates/signatureRequired"; + +describe("notification recipient resolution", () => { + it("returns only verified opted-in signers that still need to sign", async () => { + const db = { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => [ + { + signerAddress: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_unverified", + email: "unverified@example.com", + emailNormalized: "unverified@example.com", + emailVerifiedAt: null, + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_opted_out", + email: "out@example.com", + emailNormalized: "out@example.com", + emailVerifiedAt: new Date(), + emailOptIn: false, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_disabled", + email: "disabled@example.com", + emailNormalized: "disabled@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: false, + notifySignableSignatures: true, + }, + ]), + }, + }; + + const result = await resolveSignatureRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: [ + "addr_creator", + "addr_signed", + "addr_rejected", + "addr_verified", + "addr_unverified", + "addr_opted_out", + "addr_disabled", + "addr_missing", + ], + resourceType: "transaction", + signedAddresses: ["addr_signed"], + rejectedAddresses: ["addr_rejected"], + creatorAddress: "addr_creator", + }); + + expect(result.eligible).toEqual([ + { + address: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + }, + ]); + expect(result.skipped).toEqual( + expect.arrayContaining([ + { + address: "addr_unverified", + reason: NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + }, + { + address: "addr_opted_out", + reason: NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, + }, + { + address: "addr_disabled", + reason: NOTIFICATION_STATUS_SKIPPED_DISABLED, + }, + { + address: "addr_missing", + reason: NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + }, + ]), + ); + }); +}); + +describe("notification email templates", () => { + it("escapes dynamic values and returns html plus text bodies", () => { + const email = renderSignatureRequiredEmail({ + walletName: "", + resourceType: "transaction", + description: "", + signedCount: 1, + requiredCount: 2, + totalSigners: 3, + actionUrl: "https://example.com/sign?x=", + preferencesUrl: "https://example.com/preferences", + }); + + expect(email.subject).toBe("Signature required: "); + expect(email.html).toContain("<Vault>"); + expect(email.html).toContain("<script>alert('x')</script>"); + expect(email.html).not.toContain("