Skip to content

Commit

Permalink
feat: orgs trigger alert when loading a calendar and no availability …
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
3 people committed May 3, 2024
1 parent 88f4dd2 commit c478f73
Show file tree
Hide file tree
Showing 19 changed files with 508 additions and 10 deletions.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
10 changes: 6 additions & 4 deletions packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
55 changes: 55 additions & 0 deletions packages/emails/src/templates/OrganizationAdminNoSlots.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
1 change: 1 addition & 0 deletions packages/emails/src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificat
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
51 changes: 51 additions & 0 deletions packages/emails/templates/organization-admin-no-slots-email.ts
Original file line number Diff line number Diff line change
@@ -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
`;
}
}
Original file line number Diff line number Diff line change
@@ -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"
/>
</>
);
};
2 changes: 2 additions & 0 deletions packages/features/ee/organizations/pages/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -87,6 +88,7 @@ const OrgGeneralView = () => {
/>

<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} isAdminOrOwner={!!isAdminOrOwner} />
</LicenseRequired>
);
};
Expand Down
11 changes: 11 additions & 0 deletions packages/features/redis/IRedisService.d.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions packages/features/redis/RedisService.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrganizationSettings" ADD COLUMN "adminGetsNoSlotsNotification" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
},
select: {
lockEventTypeCreationForUsers: true,
adminGetsNoSlotsNotification: true,
isAdminReviewed: true,
},
});
Expand All @@ -52,6 +53,7 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
canAdminImpersonate: !!organizationSettings?.isAdminReviewed,
organizationSettings: {
lockEventTypeCreationForUsers: organizationSettings?.lockEventTypeCreationForUsers,
adminGetsNoSlotsNotification: organizationSettings?.adminGetsNoSlotsNotification,
},
user: {
role: membership?.role,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 comments on commit c478f73

Please sign in to comment.