* feat: setup onboarding and select plan page * feat: add workspace details step * refactor: optimize board rendering in workspace details * feat: tweak padding in boards mockup * fix: validate/convert slug input * refactor: tweak onboarding steps UX * feat: add team to workspace plans * feat: allow setting description when creating a workspace * feat: add logging to next api routes * feat: create worspace on checkout.session.completed * chore: update types * refactor: improve webhook logs * feat: create workspace on successful activation * feat: set slug on workspace name change * feat: add returnUrl params
109 lines
2.9 KiB
TypeScript
109 lines
2.9 KiB
TypeScript
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 { env } from "~/env";
|
||
|
||
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 });
|
||
|
||
try {
|
||
await novu.subscribers.preferences.update(
|
||
{
|
||
channels: {
|
||
email: false,
|
||
},
|
||
},
|
||
payload.subscriberId,
|
||
);
|
||
} catch (error) {
|
||
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 });
|
||
},
|
||
),
|
||
);
|