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:
co-authored by
Hariom Balhara
Joe Au-Yeung
parent
88f4dd2446
commit
c478f73327
@@ -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 />There’s 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}}",
|
||||
|
||||
@@ -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 />
|
||||
There’s 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>
|
||||
);
|
||||
};
|
||||
@@ -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}.
|
||||
|
||||
There’s 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
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationSettings" ADD COLUMN "adminGetsNoSlotsNotification" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user