Skip to content

Commit 61d0adb

Browse files
authored
Send email route and notification settings page (stack-auth#717)
1 parent dfae043 commit 61d0adb

File tree

22 files changed

+1154
-10
lines changed

22 files changed

+1154
-10
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "UserNotificationPreference" (
3+
"id" UUID NOT NULL,
4+
"tenancyId" UUID NOT NULL,
5+
"projectUserId" UUID NOT NULL,
6+
"notificationCategoryId" UUID NOT NULL,
7+
"enabled" BOOLEAN NOT NULL,
8+
9+
CONSTRAINT "UserNotificationPreference_pkey" PRIMARY KEY ("tenancyId","id")
10+
);
11+
12+
-- CreateIndex
13+
CREATE UNIQUE INDEX "UserNotificationPreference_tenancyId_projectUserId_notifica_key" ON "UserNotificationPreference"("tenancyId", "projectUserId", "notificationCategoryId");
14+
15+
-- AddForeignKey
16+
ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17+
18+
-- AddForeignKey
19+
ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;

apps/backend/prisma/schema.prisma

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ model Tenancy {
5151
organizationId String? @db.Uuid
5252
hasNoOrganization BooleanTrue?
5353
54-
teams Team[] @relation("TenancyTeams")
55-
projectUsers ProjectUser[] @relation("TenancyProjectUsers")
56-
authMethods AuthMethod[] @relation("TenancyAuthMethods")
57-
contactChannels ContactChannel[] @relation("TenancyContactChannels")
58-
connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts")
59-
SentEmail SentEmail[]
60-
cliAuthAttempts CliAuthAttempt[]
61-
projectApiKey ProjectApiKey[]
54+
teams Team[] @relation("TenancyTeams")
55+
projectUsers ProjectUser[] @relation("TenancyProjectUsers")
56+
authMethods AuthMethod[] @relation("TenancyAuthMethods")
57+
contactChannels ContactChannel[] @relation("TenancyContactChannels")
58+
connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts")
59+
SentEmail SentEmail[]
60+
cliAuthAttempts CliAuthAttempt[]
61+
projectApiKey ProjectApiKey[]
62+
userNotificationPreferences UserNotificationPreference[]
6263
6364
@@unique([projectId, branchId, organizationId])
6465
@@unique([projectId, branchId, hasNoOrganization])
@@ -192,6 +193,7 @@ model ProjectUser {
192193
contactChannels ContactChannel[]
193194
authMethods AuthMethod[]
194195
connectedAccounts ConnectedAccount[]
196+
userNotificationPreferences UserNotificationPreference[]
195197
196198
// some backlinks for the unique constraints on some auth methods
197199
passwordAuthMethod PasswordAuthMethod[]
@@ -736,3 +738,17 @@ model CliAuthAttempt {
736738
737739
@@id([tenancyId, id])
738740
}
741+
742+
model UserNotificationPreference {
743+
id String @default(uuid()) @db.Uuid
744+
tenancyId String @db.Uuid
745+
projectUserId String @db.Uuid
746+
notificationCategoryId String @db.Uuid
747+
748+
enabled Boolean
749+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
750+
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
751+
752+
@@id([tenancyId, id])
753+
@@unique([tenancyId, projectUserId, notificationCategoryId])
754+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { notificationPreferencesCrudHandlers } from "../../crud";
2+
3+
export const PATCH = notificationPreferencesCrudHandlers.updateHandler;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { notificationPreferencesCrudHandlers } from "../crud";
2+
3+
export const GET = notificationPreferencesCrudHandlers.listHandler;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { listNotificationCategories } from "@/lib/notification-categories";
2+
import { ensureUserExists } from "@/lib/request-checks";
3+
import { prismaClient } from "@/prisma-client";
4+
import { createCrudHandlers } from "@/route-handlers/crud-handler";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { notificationPreferenceCrud, NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
7+
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
9+
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
10+
11+
export const notificationPreferencesCrudHandlers = createLazyProxy(() => createCrudHandlers(notificationPreferenceCrud, {
12+
paramsSchema: yupObject({
13+
user_id: userIdOrMeSchema.defined(),
14+
notification_category_id: yupString().uuid().optional(),
15+
}),
16+
onUpdate: async ({ auth, params, data }) => {
17+
const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired())) : params.user_id;
18+
const notificationCategories = listNotificationCategories();
19+
const notificationCategory = notificationCategories.find(c => c.id === params.notification_category_id);
20+
if (!notificationCategory || !params.notification_category_id) {
21+
throw new StatusError(404, "Notification category not found");
22+
}
23+
24+
if (auth.type === 'client') {
25+
if (!auth.user) {
26+
throw new KnownErrors.UserAuthenticationRequired();
27+
}
28+
if (userId !== auth.user.id) {
29+
throw new StatusError(StatusError.Forbidden, "You can only manage your own notification preferences");
30+
}
31+
}
32+
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
33+
34+
const notificationPreference = await prismaClient.userNotificationPreference.upsert({
35+
where: {
36+
tenancyId_projectUserId_notificationCategoryId: {
37+
tenancyId: auth.tenancy.id,
38+
projectUserId: userId,
39+
notificationCategoryId: params.notification_category_id,
40+
},
41+
},
42+
update: {
43+
enabled: data.enabled,
44+
},
45+
create: {
46+
tenancyId: auth.tenancy.id,
47+
projectUserId: userId,
48+
notificationCategoryId: params.notification_category_id,
49+
enabled: data.enabled,
50+
},
51+
});
52+
53+
return {
54+
notification_category_id: notificationPreference.notificationCategoryId,
55+
notification_category_name: notificationCategory.name,
56+
enabled: notificationPreference.enabled,
57+
can_disable: notificationCategory.can_disable,
58+
};
59+
},
60+
onList: async ({ auth, params }) => {
61+
const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired)) : params.user_id;
62+
63+
if (!userId) {
64+
throw new KnownErrors.UserAuthenticationRequired;
65+
}
66+
if (auth.type === 'client') {
67+
if (!auth.user) {
68+
throw new KnownErrors.UserAuthenticationRequired;
69+
}
70+
if (userId && userId !== auth.user.id) {
71+
throw new StatusError(StatusError.Forbidden, "You can only view your own notification preferences");
72+
}
73+
}
74+
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
75+
76+
const notificationPreferences = await prismaClient.userNotificationPreference.findMany({
77+
where: {
78+
tenancyId: auth.tenancy.id,
79+
projectUserId: userId,
80+
},
81+
select: {
82+
notificationCategoryId: true,
83+
enabled: true,
84+
},
85+
});
86+
87+
const notificationCategories = listNotificationCategories();
88+
const items: NotificationPreferenceCrud["Client"]["Read"][] = notificationCategories.map(category => {
89+
const preference = notificationPreferences.find(p => p.notificationCategoryId === category.id);
90+
return {
91+
notification_category_id: category.id,
92+
notification_category_name: category.name,
93+
enabled: preference?.enabled ?? category.default_enabled,
94+
can_disable: category.can_disable,
95+
};
96+
});
97+
98+
return {
99+
items,
100+
is_paginated: false,
101+
};
102+
},
103+
}));
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { getEmailConfig, sendEmail } from "@/lib/emails";
2+
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { getUser } from "../../users/crud";
7+
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
8+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
9+
10+
export const POST = createSmartRouteHandler({
11+
metadata: {
12+
hidden: true,
13+
},
14+
request: yupObject({
15+
auth: yupObject({
16+
type: serverOrHigherAuthTypeSchema,
17+
tenancy: adaptSchema.defined(),
18+
}).defined(),
19+
body: yupObject({
20+
user_id: yupString().defined(),
21+
html: yupString().defined(),
22+
subject: yupString().defined(),
23+
notification_category_name: yupString().defined(),
24+
}),
25+
method: yupString().oneOf(["POST"]).defined(),
26+
}),
27+
response: yupObject({
28+
statusCode: yupNumber().oneOf([200]).defined(),
29+
bodyType: yupString().oneOf(["json"]).defined(),
30+
body: yupObject({
31+
user_email: yupString().defined(),
32+
}).defined(),
33+
}),
34+
handler: async ({ body, auth }) => {
35+
if (auth.tenancy.config.email_config.type === "shared") {
36+
throw new StatusError(400, "Cannot send custom emails when using shared email config");
37+
}
38+
const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id });
39+
if (!user) {
40+
throw new StatusError(404, "User not found");
41+
}
42+
if (!user.primary_email) {
43+
throw new StatusError(400, "User does not have a primary email");
44+
}
45+
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
46+
if (!notificationCategory) {
47+
throw new StatusError(404, "Notification category not found");
48+
}
49+
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id);
50+
if (!isNotificationEnabled) {
51+
throw new StatusError(400, "User has disabled notifications for this category");
52+
}
53+
54+
let html = body.html;
55+
if (notificationCategory.can_disable) {
56+
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
57+
tenancy: auth.tenancy,
58+
method: {},
59+
data: {
60+
user_id: user.id,
61+
notification_category_id: notificationCategory.id,
62+
},
63+
callbackUrl: undefined
64+
});
65+
const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
66+
unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link";
67+
unsubscribeLink.searchParams.set("code", code);
68+
html += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
69+
}
70+
71+
await sendEmail({
72+
tenancyId: auth.tenancy.id,
73+
emailConfig: await getEmailConfig(auth.tenancy),
74+
to: user.primary_email,
75+
subject: body.subject,
76+
html,
77+
});
78+
79+
return {
80+
statusCode: 200,
81+
bodyType: 'json',
82+
body: {
83+
user_email: user.primary_email,
84+
},
85+
};
86+
},
87+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
2+
import { prismaClient } from "@/prisma-client";
3+
import { VerificationCodeType } from "@prisma/client";
4+
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
5+
import { NextRequest } from "next/server";
6+
7+
export async function GET(request: NextRequest) {
8+
const { searchParams } = new URL(request.url);
9+
const code = searchParams.get('code');
10+
if (!code || code.length !== 45)
11+
return new Response('Invalid code', { status: 400 });
12+
13+
const codeLower = code.toLowerCase();
14+
const verificationCode = await prismaClient.verificationCode.findFirst({
15+
where: {
16+
code: codeLower,
17+
type: VerificationCodeType.ONE_TIME_PASSWORD,
18+
},
19+
});
20+
21+
if (!verificationCode) throw new KnownErrors.VerificationCodeNotFound();
22+
if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired();
23+
if (verificationCode.usedAt) {
24+
return new Response('<p>You have already unsubscribed from this notification group</p>', {
25+
status: 200,
26+
headers: { 'Content-Type': 'text/html' },
27+
});
28+
}
29+
const { user_id, notification_category_id } = verificationCode.data as { user_id: string, notification_category_id: string };
30+
31+
await prismaClient.verificationCode.update({
32+
where: {
33+
projectId_branchId_code: {
34+
projectId: verificationCode.projectId,
35+
branchId: verificationCode.branchId,
36+
code: codeLower,
37+
},
38+
},
39+
data: { usedAt: new Date() },
40+
});
41+
42+
const tenancy = await getSoleTenancyFromProjectBranch(verificationCode.projectId, verificationCode.branchId);
43+
await prismaClient.userNotificationPreference.upsert({
44+
where: {
45+
tenancyId_projectUserId_notificationCategoryId: {
46+
tenancyId: tenancy.id,
47+
projectUserId: user_id,
48+
notificationCategoryId: notification_category_id,
49+
},
50+
},
51+
update: {
52+
enabled: false,
53+
},
54+
create: {
55+
tenancyId: tenancy.id,
56+
projectUserId: user_id,
57+
notificationCategoryId: notification_category_id,
58+
enabled: false,
59+
},
60+
});
61+
62+
return new Response('<p>Successfully unsubscribed from notification group</p>', {
63+
status: 200,
64+
headers: { 'Content-Type': 'text/html' },
65+
});
66+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
2+
import { VerificationCodeType } from "@prisma/client";
3+
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const unsubscribeLinkVerificationCodeHandler = createVerificationCodeHandler({
6+
type: VerificationCodeType.ONE_TIME_PASSWORD,
7+
data: yupObject({
8+
user_id: yupString().defined(),
9+
notification_category_id: yupString().defined(),
10+
}),
11+
// @ts-expect-error handler functions are not used for this verificationCodeHandler
12+
async handler() {
13+
return null;
14+
},
15+
});

apps/backend/src/lib/emails.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ export async function sendEmailFromTemplate(options: {
332332
});
333333
}
334334

335-
async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
335+
export async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
336336
const projectEmailConfig = tenancy.config.email_config;
337337

338338
if (projectEmailConfig.type === 'shared') {

0 commit comments

Comments
 (0)