Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b6d7db1
Install upstash redis again - base of noficationSender
sean-brydon Apr 26, 2024
4c59fbb
Setup email handler etc and use tasker
sean-brydon Apr 26, 2024
12c6bd1
Remove logs
sean-brydon Apr 27, 2024
530eded
Sending emails and correct format and spacing in emails
sean-brydon Apr 29, 2024
de49b0b
Update email styles
sean-brydon Apr 29, 2024
bcee32a
Update email styles
sean-brydon Apr 29, 2024
ecfaf52
add switch to enable feature
sean-brydon Apr 29, 2024
c042818
fix: reset tasker types - will fix in new PR
sean-brydon Apr 29, 2024
2864163
WIP: test suite
sean-brydon Apr 30, 2024
81456a3
WIP: test suite
sean-brydon Apr 30, 2024
d03339e
Add redis service and add WIP mock test
sean-brydon Apr 30, 2024
1d057a3
Add tests for redis service and fix lpush
sean-brydon Apr 30, 2024
4f34fad
More working tests
sean-brydon Apr 30, 2024
5d4b15b
More working tests
sean-brydon Apr 30, 2024
f71fffa
fix: type error + typo
sean-brydon May 1, 2024
2d41ba1
update export to match i18n next mock
sean-brydon May 1, 2024
b7f13fc
(debug) push for debug
sean-brydon May 1, 2024
0c30295
Merge remote-tracking branch 'refs/remotes/origin/sean/cal-3480-trigg…
sean-brydon May 1, 2024
8969e1d
Fix: fixed hosting in mocks
sean-brydon May 1, 2024
6d3e1c5
Add common.json i18n
sean-brydon May 1, 2024
09042a0
add better test descriptions
sean-brydon May 1, 2024
6042107
reset mocks after each test to allow concurancy
sean-brydon May 1, 2024
8608ec2
Remove redundant RedisService.test.ts
sean-brydon May 1, 2024
efb57e3
Merge remote-tracking branch 'origin/main' into sean/cal-3480-trigger…
sean-brydon May 1, 2024
1edc030
Rename and remove redundant isAdmin check
sean-brydon May 2, 2024
bd2504d
Rename to .d.ts
sean-brydon May 2, 2024
874ddbd
Add key versioning to constructRedisKey
sean-brydon May 2, 2024
50a26cd
Update packages/trpc/server/routers/viewer/slots/handleNotificationWh…
sean-brydon May 2, 2024
f256d18
Update packages/trpc/server/routers/viewer/slots/handleNotificationWh…
sean-brydon May 2, 2024
81475ca
Add try/catch
sean-brydon May 2, 2024
99ac1d4
Merge remote-tracking branch 'refs/remotes/origin/sean/cal-3480-trigg…
sean-brydon May 2, 2024
2098ac2
fix breaking when option === undefined
sean-brydon May 2, 2024
92f0571
Add missing await to Promise.all
sean-brydon May 2, 2024
1e5e248
Rename data stored in redis to save space
sean-brydon May 2, 2024
c6f744a
Include thrown error in logs
sean-brydon May 2, 2024
c7263eb
Merge branch 'main' into sean/cal-3480-trigger-alert-when-loading-a-c…
joeauyeung May 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
5 changes: 5 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,11 @@
"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",

"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 AdminNoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [notifcationActive, setNotifcationActive] = 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 || !isAdminOrOwner}
sean-brydon marked this conversation as resolved.
Show resolved Hide resolved
description={t("organization_no_slots_notification_switch_description")}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

organization_no_slots_notification_switch_title and organization_no_slots_notification_switch_description doesn't exist in common.json

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yeah this should be draft still!

checked={notifcationActive}
sean-brydon marked this conversation as resolved.
Show resolved Hide resolved
onCheckedChange={(checked) => {
mutation.mutate({
adminGetsNoSlotsNotification: checked,
});
setNotifcationActive(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 @@ -25,6 +25,7 @@ import {
TimezoneSelect,
} from "@calcom/ui";

import { AdminNoSlotsNotificationSwitch } from "../components/AdminNoSlotsNotificationSwitch";
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";

const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
Expand Down Expand Up @@ -85,6 +86,7 @@ const OrgGeneralView = () => {
/>

<LockEventTypeSwitch currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} />
<AdminNoSlotsNotificationSwitch currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} />
</LicenseRequired>
);
};
Expand Down
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 @@ -412,6 +412,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;
}
Comment on lines +37 to 43
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to construct data property so we only update what has changed - allows us to pass in only the orgSetting we want to update and not have the previous state of for data before


// 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>;