mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Send email route and notification settings page (#717)
This commit is contained in:
parent
dfae043457
commit
61d0adb7a5
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { notificationPreferencesCrudHandlers } from "../../crud";
|
||||
|
||||
export const PATCH = notificationPreferencesCrudHandlers.updateHandler;
|
||||
@ -0,0 +1,3 @@
|
||||
import { notificationPreferencesCrudHandlers } from "../crud";
|
||||
|
||||
export const GET = notificationPreferencesCrudHandlers.listHandler;
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
87
apps/backend/src/app/api/latest/emails/send-email/route.tsx
Normal file
87
apps/backend/src/app/api/latest/emails/send-email/route.tsx
Normal file
@ -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 += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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('<p>You have already unsubscribed from this notification group</p>', {
|
||||
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('<p>Successfully unsubscribed from notification group</p>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -332,7 +332,7 @@ export async function sendEmailFromTemplate(options: {
|
||||
});
|
||||
}
|
||||
|
||||
async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
|
||||
export async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
|
||||
const projectEmailConfig = tenancy.config.email_config;
|
||||
|
||||
if (projectEmailConfig.type === 'shared') {
|
||||
|
||||
59
apps/backend/src/lib/notification-categories.ts
Normal file
59
apps/backend/src/lib/notification-categories.ts
Normal file
@ -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}`;
|
||||
};
|
||||
@ -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",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 404 when invalid notification category id is provided", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch(
|
||||
`/api/v1/emails/notification-preference/me/${randomUUID()}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
accessType: "client",
|
||||
body: {
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 404,
|
||||
"body": "Notification category not found",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists default notification preferences", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/notification-preference/me",
|
||||
{
|
||||
method: "GET",
|
||||
accessType: "client",
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"can_disable": false,
|
||||
"enabled": true,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Transactional",
|
||||
},
|
||||
{
|
||||
"can_disable": true,
|
||||
"enabled": true,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Marketing",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("updates notification preferences", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/notification-preference/me/4f6f8873-3d04-46bd-8bef-18338b1a1b4c",
|
||||
{
|
||||
method: "PATCH",
|
||||
accessType: "client",
|
||||
body: {
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"can_disable": true,
|
||||
"enabled": false,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Marketing",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
const listPreferencesResponse = await niceBackendFetch(
|
||||
"/api/v1/emails/notification-preference/me",
|
||||
{
|
||||
method: "GET",
|
||||
accessType: "client",
|
||||
}
|
||||
);
|
||||
expect(listPreferencesResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"can_disable": false,
|
||||
"enabled": true,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Transactional",
|
||||
},
|
||||
{
|
||||
"can_disable": true,
|
||||
"enabled": false,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Marketing",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
272
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
Normal file
272
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../helpers";
|
||||
import { niceBackendFetch, Project, User } from "../../../backend-helpers";
|
||||
|
||||
const testEmailConfig = {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: 2500,
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
} as const;
|
||||
|
||||
describe("invalid requests", () => {
|
||||
it("should return 401 when invalid access type is provided", async ({ expect }) => {
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
user_id: randomUUID(),
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": {
|
||||
"code": "INSUFFICIENT_ACCESS_TYPE",
|
||||
"details": {
|
||||
"actual_access_type": "client",
|
||||
"allowed_access_types": [
|
||||
"server",
|
||||
"admin",
|
||||
],
|
||||
},
|
||||
"error": "The x-stack-access-type header must be 'server' or 'admin', but was 'client'.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 404 when user is not found", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: randomUUID(),
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 404,
|
||||
"body": "User not found",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when using shared email config", async ({ expect }) => {
|
||||
const createUserResponse = await niceBackendFetch("/api/v1/users", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
primary_email: "test@example.com",
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "Cannot send custom emails when using shared email config",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 404 when invalid notification category name is provided", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const createUserResponse = await niceBackendFetch("/api/v1/users", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
primary_email: "test@example.com",
|
||||
},
|
||||
});
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Invalid",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 404,
|
||||
"body": "Notification category not found",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when user has disabled notifications for the category", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const user = await User.create();
|
||||
|
||||
// Disable notifications for Marketing category
|
||||
const disableNotificationsResponse = await niceBackendFetch(`/api/v1/emails/notification-preference/${user.userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`, {
|
||||
method: "PATCH",
|
||||
accessType: "server",
|
||||
body: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(disableNotificationsResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"can_disable": true,
|
||||
"enabled": false,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Marketing",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "User has disabled notifications for this category",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when user does not have a primary email", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const createUserResponse = await niceBackendFetch("/api/v1/users", {
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {},
|
||||
});
|
||||
expect(createUserResponse.status).toBe(201);
|
||||
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: createUserResponse.body.id,
|
||||
html: "<p>Test email</p>",
|
||||
subject: "Test Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "User does not have a primary email",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 200 and send email successfully", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: testEmailConfig,
|
||||
},
|
||||
});
|
||||
const user = await User.create();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Verify the email was actually sent by checking the mailbox
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toContain("<h1>Test Email</h1>");
|
||||
expect(sentEmail!.body?.html).toContain("<p>This is a test email with HTML content.</p>");
|
||||
});
|
||||
133
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
Normal file
133
apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { it } from "../../../../helpers";
|
||||
import { niceBackendFetch, Project, User } from "../../../backend-helpers";
|
||||
|
||||
it("unsubscribe link should be sent and update notification preference", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: 2500,
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
const user = await User.create();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Marketing",
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Verify the email was actually sent by checking the mailbox
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toMatch(/<h1>Test Email<\/h1><p>This is a test email with HTML content\.<\/p><br \/><a href="http:\/\/localhost:8102\/api\/v1\/emails\/unsubscribe-link\?code=[a-zA-Z0-9]+">Click here to unsubscribe<\/a>/);
|
||||
|
||||
// Extract the unsubscribe link and fetch it
|
||||
const unsubscribeLinkMatch = sentEmail!.body?.html.match(/href="([^"]+)"/);
|
||||
expect(unsubscribeLinkMatch).toBeDefined();
|
||||
const unsubscribeUrl = unsubscribeLinkMatch![1];
|
||||
const unsubscribeResponse = await niceBackendFetch(unsubscribeUrl, {
|
||||
method: "GET",
|
||||
accessType: "client",
|
||||
});
|
||||
expect(unsubscribeResponse.status).toBe(200);
|
||||
expect(unsubscribeResponse.body).toBe("<p>Successfully unsubscribed from notification group</p>");
|
||||
|
||||
const listPreferencesResponse = await niceBackendFetch(
|
||||
`/api/v1/emails/notification-preference/${user.userId}`,
|
||||
{
|
||||
method: "GET",
|
||||
accessType: "admin",
|
||||
}
|
||||
);
|
||||
expect(listPreferencesResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"can_disable": false,
|
||||
"enabled": true,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Transactional",
|
||||
},
|
||||
{
|
||||
"can_disable": true,
|
||||
"enabled": false,
|
||||
"notification_category_id": "<stripped UUID>",
|
||||
"notification_category_name": "Marketing",
|
||||
},
|
||||
],
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("unsubscribe link should not be sent for emails with transactional notification category", async ({ expect }) => {
|
||||
await Project.createAndSwitch({
|
||||
display_name: "Test Successful Email Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: 2500,
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
const user = await User.create();
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/emails/send-email",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
body: {
|
||||
user_id: user.userId,
|
||||
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
||||
subject: "Custom Test Email Subject",
|
||||
notification_category_name: "Transactional",
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
const messages = await user.mailbox.fetchMessages();
|
||||
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
|
||||
expect(sentEmail).toBeDefined();
|
||||
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<h1>Test Email</h1><p>This is a test email with HTML content.</p>\\n"`);
|
||||
});
|
||||
@ -15,6 +15,7 @@ import { Result } from "../utils/results";
|
||||
import { deindent } from '../utils/strings';
|
||||
import { ContactChannelsCrud } from './crud/contact-channels';
|
||||
import { CurrentUserCrud } from './crud/current-user';
|
||||
import { NotificationPreferenceCrud } from './crud/notification-preferences';
|
||||
import { ConnectedAccountAccessTokenCrud } from './crud/oauth';
|
||||
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateInputSchema, teamApiKeysCreateOutputSchema, userApiKeysCreateInputSchema, userApiKeysCreateOutputSchema } from './crud/project-api-keys';
|
||||
import { ProjectPermissionsCrud } from './crud/project-permissions';
|
||||
@ -1636,5 +1637,37 @@ export class StackClientInterface {
|
||||
}
|
||||
return await result.data.json();
|
||||
}
|
||||
|
||||
async listNotificationCategories(
|
||||
session: InternalSession,
|
||||
): Promise<NotificationPreferenceCrud['Client']['Read'][]> {
|
||||
const response = await this.sendClientRequest(
|
||||
`/emails/notification-preference/me`,
|
||||
{},
|
||||
session,
|
||||
);
|
||||
const result = await response.json() as NotificationPreferenceCrud['Client']['List'];
|
||||
return result.items;
|
||||
}
|
||||
|
||||
async setNotificationsEnabled(
|
||||
notificationCategoryId: string,
|
||||
enabled: boolean,
|
||||
session: InternalSession,
|
||||
): Promise<void> {
|
||||
await this.sendClientRequest(
|
||||
`/emails/notification-preference/me/${notificationCategoryId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
}),
|
||||
},
|
||||
session,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import { createCrud, CrudTypeOf } from "../../crud";
|
||||
import { yupBoolean, yupObject, yupString } from "../../schema-fields";
|
||||
|
||||
|
||||
const notificationPreferenceReadSchema = yupObject({
|
||||
notification_category_id: yupString().defined(),
|
||||
notification_category_name: yupString().defined(),
|
||||
enabled: yupBoolean().defined(),
|
||||
can_disable: yupBoolean().defined(),
|
||||
}).defined();
|
||||
|
||||
const notificationPreferenceUpdateSchema = yupObject({
|
||||
enabled: yupBoolean().defined(),
|
||||
}).defined();
|
||||
|
||||
export const notificationPreferenceCrud = createCrud({
|
||||
clientReadSchema: notificationPreferenceReadSchema,
|
||||
clientUpdateSchema: notificationPreferenceUpdateSchema,
|
||||
});
|
||||
|
||||
export type NotificationPreferenceCrud = CrudTypeOf<typeof notificationPreferenceCrud>;
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "./client-interface";
|
||||
import { ContactChannelsCrud } from "./crud/contact-channels";
|
||||
import { CurrentUserCrud } from "./crud/current-user";
|
||||
import { NotificationPreferenceCrud } from "./crud/notification-preferences";
|
||||
import { ConnectedAccountAccessTokenCrud } from "./crud/oauth";
|
||||
import { ProjectPermissionsCrud } from "./crud/project-permissions";
|
||||
import { SessionsCrud } from "./crud/sessions";
|
||||
@ -569,6 +570,40 @@ export class StackServerInterface extends StackClientInterface {
|
||||
return json.items;
|
||||
}
|
||||
|
||||
async listServerNotificationCategories(
|
||||
userId: string,
|
||||
): Promise<NotificationPreferenceCrud['Server']['Read'][]> {
|
||||
const response = await this.sendServerRequest(
|
||||
urlString`/emails/notification-preference/${userId}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
null,
|
||||
);
|
||||
const json = await response.json() as NotificationPreferenceCrud['Server']['List'];
|
||||
return json.items;
|
||||
}
|
||||
|
||||
async setServerNotificationsEnabled(
|
||||
userId: string,
|
||||
notificationCategoryId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await this.sendServerRequest(
|
||||
urlString`/emails/notification-preference/${userId}/${notificationCategoryId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
}),
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async sendServerContactChannelVerificationEmail(
|
||||
userId: string,
|
||||
contactChannelId: string,
|
||||
|
||||
@ -11,6 +11,7 @@ import { useTranslation } from "../lib/translations";
|
||||
import { ActiveSessionsPage } from "./account-settings/active-sessions/active-sessions-page";
|
||||
import { ApiKeysPage } from "./account-settings/api-keys/api-keys-page";
|
||||
import { EmailsAndAuthPage } from './account-settings/email-and-auth/email-and-auth-page';
|
||||
import { NotificationsPage } from './account-settings/notifications/notifications-page';
|
||||
import { ProfilePage } from "./account-settings/profile-page/profile-page";
|
||||
import { SettingsPage } from './account-settings/settings/settings-page';
|
||||
import { TeamCreationPage } from './account-settings/teams/team-creation-page';
|
||||
@ -100,6 +101,15 @@ export function AccountSettings(props: {
|
||||
<EmailsAndAuthPage mockMode={!!props.mockUser}/>
|
||||
</Suspense>,
|
||||
},
|
||||
{
|
||||
title: t('Notifications'),
|
||||
type: 'item',
|
||||
id: 'notifications',
|
||||
icon: <Icon name="Bell"/>,
|
||||
content: <Suspense fallback={<NotificationsPageSkeleton/>}>
|
||||
<NotificationsPage/>
|
||||
</Suspense>,
|
||||
},
|
||||
{
|
||||
title: t('Active Sessions'),
|
||||
type: 'item',
|
||||
@ -219,3 +229,10 @@ function TeamCreationSkeleton() {
|
||||
<Skeleton className="h-9 w-full mt-1"/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
function NotificationsPageSkeleton() {
|
||||
return <PageLayout>
|
||||
<Skeleton className="h-9 w-full mt-1"/>
|
||||
<Skeleton className="h-9 w-full mt-1"/>
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import { useUser } from "../../../lib/hooks";
|
||||
import { useTranslation } from "../../../lib/translations";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { Switch } from "@stackframe/stack-ui";
|
||||
import { Separator, Typography } from "@stackframe/stack-ui";
|
||||
|
||||
|
||||
export function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser({ or: 'redirect' });
|
||||
const notificationCategories = user.useNotificationCategories();
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<Separator />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='sm:flex-1 flex flex-col justify-center pb-2'>
|
||||
<Typography className="font-medium">
|
||||
{t('Choose which emails you want to receive')}
|
||||
</Typography>
|
||||
</div>
|
||||
{notificationCategories.map((category) => (
|
||||
<div key={category.id} className="flex justify-start gap-4 items-center">
|
||||
<Switch
|
||||
checked={category.enabled}
|
||||
onCheckedChange={(value) => void category.setEnabled(value)}
|
||||
disabled={!category.canDisable}
|
||||
/>
|
||||
<Typography>{category.name}</Typography>
|
||||
{!category.canDisable && (
|
||||
<Typography variant='secondary' type='footnote'>
|
||||
(cannot be disabled)
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/
|
||||
import { TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
|
||||
import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
|
||||
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
|
||||
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
|
||||
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
|
||||
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
|
||||
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
|
||||
@ -34,6 +35,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
|
||||
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
|
||||
import { OAuthConnection } from "../../connected-accounts";
|
||||
import { ContactChannel, ContactChannelCreateOptions, ContactChannelUpdateOptions, contactChannelCreateOptionsToCrud, contactChannelUpdateOptionsToCrud } from "../../contact-channels";
|
||||
import { NotificationCategory } from "../../notification-categories";
|
||||
import { TeamPermission } from "../../permissions";
|
||||
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
|
||||
import { EditableTeamMemberProfile, Team, TeamCreateOptions, TeamInvitation, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
|
||||
@ -191,6 +193,13 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
}
|
||||
);
|
||||
|
||||
private readonly _notificationCategoriesCache = createCacheBySession<[], NotificationPreferenceCrud['Client']['Read'][]>(
|
||||
async (session) => {
|
||||
const results = await this._interface.listNotificationCategories(session);
|
||||
return results as NotificationPreferenceCrud['Client']['Read'][];
|
||||
}
|
||||
);
|
||||
|
||||
private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null;
|
||||
|
||||
protected async _createCookieHelper(): Promise<CookieHelper> {
|
||||
@ -785,6 +794,20 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
},
|
||||
};
|
||||
}
|
||||
protected _clientNotificationCategoryFromCrud(crud: NotificationPreferenceCrud['Client']['Read'], session: InternalSession): NotificationCategory {
|
||||
const app = this;
|
||||
return {
|
||||
id: crud.notification_category_id,
|
||||
name: crud.notification_category_name,
|
||||
enabled: crud.enabled,
|
||||
canDisable: crud.can_disable,
|
||||
|
||||
async setEnabled(enabled: boolean) {
|
||||
await app._interface.setNotificationsEnabled(crud.notification_category_id, enabled, session);
|
||||
await app._notificationCategoriesCache.refresh([session]);
|
||||
},
|
||||
};
|
||||
}
|
||||
protected _createAuth(session: InternalSession): Auth {
|
||||
const app = this;
|
||||
return {
|
||||
@ -1077,7 +1100,16 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
await app._clientContactChannelsCache.refresh([session]);
|
||||
return app._clientContactChannelFromCrud(crud, session);
|
||||
},
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
useNotificationCategories() {
|
||||
const results = useAsyncCache(app._notificationCategoriesCache, [session] as const, "user.useNotificationCategories()");
|
||||
return results.map((crud) => app._clientNotificationCategoryFromCrud(crud, session));
|
||||
},
|
||||
// END_PLATFORM
|
||||
async listNotificationCategories() {
|
||||
const results = Result.orThrow(await app._notificationCategoriesCache.getOrWait([session], "write-only"));
|
||||
return results.map((crud) => app._clientNotificationCategoryFromCrud(crud, session));
|
||||
},
|
||||
// IF_PLATFORM react-like
|
||||
useApiKeys() {
|
||||
const result = useAsyncCache(app._userApiKeysCache, [session] as const, "user.useApiKeys()");
|
||||
@ -1099,6 +1131,8 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
await app._userApiKeysCache.refresh([session]);
|
||||
return app._clientApiKeyFromCrud(session, result);
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { KnownErrors, StackServerInterface } from "@stackframe/stack-shared";
|
||||
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
|
||||
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
|
||||
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys";
|
||||
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
|
||||
import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
|
||||
@ -20,6 +21,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
|
||||
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common";
|
||||
import { OAuthConnection } from "../../connected-accounts";
|
||||
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
|
||||
import { NotificationCategory } from "../../notification-categories";
|
||||
import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions";
|
||||
import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams";
|
||||
import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users";
|
||||
@ -118,6 +120,11 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
return await this._interface.listServerContactChannels(userId);
|
||||
}
|
||||
);
|
||||
private readonly _serverNotificationCategoriesCache = createCache<[string], NotificationPreferenceCrud['Server']['Read'][]>(
|
||||
async ([userId]) => {
|
||||
return await this._interface.listServerNotificationCategories(userId);
|
||||
}
|
||||
);
|
||||
|
||||
private readonly _serverUserApiKeysCache = createCache<[string], UserApiKeysCrud['Server']['Read'][]>(
|
||||
async ([userId]) => {
|
||||
@ -201,6 +208,21 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
};
|
||||
}
|
||||
|
||||
protected _serverNotificationCategoryFromCrud(userId: string, crud: NotificationPreferenceCrud['Server']['Read']): NotificationCategory {
|
||||
const app = this;
|
||||
return {
|
||||
id: crud.notification_category_id,
|
||||
name: crud.notification_category_name,
|
||||
enabled: crud.enabled,
|
||||
canDisable: crud.can_disable,
|
||||
|
||||
async setEnabled(enabled: boolean) {
|
||||
await app._interface.setServerNotificationsEnabled(userId, crud.notification_category_id, enabled);
|
||||
await app._serverNotificationCategoriesCache.refresh([userId]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor(options:
|
||||
| StackServerAppConstructorOptions<HasTokenStore, ProjectId>
|
||||
| {
|
||||
@ -504,6 +526,16 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
return app._serverContactChannelFromCrud(crud.id, contactChannel);
|
||||
},
|
||||
// IF_PLATFORM react-like
|
||||
useNotificationCategories() {
|
||||
const results = useAsyncCache(app._serverNotificationCategoriesCache, [crud.id] as const, "user.useNotificationCategories()");
|
||||
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
|
||||
},
|
||||
// END_PLATFORM
|
||||
async listNotificationCategories() {
|
||||
const results = Result.orThrow(await app._serverNotificationCategoriesCache.getOrWait([crud.id], "write-only"));
|
||||
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
|
||||
},
|
||||
// IF_PLATFORM react-like
|
||||
useApiKeys() {
|
||||
const result = useAsyncCache(app._serverUserApiKeysCache, [crud.id] as const, "user.useApiKeys()");
|
||||
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export type NotificationCategory = {
|
||||
id: string,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
canDisable: boolean,
|
||||
|
||||
setEnabled(enabled: boolean): Promise<void>,
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { ContactChannel, ContactChannelCreateOptions, ServerContactChannel, Serv
|
||||
import { AdminTeamPermission, TeamPermission } from "../permissions";
|
||||
import { AdminOwnedProject, AdminProjectUpdateOptions } from "../projects";
|
||||
import { EditableTeamMemberProfile, ServerTeam, ServerTeamCreateOptions, Team, TeamCreateOptions } from "../teams";
|
||||
import { NotificationCategory } from "../notification-categories";
|
||||
|
||||
|
||||
export type Session = {
|
||||
@ -178,6 +179,9 @@ export type UserExtra = {
|
||||
listContactChannels(): Promise<ContactChannel[]>,
|
||||
createContactChannel(data: ContactChannelCreateOptions): Promise<ContactChannel>,
|
||||
|
||||
useNotificationCategories(): NotificationCategory[], // THIS_LINE_PLATFORM react-like
|
||||
listNotificationCategories(): Promise<NotificationCategory[]>,
|
||||
|
||||
delete(): Promise<void>,
|
||||
|
||||
getConnectedAccount(id: ProviderType, options: { or: 'redirect', scopes?: string[] }): Promise<OAuthConnection>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user