Files
calendar/apps/web/app/api/customer-card/route.ts
T
Joe Au-YeungandGitHub 6bbf263981 feat: Optout of SMS workflows (#20769)
* Do not call dub if API key isn't present

* Add `WorkflowOptOutContact` table

* Add `sendTo` column to `WorkflowReminder`

* Add `workflowOptOutContactRepository`

* Add `WorkflowReminderRepository`

* Write `sendTo` number when scheduling SMS messages

* Add `p-limit` package

* Add `determineOptOutType` to `TwilioProvider`

* Add `deleteMultipleScheduledSMS`

* Create `WorkflowOptOutService`

* Add endpoint handler for Twilio SMS responses

* Add `isOptedOut` method to `workflowOptOutContactRepository`

* Verify phone number is not opted out before scheduling SMS

* Use `smsReminderNumber` instead of `sendTo`

* Type fix

* Add .env variable if opt out is available

* Add opt out message to SMS

* Import `pLimit` directly in method

* Address select comment

* Guard against undefined phone number from form

* Add early return to `deleteWorkflowReminders`

* Add request validation from Twilio

* Add fallback message as i18N string

* Only delete SMS attendee scheduled reminders
2025-04-22 22:00:19 +01:00

545 lines
12 KiB
TypeScript

import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { createHmac } from "crypto";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
const customerCardDisplay = (
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
): Card => {
return {
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
text: "Basic Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: name || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: email || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Username",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: username || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "User ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: id || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Account Settings Section Title
{
componentText: {
text: "Account Settings",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: identityProvider || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Last Active At",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Team Information Section Title
{
componentText: {
text: "Team Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamName || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Slug",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamSlug || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Is Organization",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: isOrganization ? "Yes" : "No",
badgeColor: isOrganization ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
{
componentText: {
text: "Security & Billing",
textColor: "NORMAL",
textSize: "M",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Stripe Customer ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: stripeCustomerId || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
],
};
};
const Card = z.object({
key: z.string(),
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
components: z.array(CardComponent).nullable(),
});
type Card = z.infer<typeof Card>;
const cardExamples: ((
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
) => Card)[] = [customerCardDisplay];
const inputSchema = z.object({
customer: z.object({
name: z.string().optional(),
email: z.string().email(),
username: z.string().optional(),
timeZone: timeZoneSchema.optional(),
emailVerified: z.boolean().optional(),
identityProvider: z.string().optional(),
twoFactorEnabled: z.boolean().optional(),
lastActiveAt: z.string().optional(),
teamName: z.string().optional(),
teamSlug: z.string().optional(),
isOrganization: z.boolean().optional(),
stripeCustomerId: z.string().optional(),
}),
cardKeys: z.array(z.string()),
});
async function handler(request: NextRequest) {
const headersList = await headers();
const requestBody = await request.json();
// HMAC verification
const incomingSignature = headersList.get("plain-request-signature");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
.update(JSON.stringify(requestBody))
.digest("hex");
if (incomingSignature !== expectedSignature) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Validate request body
const { cardKeys, customer } = inputSchema.parse(requestBody);
const user = await UserRepository.findByEmail({ email: customer.email });
if (!user) {
return NextResponse.json({
cards: [
{
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
textSize: "L",
textColor: "MUTED",
text: "User does not exist!",
},
},
],
},
],
});
}
// Fetch team details including userId and team name
const teamMemberships = await UserRepository.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
const parsedMetadata = userMetadata.parse(user.metadata);
const cards = await Promise.all(
cardExamples.map(async (cardFn) => {
return cardFn(
user.name || "Unknown",
user.email,
user.id.toString(),
user.username || "Unknown",
user.timeZone,
user.emailVerified,
user.twoFactorEnabled,
user.identityProvider,
user.lastActiveAt,
firstTeam?.name || "Unknown",
firstTeam?.slug || "Unknown",
firstTeam?.isOrganization || false,
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
);
})
);
const filteredCards = cards.filter((card) => {
return cardKeys.length === 0 || cardKeys.includes(card.key);
});
return NextResponse.json({
cards: filteredCards,
user: {
...user,
metadata: parsedMetadata,
},
});
}
export const POST = defaultResponderForAppDir(handler);