diff --git a/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql b/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql new file mode 100644 index 000000000..9b96ac9c0 --- /dev/null +++ b/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "UserNotificationPreference" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "projectUserId" UUID NOT NULL, + "notificationCategoryId" UUID NOT NULL, + "enabled" BOOLEAN NOT NULL, + + CONSTRAINT "UserNotificationPreference_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserNotificationPreference_tenancyId_projectUserId_notifica_key" ON "UserNotificationPreference"("tenancyId", "projectUserId", "notificationCategoryId"); + +-- AddForeignKey +ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 234be5613..55d7be665 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -51,14 +51,15 @@ model Tenancy { organizationId String? @db.Uuid hasNoOrganization BooleanTrue? - teams Team[] @relation("TenancyTeams") - projectUsers ProjectUser[] @relation("TenancyProjectUsers") - authMethods AuthMethod[] @relation("TenancyAuthMethods") - contactChannels ContactChannel[] @relation("TenancyContactChannels") - connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts") - SentEmail SentEmail[] - cliAuthAttempts CliAuthAttempt[] - projectApiKey ProjectApiKey[] + teams Team[] @relation("TenancyTeams") + projectUsers ProjectUser[] @relation("TenancyProjectUsers") + authMethods AuthMethod[] @relation("TenancyAuthMethods") + contactChannels ContactChannel[] @relation("TenancyContactChannels") + connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts") + SentEmail SentEmail[] + cliAuthAttempts CliAuthAttempt[] + projectApiKey ProjectApiKey[] + userNotificationPreferences UserNotificationPreference[] @@unique([projectId, branchId, organizationId]) @@unique([projectId, branchId, hasNoOrganization]) @@ -192,6 +193,7 @@ model ProjectUser { contactChannels ContactChannel[] authMethods AuthMethod[] connectedAccounts ConnectedAccount[] + userNotificationPreferences UserNotificationPreference[] // some backlinks for the unique constraints on some auth methods passwordAuthMethod PasswordAuthMethod[] @@ -736,3 +738,17 @@ model CliAuthAttempt { @@id([tenancyId, id]) } + +model UserNotificationPreference { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + projectUserId String @db.Uuid + notificationCategoryId String @db.Uuid + + enabled Boolean + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + + @@id([tenancyId, id]) + @@unique([tenancyId, projectUserId, notificationCategoryId]) +} diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx new file mode 100644 index 000000000..4866618aa --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx @@ -0,0 +1,3 @@ +import { notificationPreferencesCrudHandlers } from "../../crud"; + +export const PATCH = notificationPreferencesCrudHandlers.updateHandler; diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx new file mode 100644 index 000000000..103180136 --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx @@ -0,0 +1,3 @@ +import { notificationPreferencesCrudHandlers } from "../crud"; + +export const GET = notificationPreferencesCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx new file mode 100644 index 000000000..7a9078fff --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx @@ -0,0 +1,103 @@ +import { listNotificationCategories } from "@/lib/notification-categories"; +import { ensureUserExists } from "@/lib/request-checks"; +import { prismaClient } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { notificationPreferenceCrud, NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences"; +import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +export const notificationPreferencesCrudHandlers = createLazyProxy(() => createCrudHandlers(notificationPreferenceCrud, { + paramsSchema: yupObject({ + user_id: userIdOrMeSchema.defined(), + notification_category_id: yupString().uuid().optional(), + }), + onUpdate: async ({ auth, params, data }) => { + const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired())) : params.user_id; + const notificationCategories = listNotificationCategories(); + const notificationCategory = notificationCategories.find(c => c.id === params.notification_category_id); + if (!notificationCategory || !params.notification_category_id) { + throw new StatusError(404, "Notification category not found"); + } + + if (auth.type === 'client') { + if (!auth.user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (userId !== auth.user.id) { + throw new StatusError(StatusError.Forbidden, "You can only manage your own notification preferences"); + } + } + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId }); + + const notificationPreference = await prismaClient.userNotificationPreference.upsert({ + where: { + tenancyId_projectUserId_notificationCategoryId: { + tenancyId: auth.tenancy.id, + projectUserId: userId, + notificationCategoryId: params.notification_category_id, + }, + }, + update: { + enabled: data.enabled, + }, + create: { + tenancyId: auth.tenancy.id, + projectUserId: userId, + notificationCategoryId: params.notification_category_id, + enabled: data.enabled, + }, + }); + + return { + notification_category_id: notificationPreference.notificationCategoryId, + notification_category_name: notificationCategory.name, + enabled: notificationPreference.enabled, + can_disable: notificationCategory.can_disable, + }; + }, + onList: async ({ auth, params }) => { + const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired)) : params.user_id; + + if (!userId) { + throw new KnownErrors.UserAuthenticationRequired; + } + if (auth.type === 'client') { + if (!auth.user) { + throw new KnownErrors.UserAuthenticationRequired; + } + if (userId && userId !== auth.user.id) { + throw new StatusError(StatusError.Forbidden, "You can only view your own notification preferences"); + } + } + await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId }); + + const notificationPreferences = await prismaClient.userNotificationPreference.findMany({ + where: { + tenancyId: auth.tenancy.id, + projectUserId: userId, + }, + select: { + notificationCategoryId: true, + enabled: true, + }, + }); + + const notificationCategories = listNotificationCategories(); + const items: NotificationPreferenceCrud["Client"]["Read"][] = notificationCategories.map(category => { + const preference = notificationPreferences.find(p => p.notificationCategoryId === category.id); + return { + notification_category_id: category.id, + notification_category_name: category.name, + enabled: preference?.enabled ?? category.default_enabled, + can_disable: category.can_disable, + }; + }); + + return { + items, + is_paginated: false, + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx new file mode 100644 index 000000000..40d1dfdd2 --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -0,0 +1,87 @@ +import { getEmailConfig, sendEmail } from "@/lib/emails"; +import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getUser } from "../../users/crud"; +import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + user_id: yupString().defined(), + html: yupString().defined(), + subject: yupString().defined(), + notification_category_name: yupString().defined(), + }), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + user_email: yupString().defined(), + }).defined(), + }), + handler: async ({ body, auth }) => { + if (auth.tenancy.config.email_config.type === "shared") { + throw new StatusError(400, "Cannot send custom emails when using shared email config"); + } + const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id }); + if (!user) { + throw new StatusError(404, "User not found"); + } + if (!user.primary_email) { + throw new StatusError(400, "User does not have a primary email"); + } + const notificationCategory = getNotificationCategoryByName(body.notification_category_name); + if (!notificationCategory) { + throw new StatusError(404, "Notification category not found"); + } + const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id); + if (!isNotificationEnabled) { + throw new StatusError(400, "User has disabled notifications for this category"); + } + + let html = body.html; + if (notificationCategory.can_disable) { + const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({ + tenancy: auth.tenancy, + method: {}, + data: { + user_id: user.id, + notification_category_id: notificationCategory.id, + }, + callbackUrl: undefined + }); + const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); + unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link"; + unsubscribeLink.searchParams.set("code", code); + html += `
Click here to unsubscribe`; + } + + await sendEmail({ + tenancyId: auth.tenancy.id, + emailConfig: await getEmailConfig(auth.tenancy), + to: user.primary_email, + subject: body.subject, + html, + }); + + return { + statusCode: 200, + bodyType: 'json', + body: { + user_email: user.primary_email, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx new file mode 100644 index 000000000..a084b4cb8 --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx @@ -0,0 +1,66 @@ +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { prismaClient } from "@/prisma-client"; +import { VerificationCodeType } from "@prisma/client"; +import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + if (!code || code.length !== 45) + return new Response('Invalid code', { status: 400 }); + + const codeLower = code.toLowerCase(); + const verificationCode = await prismaClient.verificationCode.findFirst({ + where: { + code: codeLower, + type: VerificationCodeType.ONE_TIME_PASSWORD, + }, + }); + + if (!verificationCode) throw new KnownErrors.VerificationCodeNotFound(); + if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired(); + if (verificationCode.usedAt) { + return new Response('

You have already unsubscribed from this notification group

', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }); + } + const { user_id, notification_category_id } = verificationCode.data as { user_id: string, notification_category_id: string }; + + await prismaClient.verificationCode.update({ + where: { + projectId_branchId_code: { + projectId: verificationCode.projectId, + branchId: verificationCode.branchId, + code: codeLower, + }, + }, + data: { usedAt: new Date() }, + }); + + const tenancy = await getSoleTenancyFromProjectBranch(verificationCode.projectId, verificationCode.branchId); + await prismaClient.userNotificationPreference.upsert({ + where: { + tenancyId_projectUserId_notificationCategoryId: { + tenancyId: tenancy.id, + projectUserId: user_id, + notificationCategoryId: notification_category_id, + }, + }, + update: { + enabled: false, + }, + create: { + tenancyId: tenancy.id, + projectUserId: user_id, + notificationCategoryId: notification_category_id, + enabled: false, + }, + }); + + return new Response('

Successfully unsubscribed from notification group

', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }); +} diff --git a/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx b/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx new file mode 100644 index 000000000..6910921e2 --- /dev/null +++ b/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx @@ -0,0 +1,15 @@ +import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; +import { VerificationCodeType } from "@prisma/client"; +import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const unsubscribeLinkVerificationCodeHandler = createVerificationCodeHandler({ + type: VerificationCodeType.ONE_TIME_PASSWORD, + data: yupObject({ + user_id: yupString().defined(), + notification_category_id: yupString().defined(), + }), + // @ts-expect-error handler functions are not used for this verificationCodeHandler + async handler() { + return null; + }, +}); diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 4df4c3457..09ebe6491 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -332,7 +332,7 @@ export async function sendEmailFromTemplate(options: { }); } -async function getEmailConfig(tenancy: Tenancy): Promise { +export async function getEmailConfig(tenancy: Tenancy): Promise { const projectEmailConfig = tenancy.config.email_config; if (projectEmailConfig.type === 'shared') { diff --git a/apps/backend/src/lib/notification-categories.ts b/apps/backend/src/lib/notification-categories.ts new file mode 100644 index 000000000..c7a9acccb --- /dev/null +++ b/apps/backend/src/lib/notification-categories.ts @@ -0,0 +1,59 @@ +import { Tenancy } from "@/lib/tenancies"; +import { prismaClient } from "@/prisma-client"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { signInVerificationCodeHandler } from "../app/api/latest/auth/otp/sign-in/verification-code-handler"; + +// For now, we only have two hardcoded notification categories. TODO: query from database instead and create UI to manage them in dashboard +export const listNotificationCategories = () => { + return [ + { + id: "7bb82d33-2f54-4a3d-9d23-82739e0d66ef", + name: "Transactional", + default_enabled: true, + can_disable: false, + }, + { + id: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + name: "Marketing", + default_enabled: true, + can_disable: true, + }, + ]; +}; + +export const getNotificationCategoryByName = (name: string) => { + return listNotificationCategories().find((category) => category.name === name); +}; + +export const hasNotificationEnabled = async (tenancyId: string, userId: string, notificationCategoryId: string) => { + const notificationCategory = listNotificationCategories().find((category) => category.id === notificationCategoryId); + if (!notificationCategory) { + throw new StackAssertionError('Invalid notification category id', { notificationCategoryId }); + } + const userNotificationPreference = await prismaClient.userNotificationPreference.findFirst({ + where: { + tenancyId, + projectUserId: userId, + notificationCategoryId, + }, + }); + if (!userNotificationPreference) { + return notificationCategory.default_enabled; + } + return userNotificationPreference.enabled; +}; + +export const generateUnsubscribeLink = async (tenancy: Tenancy, userId: string, notificationCategoryId: string) => { + const { code } = await signInVerificationCodeHandler.createCode({ + tenancy, + expiresInMs: 1000 * 60 * 60 * 24 * 30, + data: {}, + method: { + email: "test@test.com", + type: "standard", + }, + callbackUrl: undefined, + }); + return `${getEnvVariable("NEXT_PUBLIC_STACK_API_URL")}/api/v1/emails/unsubscribe-link?token=${code}¬ification_category_id=${notificationCategoryId}`; +}; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts new file mode 100644 index 000000000..6766a86be --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts @@ -0,0 +1,144 @@ +import { randomUUID } from "crypto"; +import { describe } from "vitest"; +import { it } from "../../../../helpers"; +import { Auth, niceBackendFetch } from "../../../backend-helpers"; + +describe("invalid requests", () => { + it("should return 401 when invalid authorization is provided", async ({ expect }) => { + const response = await niceBackendFetch( + `/api/v1/emails/notification-preference/me/${randomUUID()}`, + { + method: "PATCH", + accessType: "client", + body: { + enabled: true, + } + } + ); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "CANNOT_GET_OWN_USER_WITHOUT_USER", + "error": "You have specified 'me' as a userId, but did not provide authentication for a user.", + }, + "headers": Headers { + "x-stack-known-error": "CANNOT_GET_OWN_USER_WITHOUT_USER", +