feat(cloud): replace novu for all notification triggers
This commit is contained in:
@@ -34,7 +34,6 @@
|
|||||||
"@lingui/conf": "^5.3.2",
|
"@lingui/conf": "^5.3.2",
|
||||||
"@lingui/macro": "^5.3.2",
|
"@lingui/macro": "^5.3.2",
|
||||||
"@lingui/react": "^5.3.2",
|
"@lingui/react": "^5.3.2",
|
||||||
"@novu/api": "^3.11.0",
|
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
|||||||
@@ -52,11 +52,9 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
NOVU_API_KEY: z.string().optional(),
|
|
||||||
SUBSCRIBER_API_URL: z.string().url().optional(),
|
SUBSCRIBER_API_URL: z.string().url().optional(),
|
||||||
SUBSCRIBER_API_KEY: z.string().optional(),
|
SUBSCRIBER_API_KEY: z.string().optional(),
|
||||||
SUBSCRIBER_ENVIRONMENT_ID: z.string().optional(),
|
SUBSCRIBER_ENVIRONMENT_ID: z.string().optional(),
|
||||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
|
||||||
// Generic OIDC Provider
|
// Generic OIDC Provider
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { Novu } from "@novu/api";
|
|
||||||
import { jwtVerify } from "jose";
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenPayloadSchema = z.object({
|
|
||||||
subscriberId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ResponseData =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; error: string; code?: string };
|
|
||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
withApiLogging(
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unsubscribe endpoint is not available.",
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
return res.status(405).json({
|
|
||||||
success: false,
|
|
||||||
error: "Method not allowed.",
|
|
||||||
code: "METHOD_NOT_ALLOWED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = requestSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Invalid request payload.",
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unsubscribe service is not configured.",
|
|
||||||
code: "NOT_CONFIGURED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await jwtVerify(
|
|
||||||
parsedBody.data.token,
|
|
||||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
|
||||||
{
|
|
||||||
// We intentionally do not use exp/iat claims –
|
|
||||||
// tokens are long-lived and validated only by signature + payload.
|
|
||||||
clockTolerance: "0s",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
payload = tokenPayloadSchema.parse(verified.payload);
|
|
||||||
} catch {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: "Your unsubscribe link is invalid or has expired.",
|
|
||||||
code: "INVALID_TOKEN",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
|
||||||
|
|
||||||
let novuFailed = false;
|
|
||||||
try {
|
|
||||||
await novu.subscribers.preferences.update(
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
email: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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:
|
|
||||||
"We could not update your email preferences right now. Please try again later.",
|
|
||||||
code: "NOVU_ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ success: true });
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
|
||||||
|
|
||||||
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
|
|
||||||
|
|
||||||
export default function UnsubscribePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
const value = router.query.token;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
setToken(value);
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
setToken(value[0] ?? "");
|
|
||||||
} else {
|
|
||||||
setToken("");
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query.token]);
|
|
||||||
|
|
||||||
const handleUnsubscribe = async () => {
|
|
||||||
if (!token) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("processing");
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/unsubscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
payload?.error ??
|
|
||||||
"We couldn't update your preferences. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("success");
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`We couldn't update your preferences. Please try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = t`Unsubscribe`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHead title={`${title} | kan.bn`} />
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
|
||||||
<PatternedBackground />
|
|
||||||
<div className="z-10 w-full max-w-md space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
|
||||||
{t`Do you want to unsubscribe?`}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`Confirm your email preferences:`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={handleUnsubscribe}
|
|
||||||
disabled={status === "success"}
|
|
||||||
isLoading={status === "processing"}
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{t`Unsubscribe`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{status === "success" && (
|
|
||||||
<p className="text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`You have been unsubscribed!`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -61,9 +61,6 @@ services:
|
|||||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
- NOVU_API_KEY=${NOVU_API_KEY}
|
|
||||||
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
|
|
||||||
- EMAIL_UNSUBSCRIBE_SECRET=${EMAIL_UNSUBSCRIBE_SECRET}
|
|
||||||
- SUBSCRIBER_API_URL=${SUBSCRIBER_API_URL}
|
- SUBSCRIBER_API_URL=${SUBSCRIBER_API_URL}
|
||||||
- SUBSCRIBER_API_KEY=${SUBSCRIBER_API_KEY}
|
- SUBSCRIBER_API_KEY=${SUBSCRIBER_API_KEY}
|
||||||
- SUBSCRIBER_ENVIRONMENT_ID=${SUBSCRIBER_ENVIRONMENT_ID}
|
- SUBSCRIBER_ENVIRONMENT_ID=${SUBSCRIBER_ENVIRONMENT_ID}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { env } from "next-runtime-env";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
|
||||||
|
import { createDatabaseHooks } from "./hooks";
|
||||||
|
|
||||||
vi.mock("next-runtime-env", () => ({
|
vi.mock("next-runtime-env", () => ({
|
||||||
env: vi.fn(),
|
env: vi.fn(),
|
||||||
@@ -15,11 +20,11 @@ vi.mock("@kan/db/repository/user.repo", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@kan/email", () => ({
|
vi.mock("@kan/email", () => ({
|
||||||
notificationClient: null,
|
createSubscriber: vi.fn(),
|
||||||
|
triggerSubscriberWorkflow: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@kan/shared", () => ({
|
vi.mock("@kan/shared", () => ({
|
||||||
createEmailUnsubscribeLink: vi.fn(),
|
|
||||||
createS3Client: vi.fn(),
|
createS3Client: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -27,17 +32,10 @@ vi.mock("@aws-sdk/client-s3", () => ({
|
|||||||
PutObjectCommand: vi.fn(),
|
PutObjectCommand: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@novu/api/models/components", () => ({
|
|
||||||
ChatOrPushProviderEnum: { Discord: "discord" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
|
||||||
import { createDatabaseHooks } from "./hooks";
|
|
||||||
|
|
||||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||||
const mockGetByEmailAndStatus =
|
const mockGetByEmailAndStatus = memberRepo.getByEmailAndStatus as ReturnType<
|
||||||
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
const db = {} as Parameters<typeof createDatabaseHooks>[0];
|
const db = {} as Parameters<typeof createDatabaseHooks>[0];
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { createSubscriber, notificationClient } from "@kan/email";
|
import { createSubscriber, triggerSubscriberWorkflow } from "@kan/email";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
|
import { createS3Client } from "@kan/shared";
|
||||||
|
|
||||||
import { downloadImage } from "./utils";
|
import { downloadImage } from "./utils";
|
||||||
|
|
||||||
@@ -94,80 +93,49 @@ export function createDatabaseHooks(db: dbClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationClient) {
|
const [firstName, ...rest] = (user.name || "")
|
||||||
const [firstName, ...rest] = (user.name || "")
|
.split(" ")
|
||||||
.split(" ")
|
.filter(Boolean);
|
||||||
.filter(Boolean);
|
const lastName = rest.length ? rest.join(" ") : undefined;
|
||||||
const lastName = rest.length ? rest.join(" ") : undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const avatarUrl = avatarKey
|
const avatarUrl = avatarKey
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
await createSubscriber({
|
||||||
|
publicId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
externalId: user.id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
name: user.name,
|
||||||
|
attributes: {
|
||||||
|
avatarUrl,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
stripeCustomerId: user.stripeCustomerId,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ err: error }, "Error creating subscriber");
|
||||||
|
}
|
||||||
|
|
||||||
log.info(
|
try {
|
||||||
{
|
log.info(
|
||||||
workflowId: "user-signup",
|
{ workflowId: "user-signup", userId: user.id, email: user.email },
|
||||||
userId: user.id,
|
"Triggering user-signup workflow",
|
||||||
email: user.email,
|
);
|
||||||
},
|
await triggerSubscriberWorkflow("user-signup", {
|
||||||
"Triggering Novu workflow",
|
publicId: user.id,
|
||||||
);
|
});
|
||||||
await notificationClient.trigger({
|
log.info(
|
||||||
to: {
|
{ workflowId: "user-signup", userId: user.id },
|
||||||
subscriberId: user.id,
|
"user-signup workflow triggered",
|
||||||
firstName: firstName,
|
);
|
||||||
lastName: lastName,
|
} catch (error) {
|
||||||
email: user.email,
|
log.error({ err: error }, "Error triggering user-signup workflow");
|
||||||
avatar: avatarUrl,
|
|
||||||
data: {
|
|
||||||
emailVerified: user.emailVerified,
|
|
||||||
stripeCustomerId: user.stripeCustomerId,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
emailUnsubscribeUrl: unsubscribeUrl,
|
|
||||||
},
|
|
||||||
workflowId: "user-signup",
|
|
||||||
});
|
|
||||||
log.info(
|
|
||||||
{ workflowId: "user-signup", userId: user.id },
|
|
||||||
"Novu workflow triggered",
|
|
||||||
);
|
|
||||||
|
|
||||||
await notificationClient.subscribers.credentials.update(
|
|
||||||
{
|
|
||||||
providerId: ChatOrPushProviderEnum.Discord,
|
|
||||||
credentials: {
|
|
||||||
webhookUrl: env("DISCORD_WEBHOOK_URL"),
|
|
||||||
},
|
|
||||||
integrationIdentifier: "discord",
|
|
||||||
},
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
{ err: error },
|
|
||||||
"Error adding user to notification client",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createSubscriber({
|
|
||||||
publicId: user.id,
|
|
||||||
email: user.email,
|
|
||||||
externalId: user.id,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
name: user.name,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error({ err: error }, "Error creating subscriber");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import type Stripe from "stripe";
|
|||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { notificationClient } from "@kan/email";
|
import { triggerSubscriberWorkflow } from "@kan/email";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
|
||||||
|
|
||||||
const log = createLogger("auth");
|
const log = createLogger("auth");
|
||||||
|
|
||||||
@@ -24,30 +23,22 @@ export async function triggerWorkflow(
|
|||||||
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (!subscription.stripeCustomerId || !notificationClient) return;
|
if (!subscription.stripeCustomerId) return;
|
||||||
|
|
||||||
const user = await userRepo.getByStripeCustomerId(
|
const user = await userRepo.getByStripeCustomerId(
|
||||||
db,
|
db,
|
||||||
subscription.stripeCustomerId,
|
subscription.stripeCustomerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user || !notificationClient) return;
|
if (!user) return;
|
||||||
|
|
||||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
log.info({ workflowId, userId: user.id }, "Triggering workflow");
|
||||||
|
await triggerSubscriberWorkflow(
|
||||||
log.info({ workflowId, userId: user.id }, "Triggering Novu workflow");
|
|
||||||
await notificationClient.trigger({
|
|
||||||
to: {
|
|
||||||
subscriberId: user.id,
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
...subscription,
|
|
||||||
cancellationDetails,
|
|
||||||
emailUnsubscribeUrl: unsubscribeUrl,
|
|
||||||
},
|
|
||||||
workflowId,
|
workflowId,
|
||||||
});
|
{ publicId: user.id },
|
||||||
log.info({ workflowId, userId: user.id }, "Novu workflow triggered");
|
{ ...subscription, cancellationDetails },
|
||||||
|
);
|
||||||
|
log.info({ workflowId, userId: user.id }, "Workflow triggered");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error({ err: error, workflowId }, "Error triggering workflow");
|
log.error({ err: error, workflowId }, "Error triggering workflow");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kan/logger": "workspace:^",
|
"@kan/logger": "workspace:^",
|
||||||
"@novu/api": "^3.11.0",
|
|
||||||
"@react-email/components": "^1.0.1",
|
"@react-email/components": "^1.0.1",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"react-email": "^5.0.6"
|
"react-email": "^5.0.6"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export const name = "email";
|
export const name = "email";
|
||||||
|
|
||||||
export { sendEmail } from "./sendEmail";
|
export { sendEmail } from "./sendEmail";
|
||||||
export { notificationClient } from "./notificationClient";
|
|
||||||
export {
|
export {
|
||||||
createSubscriber,
|
createSubscriber,
|
||||||
updateSubscriberPreferences,
|
updateSubscriberPreferences,
|
||||||
|
triggerSubscriberWorkflow,
|
||||||
} from "./subscriberClient";
|
} from "./subscriberClient";
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { Novu } from "@novu/api";
|
|
||||||
|
|
||||||
export const notificationClient =
|
|
||||||
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && process.env.NOVU_API_KEY
|
|
||||||
? new Novu({ secretKey: process.env.NOVU_API_KEY })
|
|
||||||
: null;
|
|
||||||
@@ -14,6 +14,46 @@ export const subscriberClient =
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
async function subscriberRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
errorMessage: string,
|
||||||
|
) {
|
||||||
|
if (!subscriberClient) return;
|
||||||
|
|
||||||
|
const url = `${subscriberClient.apiUrl}${path}`;
|
||||||
|
|
||||||
|
log.debug({ method, url, body }, "subscriber.dev request");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": subscriberClient.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.text().catch(() => undefined);
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
{ method, url, status: response.status, body: responseBody },
|
||||||
|
"subscriber.dev response",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log.error(
|
||||||
|
{ status: response.status, body: responseBody },
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ err: error }, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateSubscriberInput {
|
interface CreateSubscriberInput {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,36 +61,18 @@ interface CreateSubscriberInput {
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSubscriber(input: CreateSubscriberInput) {
|
export async function createSubscriber(input: CreateSubscriberInput) {
|
||||||
if (!subscriberClient) return;
|
if (!subscriberClient) return;
|
||||||
|
|
||||||
try {
|
await subscriberRequest(
|
||||||
const response = await fetch(
|
"POST",
|
||||||
`${subscriberClient.apiUrl}/environments/${subscriberClient.environmentId}/subscribers`,
|
`/environments/${subscriberClient.environmentId}/subscribers`,
|
||||||
{
|
input,
|
||||||
method: "POST",
|
"Failed to create subscriber.dev subscriber",
|
||||||
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 create subscriber.dev subscriber",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error({ err: error }, "Error creating subscriber.dev subscriber");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateSubscriberPreferencesInput {
|
interface UpdateSubscriberPreferencesInput {
|
||||||
@@ -63,29 +85,31 @@ export async function updateSubscriberPreferences(
|
|||||||
) {
|
) {
|
||||||
if (!subscriberClient) return;
|
if (!subscriberClient) return;
|
||||||
|
|
||||||
try {
|
await subscriberRequest(
|
||||||
const response = await fetch(
|
"PATCH",
|
||||||
`${subscriberClient.apiUrl}/environments/${subscriberClient.environmentId}/subscribers/${subscriberId}/preferences`,
|
`/environments/${subscriberClient.environmentId}/subscribers/${subscriberId}/preferences`,
|
||||||
{
|
input,
|
||||||
method: "PATCH",
|
"Failed to update subscriber preferences",
|
||||||
headers: {
|
);
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
"X-API-Key": subscriberClient.apiKey,
|
|
||||||
},
|
interface TriggerWorkflowSubscriberInput {
|
||||||
body: JSON.stringify(input),
|
publicId?: string;
|
||||||
},
|
externalId?: string;
|
||||||
);
|
email?: string;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
|
||||||
log.error(
|
export async function triggerSubscriberWorkflow(
|
||||||
{
|
key: string,
|
||||||
status: response.status,
|
subscriber: TriggerWorkflowSubscriberInput,
|
||||||
body: await response.text().catch(() => undefined),
|
payload?: Record<string, unknown>,
|
||||||
},
|
) {
|
||||||
"Failed to update subscriber preferences",
|
if (!subscriberClient) return;
|
||||||
);
|
|
||||||
}
|
await subscriberRequest(
|
||||||
} catch (error) {
|
"POST",
|
||||||
log.error({ err: error }, "Error updating subscriber.dev preferences");
|
`/environments/${subscriberClient.environmentId}/workflows/trigger`,
|
||||||
}
|
{ key, subscriber, payload },
|
||||||
|
"Failed to trigger subscriber.dev workflow",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SignJWT } from "jose";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a long‑lived unsubscribe link for a given user/subscriber.
|
|
||||||
*
|
|
||||||
* `${NEXT_PUBLIC_BASE_URL}/unsubscribe?token=<jwt>`
|
|
||||||
*
|
|
||||||
* The JWT payload only contains the subscriberId. There is no expiry
|
|
||||||
* on purpose – unsubscribe links should remain valid indefinitely.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export async function createEmailUnsubscribeLink(
|
|
||||||
userId: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
|
||||||
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET;
|
|
||||||
|
|
||||||
if (!baseUrl || !secret) {
|
|
||||||
// Environment not configured for unsubscribe links.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await new SignJWT({ subscriberId: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
// No expiration on purpose; unsubscribe links are long‑lived.
|
|
||||||
.sign(encoder.encode(secret));
|
|
||||||
|
|
||||||
return `${baseUrl}/unsubscribe?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ export * from "./generateUID";
|
|||||||
export * from "./generateSlug";
|
export * from "./generateSlug";
|
||||||
export * from "./generateWorkspacePrefix";
|
export * from "./generateWorkspacePrefix";
|
||||||
export * from "./subscriptions";
|
export * from "./subscriptions";
|
||||||
export * from "./email";
|
|
||||||
export * from "./dueDateFilters";
|
export * from "./dueDateFilters";
|
||||||
export * from "./s3";
|
export * from "./s3";
|
||||||
export * from "./mentions";
|
export * from "./mentions";
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -124,9 +124,6 @@ importers:
|
|||||||
'@lingui/react':
|
'@lingui/react':
|
||||||
specifier: ^5.3.2
|
specifier: ^5.3.2
|
||||||
version: 5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))(react@18.3.1)
|
version: 5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))(react@18.3.1)
|
||||||
'@novu/api':
|
|
||||||
specifier: ^3.11.0
|
|
||||||
version: 3.11.0
|
|
||||||
'@t3-oss/env-nextjs':
|
'@t3-oss/env-nextjs':
|
||||||
specifier: ^0.11.1
|
specifier: ^0.11.1
|
||||||
version: 0.11.1(typescript@5.9.2)(zod@3.25.76)
|
version: 0.11.1(typescript@5.9.2)(zod@3.25.76)
|
||||||
@@ -470,9 +467,6 @@ importers:
|
|||||||
'@kan/logger':
|
'@kan/logger':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../logger
|
version: link:../logger
|
||||||
'@novu/api':
|
|
||||||
specifier: ^3.11.0
|
|
||||||
version: 3.11.0
|
|
||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^1.0.1
|
specifier: ^1.0.1
|
||||||
version: 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -1544,11 +1538,11 @@ packages:
|
|||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||||
deprecated: 'Merged into tsx: https://tsx.is'
|
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||||
|
|
||||||
'@esbuild-kit/esm-loader@2.6.5':
|
'@esbuild-kit/esm-loader@2.6.5':
|
||||||
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||||
deprecated: 'Merged into tsx: https://tsx.is'
|
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.19.12':
|
'@esbuild/aix-ppc64@0.19.12':
|
||||||
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
||||||
@@ -3001,9 +2995,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@novu/api@3.11.0':
|
|
||||||
resolution: {integrity: sha512-8u0mB5VThL7MhdxoN0UoA4CS9eu2k3Xa6iulauMhCHENuJNxUNuirJWq5t8jDoH4bFTQTfMN0VRkC0qodKR2qA==}
|
|
||||||
|
|
||||||
'@octokit/auth-token@3.0.4':
|
'@octokit/auth-token@3.0.4':
|
||||||
resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==}
|
resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -11505,10 +11496,6 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.19.1
|
fastq: 1.19.1
|
||||||
|
|
||||||
'@novu/api@3.11.0':
|
|
||||||
dependencies:
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@octokit/auth-token@3.0.4': {}
|
'@octokit/auth-token@3.0.4': {}
|
||||||
|
|
||||||
'@octokit/core@4.2.4':
|
'@octokit/core@4.2.4':
|
||||||
|
|||||||
@@ -123,7 +123,6 @@
|
|||||||
"NEXT_PUBLIC_KAN_ENV",
|
"NEXT_PUBLIC_KAN_ENV",
|
||||||
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
||||||
"STRIPE_SECRET_KEY",
|
"STRIPE_SECRET_KEY",
|
||||||
"DISCORD_WEBHOOK_URL",
|
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
||||||
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
||||||
@@ -148,8 +147,6 @@
|
|||||||
"PORT",
|
"PORT",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"BETTER_AUTH_TRUSTED_ORIGINS",
|
"BETTER_AUTH_TRUSTED_ORIGINS",
|
||||||
"NOVU_API_KEY",
|
|
||||||
"EMAIL_UNSUBSCRIBE_SECRET",
|
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"AXIOM_TOKEN",
|
"AXIOM_TOKEN",
|
||||||
|
|||||||
Reference in New Issue
Block a user