feat: orgs trigger alert when loading a calendar and no availability is (#14796)

* Install upstash redis again - base of noficationSender

* Setup email handler etc and use tasker

* Remove logs

* Sending emails and correct format and spacing in emails

* Update email styles

* Update email styles

* add switch to enable feature

* fix: reset tasker types - will fix in new PR

* WIP: test suite

* WIP: test suite

* Add redis service and add WIP mock test

* Add tests for redis service and fix lpush

* More working tests

* More working tests

* fix: type error + typo

* update export to match i18n next mock

* (debug) push for debug

* Fix: fixed hosting in mocks

* Add common.json i18n

* add better test descriptions

* reset mocks after each test to allow concurancy

* Remove redundant RedisService.test.ts

* Rename and remove redundant isAdmin check

* Rename to .d.ts

* Add key versioning to constructRedisKey

* Update packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Add try/catch

* fix breaking when option === undefined

* Add missing await to Promise.all

* Rename data stored in redis to save space

* Include thrown error in logs

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
sean-brydon
2024-05-03 13:48:04 +00:00
committed by GitHub
co-authored by Hariom Balhara Joe Au-Yeung
parent 88f4dd2446
commit c478f73327
19 changed files with 508 additions and 10 deletions
+1
View File
@@ -65,6 +65,7 @@
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.5.0",
@@ -2333,6 +2333,13 @@
"date": "Date",
"overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.",
"org_admin_no_slots|subject": "No availability found for {{name}}",
"org_admin_no_slots|heading": "No availability found for {{name}}",
"org_admin_no_slots|content": "Hello Organization Admins,<br /><br />Please note: It has been brought to our attention that {{username}} has not had any availability when a user has visited {{username}}/{{slug}}<br /><br />Theres a few reasons why this could be happening<br />The user does not have any calendars connected<br />Their schedules attached to this event are not enabled<br /> <br />We recommend checking their availability to resolve this.",
"org_admin_no_slots|cta": "Open users availability",
"organization_no_slots_notification_switch_title": "Get notifications when your team has no availability",
"organization_no_slots_notification_switch_description": "Admins will get email notifications when a user tries to book a team member and is faced with 'No availability'. We trigger this email after two occurrences and remind you every 7 days per user. ",
"email_team_invite|subject|added_to_org": "{{user}} added you to the organization {{team}} on {{appName}}",
"email_team_invite|subject|invited_to_org": "{{user}} invited you to join the organization {{team}} on {{appName}}",
"email_team_invite|subject|added_to_subteam": "{{user}} added you to the team {{team}} of organization {{parentTeamName}} on {{appName}}",
+6 -4
View File
@@ -9,6 +9,7 @@ import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail";
import type { OrganizationAdminNoSlotsEmailInput } from "./src/templates/OrganizationAdminNoSlots";
import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import type { OrganizationNotification } from "./templates/admin-organization-notification";
@@ -38,8 +39,7 @@ import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import MonthlyDigestEmail from "./templates/monthly-digest-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import type { OrgAutoInvite } from "./templates/org-auto-join-invite";
import OrgAutoJoinEmail from "./templates/org-auto-join-invite";
import OrganizationAdminNoSlotsEmail from "./templates/organization-admin-no-slots-email";
import type { OrganizationCreation } from "./templates/organization-creation-email";
import OrganizationCreationEmail from "./templates/organization-creation-email";
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
@@ -367,8 +367,10 @@ export const sendOrganizationCreationEmail = async (organizationCreationEvent: O
await sendEmail(() => new OrganizationCreationEmail(organizationCreationEvent));
};
export const sendOrganizationAutoJoinEmail = async (orgInviteEvent: OrgAutoInvite) => {
await sendEmail(() => new OrgAutoJoinEmail(orgInviteEvent));
export const sendOrganizationAdminNoSlotsNotification = async (
orgInviteEvent: OrganizationAdminNoSlotsEmailInput
) => {
await sendEmail(() => new OrganizationAdminNoSlotsEmail(orgInviteEvent));
};
export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
@@ -0,0 +1,55 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { BaseEmailHtml, CallToAction } from "../components";
export type OrganizationAdminNoSlotsEmailInput = {
language: TFunction;
to: {
email: string;
};
user: string;
slug: string;
startTime: string;
editLink: string;
};
export const OrganizationAdminNoSlotsEmail = (
props: OrganizationAdminNoSlotsEmailInput & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={`No availability found for ${props.user}`}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("org_admin_no_slots|heading", { name: props.user })}</>
</p>
<p style={{ fontWeight: 400, fontSize: "16px", lineHeight: "24px" }}>
<Trans i18nKey="org_admin_no_slots|content" values={{ username: props.user, slug: props.slug }}>
Hello Organization Admins,
<br />
<br />
Please note: It has been brought to our attention that {props.user} has not had any availability
when a user has visited {props.user}/{props.slug}
<br />
<br />
Theres a few reasons why this could be happening
<br />
The user does not have any calendars connected
<br />
Their schedules attached to this event are not enabled
</Trans>
</p>
<div style={{ marginTop: "3rem", marginBottom: "0.75rem" }}>
<CallToAction
label={props.language("org_admin_no_slots|cta")}
href={props.editLink}
endIconName="linkIcon"
/>
</div>
</BaseEmailHtml>
);
};
+1
View File
@@ -35,3 +35,4 @@ export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificat
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
@@ -0,0 +1,51 @@
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export type OrganizationAdminNoSlotsEmailInput = {
language: TFunction;
to: {
email: string;
};
user: string;
slug: string;
startTime: string;
editLink: string;
};
export default class OrganizationAdminNoSlotsEmail extends BaseEmail {
adminNoSlots: OrganizationAdminNoSlotsEmailInput;
constructor(adminNoSlots: OrganizationAdminNoSlotsEmailInput) {
super();
this.name = "SEND_ORG_ADMIN_NO_SLOTS_EMAIL_EMAIL";
this.adminNoSlots = adminNoSlots;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: this.adminNoSlots.to.email,
subject: this.adminNoSlots.language("org_admin_no_slots|subject", { name: this.adminNoSlots.user }),
html: await renderEmail("OrganizationAdminNoSlotsEmail", this.adminNoSlots),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
Hi Admins,
It has been brought to our attention that ${this.adminNoSlots.user} has not had availability users have visited ${this.adminNoSlots.user}/${this.adminNoSlots.slug}.
Theres a few reasons why this could be happening
The user does not have any calendars connected
Their schedules attached to this event are not enabled
We recommend checking their availability to resolve this
`;
}
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { SettingsToggle, showToast } from "@calcom/ui";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
}
export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [notificationActive, setNotificationActive] = useState(
!!currentOrg.organizationSettings.adminGetsNoSlotsNotification
);
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
onSettled: () => {
utils.viewer.organizations.listCurrent.invalidate();
},
});
if (!isAdminOrOwner) return null;
return (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("organization_no_slots_notification_switch_title")}
disabled={mutation?.isPending}
description={t("organization_no_slots_notification_switch_description")}
checked={notificationActive}
onCheckedChange={(checked) => {
mutation.mutate({
adminGetsNoSlotsNotification: checked,
});
setNotificationActive(checked);
}}
switchContainerClassName="mt-6"
/>
</>
);
};
@@ -27,6 +27,7 @@ import {
} from "@calcom/ui";
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";
import { NoSlotsNotificationSwitch } from "../components/NoSlotsNotificationSwitch";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
@@ -87,6 +88,7 @@ const OrgGeneralView = () => {
/>
<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
</LicenseRequired>
);
};
+11
View File
@@ -0,0 +1,11 @@
export interface IRedisService {
get: <TData>(key: string) => Promise<TData | null>;
set: <TData>(key: string, value: TData) => Promise<"OK" | TData | null>;
expire: (key: string, seconds: number) => Promise<0 | 1>;
lrange: <TResult = string>(key: string, start: number, end: number) => Promise<TResult[]>;
lpush: <TData>(key: string, ...elements: TData[]) => Promise<number>;
}
+33
View File
@@ -0,0 +1,33 @@
import { Redis } from "@upstash/redis";
import type { IRedisService } from "./IRedisService";
export class RedisService implements IRedisService {
private redis: Redis;
constructor() {
this.redis = Redis.fromEnv();
}
async get<TData>(key: string): Promise<TData | null> {
return this.redis.get(key);
}
async set<TData>(key: string, value: TData): Promise<"OK" | TData | null> {
// Implementation for setting value in Redis
return this.redis.set(key, value);
}
async expire(key: string, seconds: number): Promise<0 | 1> {
// Implementation for setting expiration time for key in Redis
return this.redis.expire(key, seconds);
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
return this.redis.lrange(key, start, end);
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
return this.redis.lpush(key, elements);
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrganizationSettings" ADD COLUMN "adminGetsNoSlotsNotification" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -415,6 +415,7 @@ model OrganizationSettings {
isOrganizationVerified Boolean @default(false)
orgAutoAcceptEmail String
lockEventTypeCreationForUsers Boolean @default(false)
adminGetsNoSlotsNotification Boolean @default(false)
// It decides if instance ADMIN has reviewed the organization or not.
// It is used to allow super sensitive operations like 'impersonation of Org members by Org admin'
isAdminReviewed Boolean @default(false)
@@ -35,6 +35,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
},
select: {
lockEventTypeCreationForUsers: true,
adminGetsNoSlotsNotification: true,
isAdminReviewed: true,
},
});
@@ -52,6 +53,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
organizationSettings: {
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification,
},
user: {
role: membership?.role,
@@ -32,17 +32,24 @@ const updateOrganizationSettings = async ({
input: TUpdateInputSchema;
tx: Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
}) => {
// if lockEventTypeCreation isn't given we don't do anything.
if (typeof input.lockEventTypeCreation === "undefined") {
return;
const data: Prisma.OrganizationSettingsUpdateInput = {};
if (input.hasOwnProperty("lockEventTypeCreation")) {
data.lockEventTypeCreationForUsers = input.lockEventTypeCreation;
}
if (input.hasOwnProperty("adminGetsNoSlotsNotification")) {
data.adminGetsNoSlotsNotification = input.adminGetsNoSlotsNotification;
}
// If no settings values have changed lets skip this update
if (Object.keys(data).length === 0) return;
await tx.organizationSettings.update({
where: {
organizationId,
},
data: {
lockEventTypeCreationForUsers: !!input.lockEventTypeCreation,
},
data,
});
if (input.lockEventTypeCreation) {
@@ -30,6 +30,7 @@ export const ZUpdateInputSchema = z.object({
metadata: teamMetadataSchema.unwrap().optional(),
lockEventTypeCreation: z.boolean().optional(),
lockEventTypeCreationOptions: z.enum(["DELETE", "HIDE"]).optional(),
adminGetsNoSlotsNotification: z.boolean().optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
@@ -0,0 +1,120 @@
import i18nMock from "../../../../../../tests/libs/__mocks__/libServerI18n";
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach } from "vitest";
import dayjs from "@calcom/dayjs";
import * as CalcomEmails from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
vi.mock("@calcom/features/redis/RedisService", () => {
const mockedRedis = vi.fn();
mockedRedis.prototype.lrange = vi.fn();
mockedRedis.prototype.lpush = vi.fn();
mockedRedis.prototype.expire = vi.fn();
return {
RedisService: mockedRedis,
};
});
vi.mock("@calcom/features/flags/server/utils", () => {
// Mock kill switch to be false
return {
getFeatureFlag: vi.fn().mockResolvedValue(false),
};
});
vi.spyOn(CalcomEmails, "sendOrganizationAdminNoSlotsNotification");
describe("(Orgs) Send admin notifications when a user has no availability", () => {
beforeAll(() => {
// Setup env vars
vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "mocked_token");
vi.stubEnv("UPSTASH_REDIS_REST_URL", "mocked_url");
});
beforeEach(() => {
// Setup mocks
prismaMock.membership.findMany.mockResolvedValue([
{
user: {
email: "test@test.com",
locale: "en",
},
},
]);
// @ts-expect-error FIXME - also type error in bookingScenario
i18nMock.getTranslation.mockImplementation(() => {
return new Promise((resolve) => {
const identityFn = (key: string) => key;
resolve(identityFn);
});
});
});
afterEach(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("Should send a notification after 2 times if the org has them enabled", async () => {
const redisService = new RedisService();
const mocked = vi.mocked(redisService);
prismaMock.team.findFirst.mockResolvedValue({
organizationSettings: {
adminGetsNoSlotsNotification: true,
},
});
// Define event and organization details
const eventDetails = {
username: "user1",
eventSlug: "event1",
startTime: dayjs(), // Mocking Dayjs format function
};
const orgDetails = {
currentOrgDomain: "org1",
isValidOrgDomain: true,
};
// Call the function
await handleNotificationWhenNoSlots({ eventDetails, orgDetails });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
// Mock length to be one then recall to trigger email
mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ eventDetails, orgDetails });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
});
it("Should not send a notification if the org has them disabled", async () => {
prismaMock.team.findFirst.mockResolvedValueOnce({
organizationSettings: {
adminGetsNoSlotsNotification: false,
},
});
// Define event and organization details
const eventDetails = {
username: "user1",
eventSlug: "event1",
startTime: dayjs(), // Mocking Dayjs format function
};
const orgDetails = {
currentOrgDomain: "org1",
isValidOrgDomain: true,
};
// Call the function
await handleNotificationWhenNoSlots({ eventDetails, orgDetails });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import type { Dayjs } from "@calcom/dayjs";
import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService";
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
type EventDetails = {
username: string;
eventSlug: string;
startTime: Dayjs;
visitorTimezone?: string;
visitorUid?: string;
};
// Incase any updates are made - lets version the key so we can invalidate
const REDIS_KEY_VERSION = "V1";
// 7 days or 60s in dev
const NO_SLOTS_NOTIFICATION_FREQUENCY = IS_PRODUCTION ? 7 * 24 * 3600 : 60;
const NO_SLOTS_COUNT_FOR_NOTIFICATION = 2;
const constructRedisKey = (eventDetails: EventDetails, orgSlug?: string) => {
return `${REDIS_KEY_VERSION}.${eventDetails.username}:${eventDetails.eventSlug}${
orgSlug ? `@${orgSlug}` : ""
}`;
};
const constructDataHash = (eventDetails: EventDetails) => {
const obj = {
st: eventDetails.startTime.format("YYYY-MM-DD"),
vTz: eventDetails?.visitorTimezone,
vUuid: eventDetails?.visitorUid,
};
return JSON.stringify(obj);
};
export const handleNotificationWhenNoSlots = async ({
eventDetails,
orgDetails,
}: {
eventDetails: EventDetails;
orgDetails: { currentOrgDomain: string | null };
}) => {
// Check for org
if (!orgDetails.currentOrgDomain) return;
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
if (!UPSTASH_ENV_FOUND) return;
// Check org has this setting enabled
const orgSettings = await prisma.team.findFirst({
where: {
slug: orgDetails.currentOrgDomain,
isOrganization: true,
},
select: {
organizationSettings: {
select: {
adminGetsNoSlotsNotification: true,
},
},
},
});
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
const redis = new RedisService();
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain);
// Get only the required amount of data so the request is as small as possible
// We may need to get more data and check the startDate occurrence of this
// Not trigger email if the start months are the same
const usersExistingNoSlots =
(await redis.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ?? [];
await redis.lpush(usersUniqueKey, constructDataHash(eventDetails));
if (!usersExistingNoSlots.length) {
await redis.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
}
// We add one as we know we just added one to the list - saves us re-fetching the data
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
// Get all org admins to send the email too
const foundAdmins = await prisma.membership.findMany({
where: {
team: {
slug: orgDetails.currentOrgDomain,
isOrganization: true,
},
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
user: {
select: {
email: true,
locale: true,
},
},
},
});
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
const emailsToSend: Array<Promise<void>> = [];
// const tasker = getTasker();
for (const admin of foundAdmins) {
const translation = await getTranslation(admin.user.locale ?? "en", "common");
const payload = {
language: translation,
to: {
email: admin.user.email,
},
user: eventDetails.username,
slug: eventDetails.eventSlug,
startTime: eventDetails.startTime.format("YYYY-MM"),
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
editLink: `${WEBAPP_URL}/availability?type=team`,
};
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
}
await Promise.all(emailsToSend);
}
};
@@ -30,6 +30,7 @@ import { TRPCError } from "@trpc/server";
import type { GetScheduleOptions } from "./getSchedule.handler";
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
export const checkIfIsAvailable = ({
time,
@@ -680,6 +681,25 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
);
loggerWithEventDetails.debug(`Available slots: ${JSON.stringify(computedAvailableSlots)}`);
// We only want to run this on single targeted events and not dynamic
if (!computedAvailableSlots.slots && input.usernameList?.length === 1) {
try {
await handleNotificationWhenNoSlots({
eventDetails: {
username: input.usernameList?.[0],
startTime: startTime,
eventSlug: eventType.slug,
},
orgDetails,
});
} catch (e) {
loggerWithEventDetails.error(
`Something has went wrong. Upstash could be down and we have caught the error to not block availability:
${e}`
);
}
}
return {
slots: computedAvailableSlots,
};
+2
View File
@@ -378,6 +378,8 @@
"TWILIO_SID",
"TWILIO_TOKEN",
"TWILIO_VERIFY_SID",
"UPSTASH_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_URL",
"UNKEY_ROOT_KEY",
"USERNAME_BLACKLIST_URL",
"VERCEL_ENV",