Skip to content

Commit 4fcf408

Browse files
feat: Send emails to potentially affected Organization Members on disabling delegation credential (calcom#21276)
* mvp * Improve UI * wip * self-review * self-review --------- Co-authored-by: Omar López <zomars@me.com>
1 parent d84d80e commit 4fcf408

15 files changed

Lines changed: 292 additions & 9 deletions

File tree

apps/web/public/static/locales/en/common.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,7 @@
15991599
"team_url": "Team URL",
16001600
"team_members": "Team members",
16011601
"more": "More",
1602+
"and_count_more": "and {{count}} more",
16021603
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.",
16031604
"workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee",
16041605
"workflow_example_2": "Send custom SMS when event is rescheduled to attendee",
@@ -3221,5 +3222,7 @@
32213222
"requires_credits": "Requires credits",
32223223
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
32233224
"available_credits": "Available credits",
3225+
"members_affected_by_disabling_delegation_credential": "Affected members",
3226+
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
32243227
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
32253228
}

packages/emails/email-manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-v
5050
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
5151
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
5252
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
53+
import DelegationCredentialDisabledEmail from "./templates/delegation-credential-disabled-email";
5354
import DisabledAppEmail from "./templates/disabled-app-email";
5455
import type { Feedback } from "./templates/feedback-email";
5556
import FeedbackEmail from "./templates/feedback-email";
@@ -835,3 +836,22 @@ export const sendCreditBalanceLimitReachedEmails = async ({
835836
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
836837
}
837838
};
839+
840+
export const sendDelegationCredentialDisabledEmail = async ({
841+
recipientEmail,
842+
recipientName,
843+
connectionName,
844+
}: {
845+
recipientEmail: string;
846+
recipientName?: string;
847+
connectionName: string;
848+
}) => {
849+
await sendEmail(
850+
() =>
851+
new DelegationCredentialDisabledEmail({
852+
recipientEmail,
853+
recipientName,
854+
connectionName,
855+
})
856+
);
857+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
2+
3+
import BaseEmail from "./_base-email";
4+
5+
export default class DelegationCredentialDisabledEmail extends BaseEmail {
6+
recipientEmail: string;
7+
recipientName?: string;
8+
connectionName: string;
9+
10+
constructor({
11+
recipientEmail,
12+
recipientName,
13+
connectionName,
14+
}: {
15+
recipientEmail: string;
16+
recipientName?: string;
17+
connectionName: string;
18+
}) {
19+
super();
20+
this.name = "DELEGATION_CREDENTIAL_DISABLED";
21+
this.recipientEmail = recipientEmail;
22+
this.recipientName = recipientName;
23+
this.connectionName = connectionName;
24+
}
25+
26+
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
27+
return {
28+
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
29+
to: this.recipientEmail,
30+
subject: `You might need to connect your ${this.connectionName} Calendar`,
31+
html: await this.getHtml(),
32+
text: this.getTextBody(),
33+
};
34+
}
35+
36+
async getHtml() {
37+
return `
38+
<div style="font-family: Arial, sans-serif;">
39+
<h2>Action Required: Connect your ${this.connectionName} Calendar</h2>
40+
<p>
41+
${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
42+
</p>
43+
<p>
44+
An admin has disabled Delegation Credential for your organization. You may need to connect your <b>${
45+
this.connectionName
46+
} Calendar</b> manually to continue receiving bookings and updates.
47+
</p>
48+
<p>
49+
Please log in to your Cal.com account and connect your calendar from your settings page.
50+
</p>
51+
<p>
52+
If you have already connected your calendar, you can ignore this message.
53+
</p>
54+
<p>
55+
Thank you,<br />The Cal.com Team
56+
</p>
57+
</div>
58+
`;
59+
}
60+
61+
protected getTextBody(): string {
62+
return `Action Required: Connect your ${this.connectionName} Calendar\n\nAn admin has disabled Delegation Credential for your organization. You may need to connect your ${this.connectionName} Calendar manually to continue receiving bookings and updates.\n\nPlease log in to your Cal.com account and connect your calendar from your settings page.\n\nIf you have already connected your calendar, you can ignore this message.\n\nThank you,\nThe Cal.com Team`;
63+
}
64+
}

packages/features/delegation-credentials/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
113113

114114
Cron jobs ensure that for each and every member of the organization that has Delegation Credential enabled, corresponding SelectedCalendar records are there. These crons currently run every 5 minutes and process a batch in one run to avoid overloading the DB and third party CalendarAPIs, look at vercel.json for the up-to-date schedule.
115115

116-
- `credentials` cron job creates Delegation User Credential records for all the members of the organization who don't have Delegation User Credentials yet. It also ensures that on disabling Delegation Credential, the Delegation User Credentials are deleted which automatically deletes the SelectedCalendars through DB cascade.
116+
- `credentials` cron job creates Delegation User Credential records for all the members of the organization who don't have Delegation User Credentials yet. It also ensures that on disabling Delegation Credential, the Delegation User Credentials are deleted which automatically deletes the SelectedCalendar and CalendarCache records through DB cascade.
117117
- `selected-calendars` cron job creates SelectedCalendar records for all the Delegation User Credentials of the organization who don't have Selected Calendars yet.
118118

119119
### Important Points
@@ -134,7 +134,8 @@ Cron jobs ensure that for each and every member of the organization that has Del
134134

135135
### Impact of disabling Delegation Credential
136136

137-
Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their calendar connections broken.
137+
- It immediately stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their calendar connections broken.
138+
- Credentials cron job would delete the Delegation User Credentials which will then cascade to delete the SelectedCalendar and CalendarCache records.
138139

139140
### Impact of enabling Delegation Credential
140141
- Existing calendar-cache records are re-used as we identify the relevant record by userId and key of CalendarCache record.

packages/features/ee/organizations/pages/settings/delegationCredential.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,41 @@ function DelegationCredentialList() {
458458
);
459459
}
460460

461+
function MembersThatWillBeAffectedOnDisablingDelegationCredential({
462+
delegationCredentialId,
463+
}: {
464+
delegationCredentialId: string;
465+
}) {
466+
const { t } = useLocale();
467+
const { data: affectedMembers, isLoading: isLoadingAffectedMembers } =
468+
trpc.viewer.delegationCredential.getAffectedMembersForDisable.useQuery({ id: delegationCredentialId });
469+
470+
return (
471+
<div className="mt-4">
472+
<strong>{t("members_affected_by_disabling_delegation_credential")}</strong>
473+
{isLoadingAffectedMembers ? (
474+
<div>{t("loading")}</div>
475+
) : affectedMembers?.length ? (
476+
<>
477+
<ul className="list-disc space-y-1 p-1 pl-5 sm:w-80">
478+
{affectedMembers.slice(0, 5).map((m) => (
479+
<li className="text-muted text-sm" key={m.email}>
480+
{m.name ? `${m.name} (${m.email})` : m.email}
481+
</li>
482+
))}
483+
</ul>
484+
{affectedMembers.length > 5 && (
485+
<p className="mt-2 text-sm text-gray-500">
486+
{t("and_count_more", { count: affectedMembers.length - 5 })}
487+
</p>
488+
)}
489+
</>
490+
) : (
491+
<div className="mt-2">{t("no_members_affected_by_disabling_delegation_credential")}</div>
492+
)}
493+
</div>
494+
);
495+
}
461496
const ToggleDelegationDialog = ({
462497
delegation,
463498
onConfirm,
@@ -468,27 +503,34 @@ const ToggleDelegationDialog = ({
468503
onClose: () => void;
469504
}) => {
470505
const { t } = useLocale();
506+
471507
if (!delegation) {
472508
return null;
473509
}
510+
511+
const isDisablingDelegation = delegation.enabled;
512+
474513
return (
475514
<Dialog
476515
name="toggle-delegation"
477516
open={!!delegation}
478517
onOpenChange={(open) => (!open ? onClose() : undefined)}>
479518
<ConfirmationDialogContent
480-
title={t(delegation.enabled ? "disable_delegation_credential" : "enable_delegation_credential")}
481-
confirmBtnText={t(delegation.enabled ? "disable" : "enable")}
519+
title={t(isDisablingDelegation ? "disable_delegation_credential" : "enable_delegation_credential")}
520+
confirmBtnText={t(isDisablingDelegation ? "disable" : "enable")}
482521
cancelBtnText={t("cancel")}
483-
variety={delegation.enabled ? "danger" : "success"}
522+
variety={isDisablingDelegation ? "danger" : "success"}
484523
onConfirm={onConfirm}>
485524
<p className="mt-5">
486525
{t(
487-
delegation.enabled
526+
isDisablingDelegation
488527
? "disable_delegation_credential_description"
489528
: "enable_delegation_credential_description"
490529
)}
491530
</p>
531+
{isDisablingDelegation && (
532+
<MembersThatWillBeAffectedOnDisablingDelegationCredential delegationCredentialId={delegation.id} />
533+
)}
492534
</ConfirmationDialogContent>
493535
</Dialog>
494536
);

packages/lib/server/repository/__tests__/delegationCredential.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ describe("DelegationCredentialRepository", () => {
234234
domain: data.domain,
235235
enabled: data.enabled,
236236
createdAt: expect.any(Date),
237+
lastEnabledAt: null,
238+
lastDisabledAt: null,
237239
updatedAt: null,
238240
organizationId: data.organizationId,
239241
workspacePlatform: {
@@ -267,6 +269,8 @@ describe("DelegationCredentialRepository", () => {
267269
domain: created.domain,
268270
enabled: created.enabled,
269271
createdAt: expect.any(Date),
272+
lastEnabledAt: created.lastEnabledAt,
273+
lastDisabledAt: created.lastDisabledAt,
270274
updatedAt: null,
271275
organizationId: created.organizationId,
272276
workspacePlatform: {
@@ -295,6 +299,8 @@ describe("DelegationCredentialRepository", () => {
295299

296300
expect(result).toEqual({
297301
id: created.id,
302+
lastEnabledAt: created.lastEnabledAt,
303+
lastDisabledAt: created.lastDisabledAt,
298304
domain: created.domain,
299305
enabled: created.enabled,
300306
createdAt: expect.any(Date),

packages/lib/server/repository/delegationCredential.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const delegationCredentialSafeSelect = {
2121
createdAt: true,
2222
updatedAt: true,
2323
organizationId: true,
24+
lastEnabledAt: true,
25+
lastDisabledAt: true,
2426
workspacePlatform: {
2527
select: {
2628
name: true,
@@ -173,6 +175,8 @@ export class DelegationCredentialRepository {
173175
domain: string;
174176
enabled: boolean;
175177
organizationId: number;
178+
lastEnabledAt: Date;
179+
lastDisabledAt: Date;
176180
}>;
177181
}) {
178182
const { workspacePlatformId, organizationId, ...rest } = data;

packages/lib/server/repository/membership.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,32 @@ export class MembershipRepository {
337337

338338
return memberships.map((membership) => membership.teamId);
339339
}
340+
341+
/**
342+
* Returns members who joined after the given time
343+
*/
344+
static async findMembershipsCreatedAfterTimeIncludeUser({
345+
organizationId,
346+
time,
347+
}: {
348+
organizationId: number;
349+
time: Date;
350+
}) {
351+
return prisma.membership.findMany({
352+
where: {
353+
teamId: organizationId,
354+
createdAt: { gt: time },
355+
accepted: true,
356+
},
357+
include: {
358+
user: {
359+
select: {
360+
email: true,
361+
name: true,
362+
id: true,
363+
},
364+
},
365+
},
366+
});
367+
}
340368
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "DelegationCredential" ADD COLUMN "lastDisabledAt" TIMESTAMP(3),
3+
ADD COLUMN "lastEnabledAt" TIMESTAMP(3);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Membership" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;

0 commit comments

Comments
 (0)