chore: sunset weekly summary (#6282)

This commit is contained in:
Piyush Gupta
2025-07-24 17:31:39 +05:30
committed by GitHub
parent ee20af54c3
commit 28514487e0
56 changed files with 38 additions and 2135 deletions

View File

@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {

View File

@@ -104,7 +104,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
} as unknown as TUser;
const mockOrganization = {

View File

@@ -106,7 +106,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;

View File

@@ -49,7 +49,6 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "project_manager",

View File

@@ -1,166 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditWeeklySummary } from "./EditWeeklySummary";
vi.mock("lucide-react", () => ({
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {
proj1: true,
proj3: false,
},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{ id: "proj1", name: "Project 1", environments: [] },
{ id: "proj2", name: "Project 2", environments: [] },
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
},
},
];
const environmentId = "test-env-id";
describe("EditWeeklySummary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and projects", () => {
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Project 3")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj1",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj2",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj3",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
const inviteLinks = screen.getAllByTestId("link");
expect(inviteLinks.length).toBe(mockMemberships.length);
inviteLinks.forEach((link) => {
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
expect(link).toHaveTextContent("common.invite_them");
});
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
expect(
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
).toBe(mockMemberships.length);
});
test("renders correctly with no memberships", () => {
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
});
test("renders correctly when an organization has no projects", () => {
const membershipsWithNoProjects: Membership[] = [
{
organization: {
id: "org3",
name: "Organization No Projects",
projects: [],
},
},
];
render(
<EditWeeklySummary
memberships={membershipsWithNoProjects}
user={mockUser}
environmentId={environmentId}
/>
);
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
});
});

View File

@@ -1,59 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
environmentId: string;
}
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
const { t } = useTranslate();
return (
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-slate-800">{membership.organization.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("common.project")}</div>
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
</div>
<div className="space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={project.id}>
<div className="col-span-2">{project?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={project.id}
notificationSettings={user.notificationSettings!}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
{t("common.invite_them")}
</Link>
</p>
</div>
</div>
))}
</>
);
};

View File

@@ -29,7 +29,6 @@ const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
expect(switchInput.checked).toBe(false);
});
test("renders with initial checked state for 'weeklySummary' (true)", () => {
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: settings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for weeklySummary"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);

View File

@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
}

View File

@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
.getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(alertsCard).toBeInTheDocument();
// Check for Weekly Summary LoadingCard
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
const weeklySummaryCard = screen
.getByText("environments.settings.notifications.weekly_summary_projects")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(weeklySummaryCard).toBeInTheDocument();
});
});

View File

@@ -14,11 +14,6 @@ const Loading = () => {
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
{
title: t("environments.settings.notifications.weekly_summary_projects"),
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
];
return (

View File

@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page";
import { Membership } from "./types";
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
}));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
}));
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
email: "test@example.com",
notificationSettings: {
alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"],
},
};
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
// after `setCompleteNotificationSettings` processes it.
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
// It iterates memberships, then projects, then environments, then surveys.
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
// This means only survey IDs found in memberships will be in the new `alert` object.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = {
alert: {
"survey-1": false,
"survey-2": false,
},
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"],
};
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
});
test("throws error if session is not found", async () => {
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editAlertsCall.memberships).toEqual([]);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editWeeklySummaryCall.memberships).toEqual([]);
});
test("handles legacy notification settings correctly", async () => {
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
id: "user-legacy",
notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure
};
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
"survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership
},
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [],
};

View File

@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types";
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[project.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
// set default values for alerts
for (const environment of project.environments) {
for (const survey of environment.surveys) {
@@ -183,11 +178,6 @@ const Page = async (props) => {
/>
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
title={t("environments.settings.notifications.weekly_summary_projects")}
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</PageContentWrapper>
);
};

View File

@@ -20,7 +20,7 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -15,7 +15,7 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -13,7 +13,7 @@ const mockUser = {
locale: "en-US",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -76,7 +76,7 @@ const mockUser = {
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",

View File

@@ -129,7 +129,7 @@ const mockUser = {
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;

View File

@@ -139,7 +139,7 @@ const mockUser = {
updatedAt: new Date(),
role: "project_manager",
objective: "increase_conversion",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [

View File

@@ -305,12 +305,8 @@ const mockUser: TUser = {
isActive: true,
notificationSettings: {
alert: {
weeklySummary: true,
responseFinished: true,
},
weeklySummary: {
test: true,
},
unsubscribedOrganizationIds: [],
},
};

View File

@@ -253,7 +253,6 @@ const mockUser: TUser = {
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
};

View File

@@ -1,276 +0,0 @@
import { convertResponseValue } from "@/lib/responses";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummarySurveyData,
} from "@formbricks/types/weekly-summary";
import { getNotificationResponse } from "./notificationResponse";
vi.mock("@/lib/responses", () => ({
convertResponseValue: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
describe("getNotificationResponse", () => {
afterEach(() => {
cleanup();
});
test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(2);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(2);
expect(notificationResponse.insights.totalResponses).toBe(5);
expect(notificationResponse.insights.completionRate).toBe(60);
expect(notificationResponse.insights.numLiveSurvey).toBe(2);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
});
test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey3",
name: "Survey 3",
status: "inProgress",
questions: [
{
id: "question3",
headline: { default: "Question 3" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display3" }],
responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(3);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(3);
expect(notificationResponse.insights.totalResponses).toBe(6);
expect(notificationResponse.insights.completionRate).toBe(50);
expect(notificationResponse.insights.numLiveSurvey).toBe(3);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
expect(notificationResponse.surveys[2].id).toBe("survey3");
expect(notificationResponse.surveys[2].name).toBe("Survey 3");
expect(notificationResponse.surveys[2].status).toBe("inProgress");
expect(notificationResponse.surveys[2].responseCount).toBe(1);
});
test("should return default insights and an empty surveys array when the environment contains no surveys", () => {
const mockEnvironment = {
id: "env1",
surveys: [],
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(0);
expect(notificationResponse.insights.totalCompletedResponses).toBe(0);
expect(notificationResponse.insights.totalDisplays).toBe(0);
expect(notificationResponse.insights.totalResponses).toBe(0);
expect(notificationResponse.insights.completionRate).toBe(0);
expect(notificationResponse.insights.numLiveSurvey).toBe(0);
});
test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: {} }, // Response missing data for question1
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
// Mock the convertResponseValue function to handle the missing data case
vi.mocked(convertResponseValue).mockReturnValue("");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys).toHaveLength(1);
expect(notificationResponse.surveys[0].responses).toHaveLength(1);
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("");
});
test("should handle unsupported question types gracefully", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "unsupported",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response");
});
});

View File

@@ -1,78 +0,0 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { convertResponseValue } from "@/lib/responses";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummaryNotificationDataSurvey,
TWeeklySummaryNotificationResponse,
TWeeklySummarySurveyResponseData,
} from "@formbricks/types/weekly-summary";
export const getNotificationResponse = (
environment: TWeeklySummaryEnvironmentData,
projectName: string
): TWeeklySummaryNotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const parsedSurvey = replaceHeadlineRecall(survey as unknown as TSurvey, "default") as TSurvey & {
responses: TWeeklyEmailResponseData[];
};
const surveyData: TWeeklySummaryNotificationDataSurvey = {
id: parsedSurvey.id,
name: parsedSurvey.name,
status: parsedSurvey.status,
responseCount: parsedSurvey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of parsedSurvey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 3) {
break;
}
const surveyResponses: TWeeklySummarySurveyResponseData[] = [];
for (const question of parsedSurvey.questions) {
const headline = question.headline;
const responseValue = convertResponseValue(response.data[question.id], question);
const surveyResponse: TWeeklySummarySurveyResponseData = {
headline: getLocalizedValue(headline, "default"),
responseValue,
questionType: question.type,
};
surveyResponses.push(surveyResponse);
}
surveyData.responses = surveyResponses;
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
projectName: projectName,
surveys,
insights,
};
};

View File

@@ -1,48 +0,0 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getOrganizationIds } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findMany: vi.fn(),
},
},
}));
describe("Organization", () => {
afterEach(() => {
cleanup();
});
test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => {
const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual(["org1", "org2", "org3"]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
test("getOrganizationIds should return an empty array when the database contains no organizations", async () => {
vi.mocked(prisma.organization.findMany).mockResolvedValue([]);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual([]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
});

View File

@@ -1,10 +0,0 @@
import { prisma } from "@formbricks/database";
export const getOrganizationIds = async (): Promise<string[]> => {
const organizations = await prisma.organization.findMany({
select: {
id: true,
},
});
return organizations.map((organization) => organization.id);
};

View File

@@ -1,570 +0,0 @@
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getProjectsByOrganizationId } from "./project";
const mockProjects = [
{
id: "project1",
name: "Project 1",
environments: [
{
id: "env1",
type: "production",
surveys: [],
attributeKeys: [],
},
],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project1: true,
},
},
locale: "en",
},
},
],
},
},
];
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days
const mockProjectsWithNoEnvironments = [
{
id: "project3",
name: "Project 3",
environments: [],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project3: true,
},
},
locale: "en",
},
},
],
},
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
},
}));
describe("Project Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("getProjectsByOrganizationId", () => {
test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles date calculations correctly across DST boundaries", async () => {
const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary)
const sevenDaysAgo = new Date(mockDate);
sevenDaysAgo.setDate(mockDate.getDate() - 7);
vi.useFakeTimers();
vi.setSystemTime(mockDate);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
await getProjectsByOrganizationId(organizationId);
expect(prisma.project.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId: organizationId,
},
select: expect.objectContaining({
environments: expect.objectContaining({
select: expect.objectContaining({
surveys: expect.objectContaining({
where: expect.objectContaining({
NOT: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({ status: "completed" }),
expect.objectContaining({
responses: expect.objectContaining({
none: expect.objectContaining({
createdAt: expect.objectContaining({
gte: sevenDaysAgo,
}),
}),
}),
}),
]),
}),
}),
}),
}),
}),
}),
})
);
vi.useRealTimers();
});
test("includes surveys with 'completed' status but responses within the last 7 days", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("returns an empty array when an invalid organization ID is provided", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]);
const invalidOrganizationId = "invalidOrgId";
const projects = await getProjectsByOrganizationId(invalidOrganizationId);
expect(projects).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: invalidOrganizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles projects with no environments", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjectsWithNoEnvironments);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
});
});

View File

@@ -1,111 +0,0 @@
import { prisma } from "@formbricks/database";
import { TWeeklySummaryProjectData } from "@formbricks/types/weekly-summary";
export const getProjectsByOrganizationId = async (
organizationId: string
): Promise<TWeeklySummaryProjectData[]> => {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return await prisma.project.findMany({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: sevenDaysAgo,
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
};

View File

@@ -1,70 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { CRON_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "@/modules/email";
import { headers } from "next/headers";
import { getNotificationResponse } from "./lib/notificationResponse";
import { getOrganizationIds } from "./lib/organization";
import { getProjectsByOrganizationId } from "./lib/project";
const BATCH_SIZE = 500;
export const POST = async (): Promise<Response> => {
const headersList = await headers();
// Check authentication
if (headersList.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const emailSendingPromises: Promise<void>[] = [];
// Fetch all organization IDs
const organizationIds = await getOrganizationIds();
// Paginate through organizations
for (let i = 0; i < organizationIds.length; i += BATCH_SIZE) {
const batchedOrganizationIds = organizationIds.slice(i, i + BATCH_SIZE);
// Fetch projects for batched organizations asynchronously
const batchedProjectsPromises = batchedOrganizationIds.map((organizationId) =>
getProjectsByOrganizationId(organizationId)
);
const batchedProjects = await Promise.all(batchedProjectsPromises);
for (const projects of batchedProjects) {
for (const project of projects) {
const organizationMembers = project.organization.memberships;
const organizationMembersWithNotificationEnabled = organizationMembers.filter(
(member) =>
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[project.id]
);
if (organizationMembersWithNotificationEnabled.length === 0) continue;
const notificationResponse = getNotificationResponse(project.environments[0], project.name);
if (notificationResponse.insights.numLiveSurvey === 0) {
for (const organizationMember of organizationMembersWithNotificationEnabled) {
if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(organizationMember.user.email, notificationResponse)
);
}
}
continue;
}
for (const organizationMember of organizationMembersWithNotificationEnabled) {
if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(organizationMember.user.email, notificationResponse)
);
}
}
}
}
}
await Promise.all(emailSendingPromises);
return responses.successResponse({}, true);
};

View File

@@ -127,7 +127,6 @@ describe("Page", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
@@ -171,7 +170,6 @@ describe("Page", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
@@ -261,7 +259,6 @@ describe("Page", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
@@ -351,7 +348,6 @@ describe("Page", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",

View File

@@ -273,7 +273,6 @@ describe("Organization Service", () => {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: [], // User is subscribed to all organizations
},
} as any;
@@ -296,7 +295,7 @@ describe("Organization Service", () => {
"existing-survey-id": true,
"survey-123": true,
},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
});
@@ -307,7 +306,6 @@ describe("Organization Service", () => {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization
},
} as any;

View File

@@ -296,7 +296,7 @@ export const subscribeOrganizationMembersToSurveyResponses = async (
return;
}
const defaultSettings = { alert: {}, weeklySummary: {} };
const defaultSettings = { alert: {} };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...surveyCreator.notificationSettings,

View File

@@ -130,7 +130,7 @@ export const mockUser: TUser = {
objective: "improve_user_retention",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "other",

View File

@@ -48,7 +48,7 @@ describe("User Service", () => {
objective: Objective.increase_conversion,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as TUserLocale,

View File

@@ -107,7 +107,6 @@ export const InvitePage = async (props: InvitePageProps) => {
notificationSettings: {
...user.notificationSettings,
alert: user.notificationSettings.alert ?? {},
weeklySummary: user.notificationSettings.weeklySummary ?? {},
unsubscribedOrganizationIds: Array.from(
new Set([
...(user.notificationSettings?.unsubscribedOrganizationIds || []),

View File

@@ -13,7 +13,7 @@ export const mockUser: TUser = {
objective: "improve_user_retention",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "other",

View File

@@ -124,7 +124,7 @@ async function handleInviteAcceptance(
await updateUser(user.id, {
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [invite.organizationId],
},
});
@@ -149,7 +149,7 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
notificationSettings: {
...user.notificationSettings,
alert: { ...user.notificationSettings?.alert },
weeklySummary: { ...user.notificationSettings?.weeklySummary },
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds ?? []), organization.id])
),

View File

@@ -127,7 +127,6 @@ describe("withAuditLogging", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
},
},
organizationId: "org1",
@@ -167,7 +166,6 @@ describe("withAuditLogging", () => {
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
},
},
organizationId: "org1",

View File

@@ -38,7 +38,6 @@ describe("ResponseTimeline", () => {
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
},
locale: "en-US",
lastLoginAt: new Date(),

View File

@@ -216,9 +216,6 @@ export const handleSsoCallback = async ({
unsubscribedOrganizationIds: Array.from(
new Set([...(userProfile.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
),
weeklySummary: {
...userProfile.notificationSettings?.weeklySummary,
},
};
await updateUser(userProfile.id, {

View File

@@ -9,7 +9,7 @@ export const mockUser: TUser = {
name: "Test User",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
emailVerified: new Date(),

View File

@@ -1,41 +0,0 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/tolgee/server";
import { Container, Text } from "@react-email/components";
import React from "react";
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weekly-summary";
import { EmailButton } from "../../components/email-button";
import { NotificationFooter } from "./notification-footer";
interface CreateReminderNotificationBodyProps {
notificationData: TWeeklySummaryNotificationResponse;
}
export async function CreateReminderNotificationBody({
notificationData,
}: CreateReminderNotificationBodyProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<Container>
<Text className="text-sm">
{t("emails.weekly_summary_create_reminder_notification_body_text", {
projectName: notificationData.projectName,
})}
</Text>
<Text className="pt-4 text-sm font-medium">
{t("emails.weekly_summary_create_reminder_notification_body_dont_let_a_week_pass")}
</Text>
<EmailButton
href={`${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA`}
label={t("emails.weekly_summary_create_reminder_notification_body_setup_a_new_survey")}
/>
<Text className="pt-4 text-sm">
{t("emails.weekly_summary_create_reminder_notification_body_need_help")}
<a href="https://cal.com/johannes/15">
{t("emails.weekly_summary_create_reminder_notification_body_cal_slot")}
</a>
{t("emails.weekly_summary_create_reminder_notification_body_reply_email")}
</Text>
<NotificationFooter environmentId={notificationData.environmentId} />
</Container>
);
}

View File

@@ -1,123 +0,0 @@
import { WEBAPP_URL } from "@/lib/constants";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { Container, Hr, Link, Tailwind, Text } from "@react-email/components";
import { TFnType } from "@tolgee/react";
import React, { type JSX } from "react";
import type { TSurveyStatus } from "@formbricks/types/surveys/types";
import type {
TWeeklySummaryNotificationDataSurvey,
TWeeklySummarySurveyResponseData,
} from "@formbricks/types/weekly-summary";
import { EmailButton } from "../../components/email-button";
const getButtonLabel = (count: number, t: TFnType): string => {
if (count === 1) {
return t("emails.live_survey_notification_view_response");
}
return t("emails.live_survey_notification_view_more_responses", {
responseCount: count > 2 ? (count - 1).toString() : "1",
});
};
const convertSurveyStatus = (status: TSurveyStatus, t: TFnType): string => {
const statusMap = {
inProgress: t("emails.live_survey_notification_in_progress"),
paused: t("emails.live_survey_notification_paused"),
completed: t("emails.live_survey_notification_completed"),
draft: t("emails.live_survey_notification_draft"),
scheduled: t("emails.live_survey_notification_scheduled"),
};
return statusMap[status] || status;
};
interface LiveSurveyNotificationProps {
environmentId: string;
surveys: TWeeklySummaryNotificationDataSurvey[];
}
export async function LiveSurveyNotification({
environmentId,
surveys,
}: LiveSurveyNotificationProps): Promise<React.JSX.Element[]> {
const t = await getTranslate();
const createSurveyFields = (
surveyResponses: TWeeklySummarySurveyResponseData[]
): React.JSX.Element | React.JSX.Element[] => {
if (surveyResponses.length === 0) {
return (
<Container className="mt-4">
<Text className="m-0 text-sm font-medium">
{t("emails.live_survey_notification_no_responses_yet")}
</Text>
</Container>
);
}
const surveyFields: JSX.Element[] = [];
const responseCount = surveyResponses.length;
surveyResponses.forEach((surveyResponse, index) => {
if (!surveyResponse.responseValue) {
return;
}
surveyFields.push(
<Container className="mt-4" key={`${index.toString()}-${surveyResponse.headline}`}>
<Text className="m-0 text-sm">{surveyResponse.headline}</Text>
{renderEmailResponseValue(surveyResponse.responseValue, surveyResponse.questionType, t)}
</Container>
);
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
if (responseCount >= 2 && index < responseCount - 1) {
surveyFields.push(<Hr key={`hr-${index.toString()}`} />);
}
});
return surveyFields;
};
if (!surveys.length) return [];
return surveys.map((survey) => {
const displayStatus = convertSurveyStatus(survey.status, t);
const isInProgress = displayStatus === "In Progress";
const noResponseLastWeek = isInProgress && survey.responses.length === 0;
return (
<Tailwind key={survey.id}>
<Container className="mt-12">
<Text className="mb-0 inline">
<Link
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}>
{survey.name}
</Link>
</Text>
<Text
className={`ml-2 inline ${isInProgress ? "bg-green-400 text-slate-100" : "bg-slate-300 text-blue-800"} rounded-full px-2 py-1 text-sm`}>
{displayStatus}
</Text>
{noResponseLastWeek ? (
<Text className="text-sm">{t("emails.live_survey_notification_no_new_response")}</Text>
) : (
createSurveyFields(survey.responses)
)}
{survey.responseCount > 0 && (
<Container className="mt-4 block text-sm">
<EmailButton
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
label={
noResponseLastWeek
? t("emails.live_survey_notification_view_previous_responses")
: getButtonLabel(survey.responseCount, t)
}
/>
</Container>
)}
</Container>
</Tailwind>
);
});
}

View File

@@ -1,33 +0,0 @@
import React from "react";
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weekly-summary";
import { CreateReminderNotificationBody } from "./create-reminder-notification-body";
import { NotificationHeader } from "./notification-header";
interface NoLiveSurveyNotificationEmailProps {
notificationData: TWeeklySummaryNotificationResponse;
startDate: string;
endDate: string;
startYear: number;
endYear: number;
}
export function NoLiveSurveyNotificationEmail({
notificationData,
startDate,
endDate,
startYear,
endYear,
}: NoLiveSurveyNotificationEmailProps): React.JSX.Element {
return (
<div>
<NotificationHeader
endDate={endDate}
endYear={endYear}
projectName={notificationData.projectName}
startDate={startDate}
startYear={startYear}
/>
<CreateReminderNotificationBody notificationData={notificationData} />
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/tolgee/server";
import { Container, Link, Tailwind, Text } from "@react-email/components";
import React from "react";
interface NotificatonFooterProps {
environmentId: string;
}
export async function NotificationFooter({
environmentId,
}: NotificatonFooterProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<Tailwind>
<Container className="w-full">
<Text className="mb-0 pt-4 text-sm font-medium">{t("emails.notification_footer_all_the_best")}</Text>
<Text className="mt-0 text-sm">{t("emails.notification_footer_the_formbricks_team")}</Text>
<Container
className="mt-0 w-full rounded-md bg-slate-100 px-4 text-center text-xs leading-5"
style={{ fontStyle: "italic" }}>
<Text className="text-sm">
{t("emails.notification_footer_to_halt_weekly_updates")}
<Link
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications`}>
{t("emails.notification_footer_please_turn_them_off")}
</Link>{" "}
{t("emails.notification_footer_in_your_settings")} 🙏
</Text>
</Container>
</Container>
</Tailwind>
);
}

View File

@@ -1,51 +0,0 @@
import { getTranslate } from "@/tolgee/server";
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
interface NotificationHeaderProps {
projectName: string;
startDate: string;
endDate: string;
startYear: number;
endYear: number;
}
export async function NotificationHeader({
projectName,
startDate,
endDate,
startYear,
endYear,
}: NotificationHeaderProps): Promise<React.JSX.Element> {
const t = await getTranslate();
const getNotificationHeaderTimePeriod = (): React.JSX.Element => {
if (startYear === endYear) {
return (
<Text className="m-0 text-right text-sm">
{startDate} - {endDate} {endYear}
</Text>
);
}
return (
<Text className="m-0 text-right text-sm">
{startDate} {startYear} - {endDate} {endYear}
</Text>
);
};
return (
<Container>
<div className="block px-0 py-4">
<div className="float-left mt-2">
<Heading className="m-0">{t("emails.notification_header_hey")}</Heading>
</div>
<div className="float-right">
<Text className="m-0 text-right text-sm font-medium">
{t("emails.notification_header_weekly_report_for")} {projectName}
</Text>
{getNotificationHeaderTimePeriod()}
</div>
</div>
</Container>
);
}

View File

@@ -1,46 +0,0 @@
import { getTranslate } from "@/tolgee/server";
import { Column, Container, Row, Section, Text } from "@react-email/components";
import React from "react";
import type { TWeeklySummaryInsights } from "@formbricks/types/weekly-summary";
interface NotificationInsightProps {
insights: TWeeklySummaryInsights;
}
export async function NotificationInsight({
insights,
}: NotificationInsightProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<Container>
<Section className="my-4 rounded-md bg-slate-100">
<Row>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_surveys")}</Text>
<Text className="text-sm font-medium">{insights.numLiveSurvey}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_displays")}</Text>
<Text className="text-sm font-medium">{insights.totalDisplays}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_responses")}</Text>
<Text className="text-sm font-medium">{insights.totalResponses}</Text>
</Column>
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_completed")}</Text>
<Text className="text-sm font-medium">{insights.totalCompletedResponses}</Text>
</Column>
{insights.totalDisplays !== 0 ? (
<Column className="text-center">
<Text className="text-sm">{t("emails.notification_insight_completion_rate")}</Text>
<Text className="text-sm font-medium">{Math.round(insights.completionRate)}%</Text>
</Column>
) : (
""
)}
</Row>
</Section>
</Container>
);
}

View File

@@ -1,44 +0,0 @@
import { TFnType } from "@tolgee/react";
import React from "react";
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weekly-summary";
import { EmailTemplate } from "../../components/email-template";
import { LiveSurveyNotification } from "./live-survey-notification";
import { NotificationFooter } from "./notification-footer";
import { NotificationHeader } from "./notification-header";
import { NotificationInsight } from "./notification-insight";
interface WeeklySummaryNotificationEmailProps {
notificationData: TWeeklySummaryNotificationResponse;
startDate: string;
endDate: string;
startYear: number;
endYear: number;
t: TFnType;
}
export function WeeklySummaryNotificationEmail({
notificationData,
startDate,
endDate,
startYear,
endYear,
t,
}: WeeklySummaryNotificationEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<NotificationHeader
endDate={endDate}
endYear={endYear}
projectName={notificationData.projectName}
startDate={startDate}
startYear={startYear}
/>
<NotificationInsight insights={notificationData.insights} />
<LiveSurveyNotification
environmentId={notificationData.environmentId}
surveys={notificationData.surveys}
/>
<NotificationFooter environmentId={notificationData.environmentId} />
</EmailTemplate>
);
}

View File

@@ -26,7 +26,6 @@ import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import type { TWeeklySummaryNotificationResponse } from "@formbricks/types/weekly-summary";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
@@ -35,8 +34,6 @@ import { InviteEmail } from "./emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
import { NoLiveSurveyNotificationEmail } from "./emails/weekly-summary/no-live-survey-notification-email";
import { WeeklySummaryNotificationEmail } from "./emails/weekly-summary/weekly-summary-notification-email";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
@@ -290,70 +287,3 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
html,
});
};
export const sendWeeklySummaryNotificationEmail = async (
email: string,
notificationData: TWeeklySummaryNotificationResponse
): Promise<void> => {
const startDate = `${notificationData.lastWeekDate.getDate().toString()} ${notificationData.lastWeekDate.toLocaleString(
"default",
{ month: "short" }
)}`;
const endDate = `${notificationData.currentDate.getDate().toString()} ${notificationData.currentDate.toLocaleString(
"default",
{ month: "short" }
)}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
const t = await getTranslate();
const html = await render(
WeeklySummaryNotificationEmail({
notificationData,
startDate,
endDate,
startYear,
endYear,
t,
})
);
await sendEmail({
to: email,
subject: t("emails.weekly_summary_email_subject", {
projectName: notificationData.projectName,
}),
html,
});
};
export const sendNoLiveSurveyNotificationEmail = async (
email: string,
notificationData: TWeeklySummaryNotificationResponse
): Promise<void> => {
const startDate = `${notificationData.lastWeekDate.getDate().toString()} ${notificationData.lastWeekDate.toLocaleString(
"default",
{ month: "short" }
)}`;
const endDate = `${notificationData.currentDate.getDate().toString()} ${notificationData.currentDate.toLocaleString(
"default",
{ month: "short" }
)}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
const t = await getTranslate();
const html = await render(
NoLiveSurveyNotificationEmail({
notificationData,
startDate,
endDate,
startYear,
endYear,
})
);
await sendEmail({
to: email,
subject: t("emails.weekly_summary_email_subject", {
projectName: notificationData.projectName,
}),
html,
});
};

View File

@@ -36,7 +36,7 @@ export const createOrganizationAction = authenticatedActionClient.schema(ZCreate
accepted: true,
});
const project = await createProject(newOrganization.id, {
await createProject(newOrganization.id, {
name: "My Project",
});
@@ -45,10 +45,7 @@ export const createOrganizationAction = authenticatedActionClient.schema(ZCreate
alert: {
...ctx.user.notificationSettings?.alert,
},
weeklySummary: {
...ctx.user.notificationSettings?.weeklySummary,
[project.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(ctx.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id]) // NOSONAR // We want to check for empty strings too
),

View File

@@ -63,7 +63,6 @@ const mockUser = {
updatedAt: new Date(),
notificationSettings: {
alert: {},
weeklySummary: {},
},
role: "other",
} as TUser;

View File

@@ -160,16 +160,6 @@ describe("CreateOrganizationPage", () => {
surveyUpdated: true,
surveyCreated: true,
},
weeklySummary: {
surveyInvite: true,
surveyResponse: true,
surveyClosed: true,
surveyPaused: true,
surveyCompleted: true,
surveyDeleted: true,
surveyUpdated: true,
surveyCreated: true,
},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,

View File

@@ -1,3 +1,3 @@
0 0 * * * curl $WEBAPP_URL/api/cron/survey-status -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"''
0 8 * * 1 curl $WEBAPP_URL/api/cron/weekly-summary -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"''
0 9 * * * curl $WEBAPP_URL/api/cron/ping -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"''

View File

@@ -48,29 +48,7 @@ cronJob:
tag: latest
schedule: 0 0 * * *
successfulJobsHistoryLimit: 0
weekly-summary:
args:
- /bin/sh
- -c
- 'curl -X POST -H "content-type: application/json" -H "x-api-key: $CRON_SECRET"
"$WEBAPP_URL/api/cron/weekly-summary"'
env:
CRON_SECRET:
valueFrom:
secretKeyRef:
key: CRON_SECRET
name: formbricks-stage-app-env
WEBAPP_URL:
valueFrom:
secretKeyRef:
key: WEBAPP_URL
name: formbricks-stage-app-env
image:
imagePullPolicy: IfNotPresent
repository: quay.io/curl/curl
tag: latest
schedule: 0 8 * * 1
successfulJobsHistoryLimit: 0
## Deployment & Autoscaling
deployment:

View File

@@ -47,29 +47,7 @@ cronJob:
tag: latest
schedule: 0 0 * * *
successfulJobsHistoryLimit: 0
weekely-summary:
args:
- /bin/sh
- -c
- 'curl -X POST -H "content-type: application/json" -H "x-api-key: $CRON_SECRET"
"$WEBAPP_URL/api/cron/weekly-summary"'
env:
CRON_SECRET:
valueFrom:
secretKeyRef:
key: CRON_SECRET
name: formbricks-app-env
WEBAPP_URL:
valueFrom:
secretKeyRef:
key: WEBAPP_URL
name: formbricks-app-env
image:
imagePullPolicy: IfNotPresent
repository: quay.io/curl/curl
tag: latest
schedule: 0 8 * * 1
successfulJobsHistoryLimit: 0
## Deployment & Autoscaling
deployment:

View File

@@ -0,0 +1,15 @@
import type { MigrationScript } from "../../src/scripts/migration-runner";
export const sunsetWeeklySummary: MigrationScript = {
type: "data",
id: "v0ruecekavlfu8410htz4mly",
name: "20250722075349_sunset_weekly_summary",
run: async ({ tx }) => {
// Remove weeklySummary from notificationSettings for all users where it is set
await tx.$queryRaw`
UPDATE "User"
SET "notificationSettings" = "notificationSettings" - 'weeklySummary'
WHERE "notificationSettings"->>'weeklySummary' IS NOT NULL
`;
},
};

View File

@@ -18,7 +18,6 @@ export type TUserObjective = z.infer<typeof ZUserObjective>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
unsubscribedOrganizationIds: z.array(z.string()).optional(),
});

View File

@@ -1,104 +0,0 @@
import { z } from "zod";
import { ZContactAttributeKey } from "./contact-attribute-key";
import { ZResponseData } from "./responses";
import { ZSurveyHiddenFields, ZSurveyQuestion, ZSurveyQuestionType, ZSurveyStatus } from "./surveys/types";
import { ZUserNotificationSettings } from "./user";
const ZWeeklySummaryInsights = z.object({
totalCompletedResponses: z.number(),
totalDisplays: z.number(),
totalResponses: z.number(),
completionRate: z.number(),
numLiveSurvey: z.number(),
});
export type TWeeklySummaryInsights = z.infer<typeof ZWeeklySummaryInsights>;
export const ZWeeklySummarySurveyResponseData = z.object({
headline: z.string(),
responseValue: z.union([z.string(), z.array(z.string())]),
questionType: ZSurveyQuestionType,
});
export type TWeeklySummarySurveyResponseData = z.infer<typeof ZWeeklySummarySurveyResponseData>;
export const ZWeeklySummaryNotificationDataSurvey = z.object({
id: z.string(),
name: z.string(),
responses: z.array(ZWeeklySummarySurveyResponseData),
responseCount: z.number(),
status: ZSurveyStatus,
});
export type TWeeklySummaryNotificationDataSurvey = z.infer<typeof ZWeeklySummaryNotificationDataSurvey>;
export const ZWeeklySummaryNotificationResponse = z.object({
environmentId: z.string(),
currentDate: z.date(),
lastWeekDate: z.date(),
projectName: z.string(),
surveys: z.array(ZWeeklySummaryNotificationDataSurvey),
insights: ZWeeklySummaryInsights,
});
export type TWeeklySummaryNotificationResponse = z.infer<typeof ZWeeklySummaryNotificationResponse>;
export const ZWeeklyEmailResponseData = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
finished: z.boolean(),
data: ZResponseData,
});
export type TWeeklyEmailResponseData = z.infer<typeof ZWeeklyEmailResponseData>;
export const ZWeeklySummarySurveyData = z.object({
id: z.string(),
name: z.string(),
questions: z.array(ZSurveyQuestion),
status: ZSurveyStatus,
responses: z.array(ZWeeklyEmailResponseData),
displays: z.array(z.object({ id: z.string() })),
hiddenFields: ZSurveyHiddenFields,
});
export type TWeeklySummarySurveyData = z.infer<typeof ZWeeklySummarySurveyData>;
export const ZWeeklySummaryEnvironmentData = z.object({
id: z.string(),
surveys: z.array(ZWeeklySummarySurveyData),
attributeKeys: z.array(ZContactAttributeKey),
});
export type TWeeklySummaryEnvironmentData = z.infer<typeof ZWeeklySummaryEnvironmentData>;
export const ZWeeklySummaryUserData = z.object({
id: z.string(),
email: z.string(),
notificationSettings: ZUserNotificationSettings,
locale: z.string(),
});
export type TWeeklySummaryUserData = z.infer<typeof ZWeeklySummaryUserData>;
export const ZWeeklySummaryMembershipData = z.object({
user: ZWeeklySummaryUserData,
});
export type TWeeklySummaryMembershipData = z.infer<typeof ZWeeklySummaryMembershipData>;
export const ZWeeklyEmailOrganizationData = z.object({
memberships: z.array(ZWeeklySummaryMembershipData),
});
export type TWeeklyEmailOrganizationData = z.infer<typeof ZWeeklyEmailOrganizationData>;
export const ZWeeklySummaryProjectData = z.object({
id: z.string(),
name: z.string(),
environments: z.array(ZWeeklySummaryEnvironmentData),
organization: ZWeeklyEmailOrganizationData,
});
export type TWeeklySummaryProjectData = z.infer<typeof ZWeeklySummaryProjectData>;