From b4e9dd38117a744f7aaf485d06ebf97114d8ebca Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 3 Aug 2026 11:53:29 +0100 Subject: [PATCH] feat(cloud): sync subscriber preferences on unsubscribe --- apps/web/package.json | 1 + apps/web/src/pages/api/unsubscribe.ts | 17 ++++++++++++ packages/auth/src/hooks.ts | 1 + packages/email/src/index.tsx | 5 +++- packages/email/src/subscriberClient.ts | 38 ++++++++++++++++++++++++++ pnpm-lock.yaml | 26 ++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 14889ab2..12df8124 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@kan/api": "workspace:*", "@kan/auth": "workspace:*", "@kan/db": "workspace:^", + "@kan/email": "workspace:^", "@kan/logger": "workspace:^", "@kan/shared": "workspace:^", "@lingui/babel-preset-react": "^2.9.2", diff --git a/apps/web/src/pages/api/unsubscribe.ts b/apps/web/src/pages/api/unsubscribe.ts index 1b1d56db..783f4d7a 100644 --- a/apps/web/src/pages/api/unsubscribe.ts +++ b/apps/web/src/pages/api/unsubscribe.ts @@ -5,9 +5,13 @@ import { z } from "zod"; import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { updateSubscriberPreferences } from "@kan/email"; +import { createLogger } from "@kan/logger"; import { env } from "~/env"; +const log = createLogger("unsubscribe"); + const requestSchema = z.object({ token: z.string().min(1), }); @@ -84,6 +88,7 @@ export default withRateLimit( const novu = new Novu({ secretKey: env.NOVU_API_KEY }); + let novuFailed = false; try { await novu.subscribers.preferences.update( { @@ -94,6 +99,18 @@ export default withRateLimit( payload.subscriberId, ); } catch (error) { + novuFailed = true; + } + + try { + await updateSubscriberPreferences(payload.subscriberId, { + email: false, + }); + } catch (error) { + log.error({ err: error }, "Error updating subscriber preferences"); + } + + if (novuFailed) { return res.status(502).json({ success: false, error: diff --git a/packages/auth/src/hooks.ts b/packages/auth/src/hooks.ts index 7ed41f85..80348c51 100644 --- a/packages/auth/src/hooks.ts +++ b/packages/auth/src/hooks.ts @@ -158,6 +158,7 @@ export function createDatabaseHooks(db: dbClient) { try { await createSubscriber({ + publicId: user.id, email: user.email, externalId: user.id, firstName, diff --git a/packages/email/src/index.tsx b/packages/email/src/index.tsx index 7aa3cdb4..e3397a33 100644 --- a/packages/email/src/index.tsx +++ b/packages/email/src/index.tsx @@ -2,4 +2,7 @@ export const name = "email"; export { sendEmail } from "./sendEmail"; export { notificationClient } from "./notificationClient"; -export { createSubscriber } from "./subscriberClient"; +export { + createSubscriber, + updateSubscriberPreferences, +} from "./subscriberClient"; diff --git a/packages/email/src/subscriberClient.ts b/packages/email/src/subscriberClient.ts index 00e68367..3ef4d8b3 100644 --- a/packages/email/src/subscriberClient.ts +++ b/packages/email/src/subscriberClient.ts @@ -15,6 +15,7 @@ export const subscriberClient = : null; interface CreateSubscriberInput { + publicId: string; email: string; externalId: string; firstName?: string; @@ -51,3 +52,40 @@ export async function createSubscriber(input: CreateSubscriberInput) { log.error({ err: error }, "Error creating subscriber.dev subscriber"); } } + +interface UpdateSubscriberPreferencesInput { + email: boolean; +} + +export async function updateSubscriberPreferences( + subscriberId: string, + input: UpdateSubscriberPreferencesInput, +) { + if (!subscriberClient) return; + + try { + const response = await fetch( + `${subscriberClient.apiUrl}/environments/${subscriberClient.environmentId}/subscribers/${subscriberId}/preferences`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-API-Key": subscriberClient.apiKey, + }, + body: JSON.stringify(input), + }, + ); + + if (!response.ok) { + log.error( + { + status: response.status, + body: await response.text().catch(() => undefined), + }, + "Failed to update subscriber preferences", + ); + } + } catch (error) { + log.error({ err: error }, "Error updating subscriber.dev preferences"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c4e18f9..ee571bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@kan/db': specifier: workspace:^ version: link:../../packages/db + '@kan/email': + specifier: workspace:^ + version: link:../../packages/email '@kan/logger': specifier: workspace:^ version: link:../../packages/logger @@ -3097,95 +3100,111 @@ packages: '@react-email/body@0.2.0': resolution: {integrity: sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.0': resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.0': resolution: {integrity: sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.13': resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@1.0.1': resolution: {integrity: sha512-HnL0Y/up61sOBQT2cQg9N/kCoW0bP727gDs2MkFWQYELg6+iIHidMDvENXFC0f1ZE6hTB+4t7sszptvTcJWsDA==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.9': resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.12': resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.15': resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.11': resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.11': resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.11': resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.12': resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.17': resolution: {integrity: sha512-6op3AfsBC9BJKkhG+eoMFRFWlr0/f3FYbtQrK+VhGzJocEAY0WINIFN+W8xzXr//3IL0K/aKtnH3FtpIuescQQ==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.13': resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -3199,18 +3218,21 @@ packages: '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.16': resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@2.0.1': resolution: {integrity: sha512-/xq0IDYVY7863xPY7cdI45Xoz7M6CnIQBJcQvbqN7MNVpopfH9f+mhjayV1JGfKaxlGWuxfLKhgi9T2shsnEFg==} engines: {node: '>=22.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@react-email/body': 0.2.0 '@react-email/button': 0.2.0 @@ -3249,6 +3271,7 @@ packages: '@react-email/text@0.1.5': resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -4296,6 +4319,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -4618,6 +4642,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -8734,6 +8759,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uvu@0.5.6: