feat: Sink url shortner for sms workflow reminders (#26608)

* feat: Sink url shortner for sms workflow reminders

* fix: remove hardcoded dub values

* update .env.example

* fix: unit tests

* chore: add tests for scheduleSmsReminder and utils

* review refactor

* fix: type check

* review refactor

* fix: update test to account for smsReminderNumber fallback from main

Co-Authored-By: unknown <>

* feat: add feature flag for sink and more tests to verify

* fix: type check

* use proper feature flags for sink

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Amit Sharma
2026-03-16 20:51:28 +00:00
committed by GitHub
co-authored by unknown <> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Keith Williams
parent 12446d826b
commit 353f71bdc6
21 changed files with 2236 additions and 98 deletions
+9
View File
@@ -109,6 +109,15 @@ NEXT_PUBLIC_POSTHOG_HOST=
# Dub Config
DUB_API_KEY=
NEXT_PUBLIC_DUB_PROGRAM_ID=
# Optional: Domain for SMS workflow shortened links
DUB_SMS_DOMAIN=
# Optional: Folder ID to organize SMS workflow shortened links in Dub
DUB_SMS_FOLDER_ID=
# Sink URL Shortener Config
SINK_API_URL=
# API key for Sink authentication
SINK_API_KEY=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -38,6 +38,7 @@ const initialData: AppFlags = {
"active-user-billing": false,
"sidebar-tips": false,
"signup-watchlist-review": false,
"sink-shortener": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
@@ -1,88 +1,693 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handler } from "./scheduleSMSReminders";
const mockShortenMany = vi.fn();
vi.mock("@calcom/features/url-shortener/UrlShortenerFactory", () => ({
UrlShortenerFactory: {
create: async () => ({
shortenMany: (...args: unknown[]) => mockShortenMany(...args),
}),
},
}));
const mockScheduleSmsOrFallbackEmail = vi.fn();
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher", () => ({
scheduleSmsOrFallbackEmail: (...args: unknown[]) => mockScheduleSmsOrFallbackEmail(...args),
}));
const mockHasAvailableCredits = vi.fn().mockResolvedValue(true);
vi.mock("@calcom/features/ee/billing/credit-service", () => {
return {
CreditService: class MockCreditService {
hasAvailableCredits = vi.fn().mockResolvedValue(true);
CreditService: class {
hasAvailableCredits = mockHasAvailableCredits;
},
};
});
const mockGetByReferenceUid = vi.fn();
vi.mock("@calcom/features/bookings/repositories/BookingSeatRepository", () => {
return {
BookingSeatRepository: class MockBookingSeatRepository {
getByReferenceUidWithAttendeeDetails = vi.fn().mockResolvedValue(null);
BookingSeatRepository: class {
getByReferenceUidWithAttendeeDetails = (...args: unknown[]) => mockGetByReferenceUid(...args);
},
};
});
vi.mock("@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport", () => ({
getSenderId: vi.fn().mockReturnValue("CalCom"),
}));
vi.mock("@calcom/features/ee/workflows/lib/service/workflowOptOutService", () => ({
WorkflowOptOutService: {
addOptOutMessage: vi.fn().mockResolvedValue("message without opt out"),
},
}));
vi.mock("@calcom/features/ee/workflows/lib/reminders/templates/smsReminderTemplate", () => ({
default: vi.fn().mockReturnValue("Test SMS reminder"),
}));
vi.mock("@calcom/features/ee/workflows/lib/reminders/templates/customTemplate", () => ({
default: vi.fn().mockReturnValue({ text: "Custom message" }),
vi.mock("@calcom/features/ee/organizations/lib/getBookerUrlServer", () => ({
getBookerBaseUrl: vi.fn().mockResolvedValue("https://app.cal.com"),
}));
vi.mock("@calcom/features/bookings/lib/getCalEventResponses", () => ({
getCalEventResponses: vi.fn().mockReturnValue({ responses: {} }),
}));
vi.mock("@calcom/features/ee/organizations/lib/getBookerUrlServer", () => ({
getBookerBaseUrl: vi.fn().mockResolvedValue("https://app.cal.com"),
}));
vi.mock("@calcom/ee/workflows/lib/reminders/utils", () => ({
bulkShortenLinks: vi.fn().mockResolvedValue([
{ shortLink: "https://short.link/meet" },
{ shortLink: "https://short.link/cancel" },
{ shortLink: "https://short.link/reschedule" },
]),
}));
vi.mock("@calcom/i18n/server", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
getTranslation: vi.fn().mockResolvedValue(((key: string) => key) as unknown),
}));
vi.mock("@calcom/lib/constants", () => ({
DUB_SMS_DOMAIN: "sms.example.com",
DUB_SMS_FOLDER_ID: "folder-123",
}));
vi.mock("../lib/alphanumericSenderIdSupport", () => ({
getSenderId: vi.fn().mockReturnValue("CalCom"),
}));
vi.mock("../lib/getWorkflowReminders", () => ({
select: { id: true },
getWorkflowRecipientEmail: vi.fn().mockReturnValue(null),
}));
const mockCustomTemplate = vi.fn().mockReturnValue({ text: "Custom SMS message", html: "" });
vi.mock("../lib/reminders/templates/customTemplate", () => ({
default: (...args: unknown[]) => mockCustomTemplate(...args),
}));
const mockSmsReminderTemplate = vi.fn().mockReturnValue("Reminder: Your event is coming up");
vi.mock("../lib/reminders/templates/smsReminderTemplate", () => ({
default: (...args: unknown[]) => mockSmsReminderTemplate(...args),
}));
const mockAddOptOutMessage = vi.fn().mockResolvedValue("message with opt-out footer");
vi.mock("../lib/service/workflowOptOutService", () => ({
WorkflowOptOutService: {
addOptOutMessage: (...args: unknown[]) => mockAddOptOutMessage(...args),
},
}));
vi.mock("@calcom/features/ee/workflows/lib/actionHelperFunctions", () => ({
isAttendeeAction: vi.fn().mockReturnValue(true),
isAttendeeAction: vi.fn(
(action: string) =>
action === "SMS_ATTENDEE" || action === "EMAIL_ATTENDEE" || action === "WHATSAPP_ATTENDEE"
),
}));
vi.mock("@calcom/features/ee/workflows/lib/getWorkflowReminders", () => ({
select: {
id: true,
scheduledDate: true,
workflowStep: true,
booking: true,
seatReferenceId: true,
isMandatoryReminder: true,
uuid: true,
vi.mock("@calcom/prisma/zod-utils", () => ({
bookingMetadataSchema: {
parse: vi.fn((metadata: Record<string, unknown>) => metadata || {}),
},
getWorkflowRecipientEmail: vi.fn().mockReturnValue("attendee@example.com"),
}));
function createMockNextRequest(): { headers: { get: (key: string) => string | null }; nextUrl: { searchParams: { get: (key: string) => string | null } } } {
vi.mock("@calcom/lib/timeFormat", () => ({
getTimeFormatStringFromUserTimeFormat: vi.fn().mockReturnValue("h:mma"),
}));
import { handler } from "./scheduleSMSReminders";
const MOCK_CRON_API_KEY = "test-cron-api-key-123";
function createMockRequest({
apiKeyParam = MOCK_CRON_API_KEY,
authorizationHeader,
skipAuth = false,
}: {
apiKeyParam?: string;
authorizationHeader?: string;
skipAuth?: boolean;
} = {}) {
const url = new URL("https://app.cal.com/api/workflows/scheduleSMSReminders");
if (!skipAuth && !authorizationHeader && apiKeyParam) {
url.searchParams.set("apiKey", apiKeyParam);
}
const headers = new Headers();
if (authorizationHeader) {
headers.set("authorization", authorizationHeader);
}
return {
headers: {
get: (name: string) => headers.get(name),
},
nextUrl: url,
};
}
function buildMockReminder(overrides: Record<string, unknown> = {}) {
return {
id: 1,
scheduledDate: new Date("2025-06-15T10:00:00Z"),
isMandatoryReminder: false,
uuid: "uuid-123",
seatReferenceId: null,
retryCount: 0,
workflowStep: {
action: WorkflowActions.SMS_ATTENDEE,
sendTo: "+15559876543",
reminderBody: "Hello {ATTENDEE_NAME}, your event {EVENT_NAME} is coming up!",
emailSubject: null,
template: WorkflowTemplates.CUSTOM,
sender: "CalCom",
includeCalendarEvent: false,
id: 10,
workflow: {
userId: 1,
teamId: null,
},
},
booking: {
startTime: new Date("2025-06-15T10:00:00Z"),
endTime: new Date("2025-06-15T11:00:00Z"),
location: "https://meet.google.com/test",
description: "Test meeting",
smsReminderNumber: "+15551234567",
userPrimaryEmail: "organizer@example.com",
metadata: {},
uid: "booking-uid-123",
customInputs: {},
responses: {},
title: "Test Event",
attendees: [
{
name: "Test Attendee",
email: "attendee@example.com",
phoneNumber: "+15551234567",
timeZone: "America/New_York",
locale: "en",
},
],
user: {
id: 1,
email: "organizer@example.com",
name: "Test Organizer",
timeZone: "Europe/London",
locale: "en",
username: "organizer",
timeFormat: 12,
hideBranding: false,
},
eventType: {
bookingFields: null,
title: "Test Event",
slug: "test-event",
hosts: [],
recurringEvent: null,
team: { parentId: null, hideBranding: false },
customReplyToEmail: null,
},
},
...overrides,
};
}
describe("scheduleSMSReminders handler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.stubEnv("CRON_API_KEY", MOCK_CRON_API_KEY);
mockShortenMany.mockResolvedValue([
{ shortLink: "https://short.link/meet" },
{ shortLink: "https://short.link/cancel" },
{ shortLink: "https://short.link/reschedule" },
]);
mockCustomTemplate.mockReturnValue({ text: "Custom SMS message", html: "" });
mockSmsReminderTemplate.mockReturnValue("Reminder: Your event is coming up");
mockAddOptOutMessage.mockResolvedValue("message with opt-out footer");
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123456", emailReminderId: null });
prismaMock.profile.findFirst.mockResolvedValue(null);
});
describe("authentication", () => {
it("returns 401 when no apiKey is provided", async () => {
const req = createMockRequest({ skipAuth: true });
const response = await handler(req as any);
const body = await response.json();
expect(response.status).toBe(401);
expect(body.message).toBe("Not authenticated");
});
it("returns 401 when apiKey does not match CRON_API_KEY", async () => {
const req = createMockRequest({ apiKeyParam: "wrong-key" });
const response = await handler(req as any);
expect(response.status).toBe(401);
});
it("authenticates via authorization header", async () => {
const req = createMockRequest({ skipAuth: true, authorizationHeader: MOCK_CRON_API_KEY });
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
const response = await handler(req as any);
expect(response.status).toBe(200);
});
it("authenticates via query parameter", async () => {
const req = createMockRequest({ apiKeyParam: MOCK_CRON_API_KEY });
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
const response = await handler(req as any);
expect(response.status).toBe(200);
});
});
describe("no reminders found", () => {
it("returns ok when no unscheduled reminders exist", async () => {
const req = createMockRequest();
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
const response = await handler(req as any);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
});
it("queries with correct filters", async () => {
const req = createMockRequest();
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
await handler(req as any);
const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0];
expect(callArgs.where.method).toBe(WorkflowMethods.SMS);
expect(callArgs.where.scheduled).toBe(false);
expect(callArgs.where.scheduledDate).toEqual(
expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(String),
})
);
expect(callArgs.where.retryCount).toEqual({ lt: 3 });
});
});
describe("skipping invalid reminders", () => {
it("skips when workflowStep is null", async () => {
const req = createMockRequest();
const invalidReminder = buildMockReminder({ workflowStep: null });
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any]);
const response = await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
expect(response.status).toBe(200);
});
it("skips when booking is null", async () => {
const req = createMockRequest();
const invalidReminder = buildMockReminder({ booking: null });
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any]);
const response = await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
expect(response.status).toBe(200);
});
it("continues processing valid reminders after skipping invalid ones", async () => {
const req = createMockRequest();
const invalidReminder = buildMockReminder({ id: 1, workflowStep: null });
const validReminder = buildMockReminder({ id: 2 });
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any, validReminder as any]);
await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
});
});
describe("seated events", () => {
it("looks up seat attendee when seatReferenceId is present", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockGetByReferenceUid.mockResolvedValue({
attendee: {
name: "Seat Attendee",
email: "seat@example.com",
phoneNumber: "+15559999999",
timeZone: "US/Pacific",
locale: "en",
},
});
await handler(req as any);
expect(mockGetByReferenceUid).toHaveBeenCalledWith("seat-ref-123");
});
it("uses seat attendee data when found", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockGetByReferenceUid.mockResolvedValue({
attendee: {
name: "Seat Attendee",
email: "seat@example.com",
phoneNumber: "+15559999999",
timeZone: "US/Pacific",
locale: "en",
},
});
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.twilioData.phoneNumber).toBe("+15559999999");
});
it("falls back to booking attendee when seat lookup returns null", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockGetByReferenceUid.mockResolvedValue(null);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.twilioData.phoneNumber).toBe("+15551234567");
});
it("does not query seat repository when seatReferenceId is null", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ seatReferenceId: null });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(mockGetByReferenceUid).not.toHaveBeenCalled();
});
});
describe("action-based field selection", () => {
it("uses workflowStep.sendTo for SMS_NUMBER action", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({
workflowStep: {
...buildMockReminder().workflowStep,
action: WorkflowActions.SMS_NUMBER,
sendTo: "+15559876543",
},
});
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.twilioData.phoneNumber).toBe("+15559876543");
});
it("uses targetAttendee.phoneNumber for SMS_ATTENDEE action", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.twilioData.phoneNumber).toBe("+15551234567");
});
});
describe("custom reminderBody processing", () => {
it("shortens meetingUrl, cancelLink, and rescheduleLink", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const [urls] = mockShortenMany.mock.calls[0];
expect(urls).toHaveLength(3);
expect(urls[1]).toContain("/booking/booking-uid-123?cancel=true");
expect(urls[2]).toContain("/reschedule/booking-uid-123");
});
it("passes shortened URLs to customTemplate variables", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.meetingUrl).toBe("https://short.link/meet");
expect(variables.cancelLink).toBe("https://short.link/cancel");
expect(variables.rescheduleLink).toBe("https://short.link/reschedule");
});
it("fetches organizer profile for booker URL", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(prismaMock.profile.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 1 },
})
);
});
it("falls back to 'en' locale when computed locale is null", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
(reminder.booking as any).attendees[0].locale = null;
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const templateLocale = mockCustomTemplate.mock.calls[0][2];
expect(templateLocale).toBe("en");
});
});
describe("REMINDER template processing", () => {
it("uses smsReminderTemplate when no reminderBody and template is REMINDER", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({
workflowStep: {
...buildMockReminder().workflowStep,
reminderBody: null,
template: WorkflowTemplates.REMINDER,
},
});
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(mockSmsReminderTemplate).toHaveBeenCalled();
expect(mockCustomTemplate).not.toHaveBeenCalled();
});
it("does not send SMS when neither reminderBody nor REMINDER template", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({
workflowStep: {
...buildMockReminder().workflowStep,
reminderBody: null,
template: WorkflowTemplates.CUSTOM,
},
});
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
});
});
describe("SMS sending and DB updates", () => {
it("calls addOptOutMessage before sending", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(mockAddOptOutMessage).toHaveBeenCalled();
});
it("calls scheduleSmsOrFallbackEmail with correct twilioData", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.twilioData).toMatchObject({
phoneNumber: "+15551234567",
scheduledDate: reminder.scheduledDate,
sender: "CalCom",
bookingUid: "booking-uid-123",
userId: 1,
teamId: null,
});
expect(callArgs.twilioData.body).toBeDefined();
expect(callArgs.twilioData.bodyWithoutOptOut).toBeDefined();
});
it("includes fallbackData for attendee actions", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.fallbackData).toBeDefined();
expect(callArgs.fallbackData.email).toBe("attendee@example.com");
expect(callArgs.fallbackData.replyTo).toBe("organizer@example.com");
expect(callArgs.fallbackData.workflowStepId).toBe(10);
});
it("does not include fallbackData for non-attendee actions", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({
workflowStep: {
...buildMockReminder().workflowStep,
action: WorkflowActions.SMS_NUMBER,
},
});
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
expect(callArgs.fallbackData).toBeUndefined();
});
it("updates reminder to scheduled=true with SID on success", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123456", emailReminderId: null });
await handler(req as any);
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { scheduled: true, referenceId: "SM123456" },
});
});
it("deletes SMS reminder when email fallback was used", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: null, emailReminderId: 456 });
await handler(req as any);
expect(prismaMock.workflowReminder.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
it("increments retryCount when scheduleSmsOrFallbackEmail returns null", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ retryCount: 1 });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockScheduleSmsOrFallbackEmail.mockResolvedValue(null);
await handler(req as any);
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { retryCount: 2 },
});
});
it("does not send when sendTo is undefined", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
(reminder.booking as any).attendees[0].phoneNumber = undefined;
(reminder.booking as any).smsReminderNumber = null;
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
});
});
describe("error handling", () => {
it("increments retryCount on caught exception", async () => {
const req = createMockRequest();
const reminder = buildMockReminder({ retryCount: 0 });
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
mockScheduleSmsOrFallbackEmail.mockRejectedValue(new Error("Twilio error"));
await handler(req as any);
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { retryCount: 1 },
});
});
it("continues processing after one reminder fails", async () => {
const req = createMockRequest();
const failingReminder = buildMockReminder({ id: 1, retryCount: 0 });
const successReminder = buildMockReminder({ id: 2, retryCount: 0 });
prismaMock.workflowReminder.findMany.mockResolvedValue([
failingReminder as any,
successReminder as any,
]);
mockScheduleSmsOrFallbackEmail
.mockRejectedValueOnce(new Error("Twilio error"))
.mockResolvedValueOnce({ sid: "SM789", emailReminderId: null });
const response = await handler(req as any);
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { retryCount: 1 },
});
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
where: { id: 2 },
data: { scheduled: true, referenceId: "SM789" },
});
expect(response.status).toBe(200);
});
it("returns 200 with message after processing all reminders", async () => {
const req = createMockRequest();
const reminder = buildMockReminder();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
const response = await handler(req as any);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.message).toBe("SMS scheduled");
});
});
describe("multiple reminders", () => {
it("processes each reminder independently", async () => {
const req = createMockRequest();
const reminder1 = buildMockReminder({ id: 1 });
const reminder2 = buildMockReminder({
id: 2,
workflowStep: {
...buildMockReminder().workflowStep,
action: WorkflowActions.SMS_NUMBER,
sendTo: "+15559876543",
reminderBody: null,
template: WorkflowTemplates.REMINDER,
},
});
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder1 as any, reminder2 as any]);
await handler(req as any);
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(2);
});
});
});
function createMockNextRequest(): {
headers: { get: (key: string) => string | null };
nextUrl: { searchParams: { get: (key: string) => string | null } };
} {
return {
headers: {
get: (key: string) => (key === "authorization" ? "test-api-key" : null),
@@ -95,7 +700,7 @@ function createMockNextRequest(): { headers: { get: (key: string) => string | nu
};
}
function createMockReminder(overrides: Record<string, unknown> = {}): Record<string, unknown> {
function createMockReminderForFallback(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const now = new Date();
const scheduledDate = new Date(now.getTime() + 60 * 60 * 1000);
@@ -168,14 +773,17 @@ function createMockReminder(overrides: Record<string, unknown> = {}): Record<str
describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_API_KEY = "test-api-key";
vi.unstubAllEnvs();
vi.stubEnv("CRON_API_KEY", "test-api-key");
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123", emailReminderId: null });
mockSmsReminderTemplate.mockReturnValue("Test SMS reminder");
mockAddOptOutMessage.mockResolvedValue("message without opt out");
prismaMock.workflowReminder.update.mockResolvedValue({} as never);
prismaMock.profile.findFirst.mockResolvedValue(null);
});
it("should use attendee phone when booking has no smsReminderNumber", async () => {
const reminder = createMockReminder();
const reminder = createMockReminderForFallback();
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
const req = createMockNextRequest();
@@ -192,8 +800,8 @@ describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
});
it("should use smsReminderNumber when booking has it set", async () => {
const base = createMockReminder();
const reminder = createMockReminder({
const base = createMockReminderForFallback();
const reminder = createMockReminderForFallback({
booking: {
...(base.booking as Record<string, unknown>),
smsReminderNumber: "+2222222222",
@@ -215,9 +823,9 @@ describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
});
it("should prefer smsReminderNumber over attendee phone for non-seated events", async () => {
const baseReminder = createMockReminder();
const baseReminder = createMockReminderForFallback();
const booking = baseReminder.booking as Record<string, unknown>;
const reminder = createMockReminder({
const reminder = createMockReminderForFallback({
booking: {
...booking,
smsReminderNumber: "+3333333333",
@@ -247,8 +855,8 @@ describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
});
it("should use workflowStep.sendTo when action is SMS_NUMBER", async () => {
const base = createMockReminder();
const reminder = createMockReminder({
const base = createMockReminderForFallback();
const reminder = createMockReminderForFallback({
workflowStep: {
...(base.workflowStep as Record<string, unknown>),
action: WorkflowActions.SMS_NUMBER,
@@ -274,9 +882,9 @@ describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
});
it("should use smsReminderNumber when attendee has no phone number", async () => {
const baseReminder = createMockReminder();
const baseReminder = createMockReminderForFallback();
const booking = baseReminder.booking as Record<string, unknown>;
const reminder = createMockReminder({
const reminder = createMockReminderForFallback({
booking: {
...booking,
smsReminderNumber: "+5555555555",
@@ -1,24 +1,24 @@
/* Schedule any workflow reminder that falls within the next 2 hours for SMS */
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { isAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { scheduleSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { UrlShortenerFactory } from "@calcom/features/url-shortener/UrlShortenerFactory";
import { DUB_SMS_DOMAIN, DUB_SMS_FOLDER_ID } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/i18n/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getSenderId } from "../lib/alphanumericSenderIdSupport";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select, getWorkflowRecipientEmail } from "../lib/getWorkflowReminders";
import { getWorkflowRecipientEmail, select } from "../lib/getWorkflowReminders";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
@@ -141,8 +141,12 @@ export async function handler(req: NextRequest) {
}`,
};
const shortener = await UrlShortenerFactory.create({ userId, teamId });
const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] =
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
await shortener.shortenMany([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink], {
domain: DUB_SMS_DOMAIN,
folderId: DUB_SMS_FOLDER_ID,
});
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
@@ -195,7 +195,7 @@ const scheduleSMSReminderForEvt = async (
}
if (smsMessage) {
smsMessage = await getSMSMessageWithVariables(smsMessage, evt, attendeeToBeUsedInSMS, action);
smsMessage = await getSMSMessageWithVariables(smsMessage, evt, attendeeToBeUsedInSMS, action, userId, teamId);
} else if (template === WorkflowTemplates.REMINDER) {
smsMessage =
smsReminderTemplate(
@@ -0,0 +1,533 @@
import dayjs from "@calcom/dayjs";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AttendeeInBookingInfo, BookingInfo } from "../types";
const mockShortenMany = vi.fn();
vi.mock("@calcom/features/url-shortener/UrlShortenerFactory", () => ({
UrlShortenerFactory: {
create: async () => ({
shortenMany: (...args: unknown[]) => mockShortenMany(...args),
}),
},
}));
vi.mock("@calcom/lib/constants", () => ({
DUB_SMS_DOMAIN: "sms.example.com",
DUB_SMS_FOLDER_ID: "folder-123",
WEBSITE_URL: "https://app.cal.com",
}));
const mockCustomTemplate = vi.fn().mockReturnValue({ text: "Final SMS text", html: "<p>Final SMS text</p>" });
const mockTransformResponses = vi.fn((responses) => responses);
vi.mock("./templates/customTemplate", () => ({
default: (...args: unknown[]) => mockCustomTemplate(...args),
transformBookingResponsesToVariableFormat: (...args: unknown[]) => mockTransformResponses(...args),
}));
const mockGetWorkflowRecipientEmail = vi.fn();
vi.mock("../../getWorkflowReminders", () => ({
getWorkflowRecipientEmail: (...args: unknown[]) => mockGetWorkflowRecipientEmail(...args),
}));
import { getAttendeeToBeUsedInSMS, getSMSMessageWithVariables, shouldUseTwilio } from "./utils";
function buildMockAttendee(overrides: Partial<AttendeeInBookingInfo> = {}): AttendeeInBookingInfo {
return {
name: "Test Attendee",
firstName: "Test",
lastName: "Attendee",
email: "attendee@example.com",
phoneNumber: "+15551234567",
timeZone: "America/New_York",
language: { locale: "en" },
...overrides,
};
}
function buildMockBookingInfo(overrides: Partial<BookingInfo> = {}): BookingInfo {
return {
uid: "booking-uid-123",
bookerUrl: "https://cal.example.com",
attendees: [buildMockAttendee()],
organizer: {
language: { locale: "en" },
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "Europe/London",
timeFormat: "HH:mm" as BookingInfo["organizer"]["timeFormat"],
username: "organizer",
},
eventType: {
slug: "test-event",
},
startTime: "2025-06-15T10:00:00Z",
endTime: "2025-06-15T11:00:00Z",
title: "Test Event",
location: "https://meet.google.com/test",
additionalNotes: "Some notes",
responses: null,
metadata: { videoCallUrl: "https://meet.google.com/abc" },
cancellationReason: null,
rescheduleReason: null,
...overrides,
};
}
describe("utils", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShortenMany.mockResolvedValue([
{ shortLink: "https://short.link/meet" },
{ shortLink: "https://short.link/cancel" },
{ shortLink: "https://short.link/reschedule" },
]);
mockCustomTemplate.mockReturnValue({ text: "Final SMS text", html: "<p>Final SMS text</p>" });
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
});
describe("getSMSMessageWithVariables", () => {
describe("URL construction", () => {
it("builds meetingUrl from metadata videoCallUrl", async () => {
const evt = buildMockBookingInfo({ metadata: { videoCallUrl: "https://meet.google.com/abc" } });
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[0]).toBe("https://meet.google.com/abc");
});
it("uses empty string for meetingUrl when no videoCallUrl in metadata", async () => {
const evt = buildMockBookingInfo({ metadata: {} });
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[0]).toBe("");
});
it("builds cancelLink with cancelledBy param for attendee actions", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toBe(
"https://cal.example.com/booking/booking-uid-123?cancel=true&cancelledBy=attendee@example.com"
);
});
it("builds cancelLink without cancelledBy when recipientEmail is null", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue(null);
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toBe("https://cal.example.com/booking/booking-uid-123?cancel=true");
});
it("builds rescheduleLink with rescheduledBy param", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[2]).toBe(
"https://cal.example.com/reschedule/booking-uid-123?rescheduledBy=attendee@example.com"
);
});
it("builds rescheduleLink without rescheduledBy when recipientEmail is null", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue(null);
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[2]).toBe("https://cal.example.com/reschedule/booking-uid-123");
});
it("falls back to WEBSITE_URL when bookerUrl is nullish", async () => {
const evt = buildMockBookingInfo({ bookerUrl: undefined as unknown as string });
const attendee = buildMockAttendee();
mockGetWorkflowRecipientEmail.mockReturnValue(null);
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toContain("https://app.cal.com/booking/");
expect(urls[2]).toContain("https://app.cal.com/reschedule/");
});
});
describe("URL shortening", () => {
it("calls shortenMany with all three URLs", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockShortenMany).toHaveBeenCalledTimes(1);
expect(mockShortenMany.mock.calls[0][0]).toHaveLength(3);
});
it("always passes domain and folderId options", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockShortenMany.mock.calls[0][1]).toEqual({
domain: "sms.example.com",
folderId: "folder-123",
});
});
it("uses shortened URLs in the variables passed to customTemplate", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.meetingUrl).toBe("https://short.link/meet");
expect(variables.cancelLink).toBe("https://short.link/cancel");
expect(variables.rescheduleLink).toBe("https://short.link/reschedule");
});
});
describe("timezone selection", () => {
it("uses attendee timezone for SMS_ATTENDEE action", async () => {
const attendee = buildMockAttendee({ timeZone: "America/New_York" });
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/London",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.timeZone).toBe("America/New_York");
});
it("uses organizer timezone for SMS_NUMBER action", async () => {
const attendee = buildMockAttendee({ timeZone: "America/New_York" });
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/London",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.timeZone).toBe("Europe/London");
});
});
describe("variable assembly", () => {
it("includes all expected variable fields", async () => {
const evt = buildMockBookingInfo({
cancellationReason: "Too busy",
rescheduleReason: "Conflict",
});
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables).toMatchObject({
eventName: "Test Event",
organizerName: "Test Organizer",
attendeeName: "Test Attendee",
attendeeFirstName: "Test",
attendeeLastName: "Attendee",
attendeeEmail: "attendee@example.com",
location: "https://meet.google.com/test",
additionalNotes: "Some notes",
meetingUrl: "https://short.link/meet",
cancelLink: "https://short.link/cancel",
rescheduleLink: "https://short.link/reschedule",
cancelReason: "Too busy",
rescheduleReason: "Conflict",
});
expect(variables.eventDate).toBeDefined();
expect(variables.eventEndTime).toBeDefined();
expect(variables.timeZone).toBeDefined();
expect(variables.attendeeTimezone).toBeDefined();
expect(variables.eventTimeInAttendeeTimezone).toBeDefined();
expect(variables.eventEndTimeInAttendeeTimezone).toBeDefined();
});
it("attendeeTimezone always uses evt.attendees[0].timeZone", async () => {
const evt = buildMockBookingInfo({
attendees: [buildMockAttendee({ timeZone: "Asia/Tokyo" })],
});
const differentAttendee = buildMockAttendee({ timeZone: "US/Pacific" });
await getSMSMessageWithVariables("Hello", evt, differentAttendee, WorkflowActions.SMS_NUMBER);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.attendeeTimezone).toBe("Asia/Tokyo");
});
});
describe("locale and template rendering", () => {
it("uses attendee locale for SMS_ATTENDEE action", async () => {
const attendee = buildMockAttendee();
attendee.language = { locale: "fr" };
const evt = buildMockBookingInfo();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockCustomTemplate.mock.calls[0][2]).toBe("fr");
});
it("uses organizer locale for non-attendee action", async () => {
const attendee = buildMockAttendee();
attendee.language = { locale: "fr" };
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "de" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/Berlin",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
expect(mockCustomTemplate.mock.calls[0][2]).toBe("de");
});
it("passes organizer timeFormat to customTemplate", async () => {
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "UTC",
timeFormat: "HH:mm" as BookingInfo["organizer"]["timeFormat"],
},
});
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
expect(mockCustomTemplate.mock.calls[0][3]).toBe("HH:mm");
});
it("returns the text property from customTemplate result", async () => {
mockCustomTemplate.mockReturnValue({ text: "Your booking is confirmed", html: "" });
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
const result = await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(result).toBe("Your booking is confirmed");
});
});
});
describe("getAttendeeToBeUsedInSMS", () => {
describe("SMS_ATTENDEE action", () => {
it("returns attendee matching responses email when reminderPhone is truthy", () => {
const matchingAttendee = buildMockAttendee({ email: "match@example.com", name: "Match" });
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee, matchingAttendee],
responses: { email: { value: "match@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("Match");
});
it("falls back to first attendee when no attendee matches email", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: { email: { value: "nomatch@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("First");
});
it("falls back to first attendee when reminderPhone is null", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const matchAttendee = buildMockAttendee({ email: "match@example.com", name: "Match" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee, matchAttendee],
responses: { email: { value: "match@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, null);
expect(result.name).toBe("First");
});
it("falls back to first attendee when reminderPhone is empty string", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: { email: { value: "first@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "");
expect(result.name).toBe("First");
});
it("falls back to first attendee when responses is null", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: null,
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("First");
});
});
describe("non-attendee actions", () => {
it("returns first attendee for SMS_NUMBER action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const secondAttendee = buildMockAttendee({ name: "Second" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee, secondAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_NUMBER, evt, "+15551234567");
expect(result.name).toBe("First");
});
it("returns first attendee for EMAIL_HOST action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.EMAIL_HOST, evt, null);
expect(result.name).toBe("First");
});
it("returns first attendee for WHATSAPP_NUMBER action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.WHATSAPP_NUMBER, evt, "+15551234567");
expect(result.name).toBe("First");
});
});
});
describe("shouldUseTwilio", () => {
describe("immediate trigger events", () => {
const immediateEvents = [
WorkflowTriggerEvents.NEW_EVENT,
WorkflowTriggerEvents.EVENT_CANCELLED,
WorkflowTriggerEvents.RESCHEDULE_EVENT,
WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED,
WorkflowTriggerEvents.BOOKING_PAID,
WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
WorkflowTriggerEvents.BOOKING_REJECTED,
WorkflowTriggerEvents.BOOKING_REQUESTED,
WorkflowTriggerEvents.FORM_SUBMITTED,
WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT,
];
immediateEvents.forEach((trigger) => {
it(`returns true for ${trigger}`, () => {
expect(shouldUseTwilio(trigger, null)).toBe(true);
});
});
it("returns true for immediate triggers regardless of scheduledDate", () => {
expect(shouldUseTwilio(WorkflowTriggerEvents.NEW_EVENT, dayjs().add(5, "hour"))).toBe(true);
});
});
describe("BEFORE_EVENT / AFTER_EVENT", () => {
it("returns true when scheduledDate is 30min from now", () => {
const scheduledDate = dayjs().add(30, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns true when scheduledDate is 1hr from now", () => {
const scheduledDate = dayjs().add(1, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns false when scheduledDate is null", () => {
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, null)).toBe(false);
});
it("returns false when scheduledDate is 10min from now (too close)", () => {
const scheduledDate = dayjs().add(10, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns false when scheduledDate is 3hr from now (beyond 2hr window)", () => {
const scheduledDate = dayjs().add(3, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns false at exactly 15min boundary", () => {
const scheduledDate = dayjs().add(15, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns true at 16min from now", () => {
const scheduledDate = dayjs().add(16, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns false at 2hr+1min from now", () => {
const scheduledDate = dayjs().add(2, "hour").add(1, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("works the same for AFTER_EVENT trigger", () => {
const withinWindow = dayjs().add(30, "minute");
const outsideWindow = dayjs().add(3, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.AFTER_EVENT, withinWindow)).toBe(true);
expect(shouldUseTwilio(WorkflowTriggerEvents.AFTER_EVENT, outsideWindow)).toBe(false);
});
});
describe("other triggers", () => {
it("returns false for AFTER_HOSTS_CAL_VIDEO_NO_SHOW", () => {
expect(
shouldUseTwilio(WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, dayjs().add(30, "minute"))
).toBe(false);
});
it("returns false for AFTER_GUESTS_CAL_VIDEO_NO_SHOW", () => {
expect(
shouldUseTwilio(WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, dayjs().add(30, "minute"))
).toBe(false);
});
});
});
});
@@ -1,46 +1,21 @@
import dayjs from "@calcom/dayjs";
import { dub } from "@calcom/features/auth/lib/dub";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { UrlShortenerFactory } from "@calcom/features/url-shortener/UrlShortenerFactory";
import { DUB_SMS_DOMAIN, DUB_SMS_FOLDER_ID, WEBSITE_URL } from "@calcom/lib/constants";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import type { AttendeeInBookingInfo, BookingInfo } from "../types";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate, { transformBookingResponsesToVariableFormat } from "./templates/customTemplate";
export const bulkShortenLinks = async (links: string[]) => {
if (!process.env.DUB_API_KEY) {
return links.map((link) => ({ shortLink: link }));
}
const linksToShorten = links.filter((link) => link);
const results = await dub.links.createMany(
linksToShorten.map((link) => ({
domain: "sms.cal.com",
url: link,
folderId: "fold_wx3NZDKQYbLDbncSubeMu0ss",
}))
);
return links.map((link) => {
const createdLink = results.find(
(result): result is Extract<typeof result, { url: string }> =>
!("error" in result) && result.url === link
);
if (createdLink) {
return { shortLink: createdLink.shortLink };
} else {
return { shortLink: link };
}
});
};
export const getSMSMessageWithVariables = async (
smsMessage: string,
evt: BookingInfo,
attendeeToBeUsedInSMS: AttendeeInBookingInfo,
action: WorkflowActions
action: WorkflowActions,
userId?: number | null,
teamId?: number | null
) => {
const recipientEmail = getWorkflowRecipientEmail({
action,
@@ -56,8 +31,12 @@ export const getSMSMessageWithVariables = async (
}`,
};
const shortener = await UrlShortenerFactory.create({ userId, teamId });
const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] =
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
await shortener.shortenMany([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink], {
domain: DUB_SMS_DOMAIN,
folderId: DUB_SMS_FOLDER_ID,
});
const timeZone =
action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE
+1
View File
@@ -39,6 +39,7 @@ export type AppFlags = {
"active-user-billing": boolean;
"sidebar-tips": boolean;
"signup-watchlist-review": boolean;
"sink-shortener": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
@@ -0,0 +1,12 @@
export interface ShortenResult {
shortLink: string;
}
export interface ShortenOptions {
domain?: string;
folderId?: string;
}
export interface IUrlShortenerProvider {
shortenMany(urls: string[], options?: ShortenOptions): Promise<ShortenResult[]>;
}
@@ -0,0 +1,43 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import prisma from "@calcom/prisma";
import type { IUrlShortenerProvider } from "./IUrlShortenerProvider";
import { DubShortener } from "./providers/DubShortener";
import { NoopShortener } from "./providers/NoopShortener";
import { SinkClient } from "./providers/SinkClient";
import { SinkShortener } from "./providers/SinkShortener";
export class UrlShortenerFactory {
static async create({
userId,
teamId,
}: { userId?: number | null; teamId?: number | null } = {}): Promise<IUrlShortenerProvider> {
if (SinkShortener.isConfigured()) {
const featuresRepository = new FeaturesRepository(prisma);
const globallyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("sink-shortener");
if (globallyEnabled) {
return new SinkShortener(new SinkClient());
}
if (userId) {
const useSink = await featuresRepository.checkIfUserHasFeature(userId, "sink-shortener");
if (useSink) {
return new SinkShortener(new SinkClient());
}
}
if (teamId) {
const useSink = await featuresRepository.checkIfTeamHasFeature(teamId, "sink-shortener");
if (useSink) {
return new SinkShortener(new SinkClient());
}
}
}
if (DubShortener.isConfigured()) {
return new DubShortener();
}
return new NoopShortener();
}
}
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
const mockCreateMany = vi.fn();
vi.mock("@calcom/features/auth/lib/dub", () => ({
dub: {
links: {
createMany: (...args: unknown[]) => mockCreateMany(...args),
},
},
}));
import { DubShortener } from "../providers/DubShortener";
describe("DubShortener", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.stubEnv("DUB_API_KEY", "");
});
describe("isConfigured", () => {
it("returns true when DUB_API_KEY is set", () => {
vi.stubEnv("DUB_API_KEY", "test-key");
expect(DubShortener.isConfigured()).toBe(true);
});
it("returns false when DUB_API_KEY is not set", () => {
expect(DubShortener.isConfigured()).toBe(false);
});
});
describe("shortenMany", () => {
it("returns shortened links", async () => {
mockCreateMany.mockResolvedValue([
{ url: "https://example.com", shortLink: "https://sms.cal.com/abc" },
]);
const shortener = new DubShortener();
const result = await shortener.shortenMany(["https://example.com"]);
expect(result).toEqual([{ shortLink: "https://sms.cal.com/abc" }]);
});
it("passes domain and folderId options", async () => {
mockCreateMany.mockResolvedValue([{ url: "https://example.com", shortLink: "https://custom.com/abc" }]);
const shortener = new DubShortener();
await shortener.shortenMany(["https://example.com"], {
domain: "custom.com",
folderId: "folder123",
});
expect(mockCreateMany).toHaveBeenCalledWith([
{
domain: "custom.com",
url: "https://example.com",
folderId: "folder123",
},
]);
});
it("handles multiple URLs", async () => {
mockCreateMany.mockResolvedValue([
{ url: "https://example1.com", shortLink: "https://sms.cal.com/abc" },
{ url: "https://example2.com", shortLink: "https://sms.cal.com/def" },
]);
const shortener = new DubShortener();
const result = await shortener.shortenMany(["https://example1.com", "https://example2.com"]);
expect(result).toEqual([
{ shortLink: "https://sms.cal.com/abc" },
{ shortLink: "https://sms.cal.com/def" },
]);
});
it("filters empty URLs before sending to Dub", async () => {
mockCreateMany.mockResolvedValue([
{ url: "https://example.com", shortLink: "https://sms.cal.com/abc" },
]);
const shortener = new DubShortener();
await shortener.shortenMany(["", "https://example.com", ""]);
expect(mockCreateMany).toHaveBeenCalledWith([
{
domain: undefined,
url: "https://example.com",
folderId: undefined,
},
]);
});
it("returns original URL when Dub returns error result", async () => {
mockCreateMany.mockResolvedValue([
{ url: "https://example1.com", shortLink: "https://sms.cal.com/abc" },
{ error: "rate_limit_exceeded" },
{ url: "https://example3.com", shortLink: "https://sms.cal.com/def" },
]);
const shortener = new DubShortener();
const result = await shortener.shortenMany([
"https://example1.com",
"https://example2.com",
"https://example3.com",
]);
expect(result).toEqual([
{ shortLink: "https://sms.cal.com/abc" },
{ shortLink: "https://example2.com" },
{ shortLink: "https://sms.cal.com/def" },
]);
});
it("returns original URLs on API error", async () => {
mockCreateMany.mockRejectedValue(new Error("Dub API error"));
const shortener = new DubShortener();
const result = await shortener.shortenMany(["https://example1.com", "https://example2.com"]);
expect(result).toEqual([{ shortLink: "https://example1.com" }, { shortLink: "https://example2.com" }]);
});
it("handles mixed empty and valid URLs correctly", async () => {
mockCreateMany.mockResolvedValue([
{ url: "https://example.com", shortLink: "https://sms.cal.com/abc" },
]);
const shortener = new DubShortener();
const result = await shortener.shortenMany(["", "https://example.com", ""]);
expect(result).toEqual([
{ shortLink: "" },
{ shortLink: "https://sms.cal.com/abc" },
{ shortLink: "" },
]);
});
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { NoopShortener } from "../providers/NoopShortener";
describe("NoopShortener", () => {
it("returns original URLs unchanged", async () => {
const shortener = new NoopShortener();
const result = await shortener.shortenMany(["https://example1.com", "https://example2.com"]);
expect(result).toEqual([{ shortLink: "https://example1.com" }, { shortLink: "https://example2.com" }]);
});
it("handles empty array", async () => {
const shortener = new NoopShortener();
const result = await shortener.shortenMany([]);
expect(result).toEqual([]);
});
it("handles empty strings", async () => {
const shortener = new NoopShortener();
const result = await shortener.shortenMany(["", "", ""]);
expect(result).toEqual([{ shortLink: "" }, { shortLink: "" }, { shortLink: "" }]);
});
it("ignores options parameter", async () => {
const shortener = new NoopShortener();
const result = await shortener.shortenMany(["https://example.com"], {
domain: "custom.com",
folderId: "folder123",
});
expect(result).toEqual([{ shortLink: "https://example.com" }]);
});
});
@@ -0,0 +1,299 @@
import type { Mock } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SinkClient } from "../providers/SinkClient";
const mockFetch = vi.fn() as Mock;
global.fetch = mockFetch;
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
describe("SinkClient", () => {
const mockSinkUrl = "https://sink.test.com";
const mockApiKey = "test-api-key";
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
describe("createLink", () => {
beforeEach(() => {
vi.stubEnv("SINK_API_URL", mockSinkUrl);
vi.stubEnv("SINK_API_KEY", mockApiKey);
});
it("should successfully create a shortened link", async () => {
const mockResponse = {
link: {
id: "abc123",
url: "https://example.com",
slug: "xyz",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: "https://sink.test.com/xyz",
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const testSink = new SinkClient();
const result = await testSink.createLink("https://example.com");
expect(result).toBe("https://sink.test.com/xyz");
expect(global.fetch).toHaveBeenCalledWith(
`${mockSinkUrl}/api/link/create`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: `Bearer ${mockApiKey}`,
}),
body: JSON.stringify({ url: "https://example.com" }),
})
);
});
it("should return null when Sink is not configured", async () => {
vi.stubEnv("SINK_API_URL", "");
const testSink = new SinkClient();
const result = await testSink.createLink("https://example.com");
expect(result).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
it("should return null on API error response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Internal Server Error",
});
const testSink = new SinkClient();
const result = await testSink.createLink("https://example.com");
expect(result).toBeNull();
});
it("should return null on network error", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const testSink = new SinkClient();
const result = await testSink.createLink("https://example.com");
expect(result).toBeNull();
});
it("should pass options to the API", async () => {
const mockResponse = {
link: {
id: "abc123",
url: "https://example.com",
slug: "custom-slug",
comment: "Test comment",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: "https://sink.test.com/custom-slug",
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const testSink = new SinkClient();
await testSink.createLink("https://example.com", {
slug: "custom-slug",
comment: "Test comment",
});
expect(global.fetch).toHaveBeenCalledWith(
`${mockSinkUrl}/api/link/create`,
expect.objectContaining({
body: JSON.stringify({
url: "https://example.com",
slug: "custom-slug",
comment: "Test comment",
}),
})
);
});
});
describe("createMany", () => {
beforeEach(() => {
vi.stubEnv("SINK_API_URL", mockSinkUrl);
vi.stubEnv("SINK_API_KEY", mockApiKey);
});
it("should successfully shorten multiple links", async () => {
const mockResponse = (url: string, index: number) => ({
link: {
id: `abc${index}`,
url,
slug: `xyz${index}`,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: `https://sink.test.com/xyz${index}`,
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse("https://example1.com", 1),
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse("https://example2.com", 2),
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse("https://example3.com", 3),
});
const testSink = new SinkClient();
const results = await testSink.createMany([
"https://example1.com",
"https://example2.com",
"https://example3.com",
]);
expect(results).toEqual([
{ url: "https://example1.com", shortLink: "https://sink.test.com/xyz1" },
{ url: "https://example2.com", shortLink: "https://sink.test.com/xyz2" },
{ url: "https://example3.com", shortLink: "https://sink.test.com/xyz3" },
]);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("should return original URLs when Sink is not configured", async () => {
vi.stubEnv("SINK_API_URL", "");
const testSink = new SinkClient();
const results = await testSink.createMany(["https://example1.com", "https://example2.com"]);
expect(results).toEqual([
{ url: "https://example1.com", shortLink: null },
{ url: "https://example2.com", shortLink: null },
]);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should handle empty URLs in the array", async () => {
const mockResponse = {
link: {
id: "abc123",
url: "https://example.com",
slug: "xyz",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: "https://sink.test.com/xyz",
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const testSink = new SinkClient();
const results = await testSink.createMany(["", "https://example.com", ""]);
expect(results[0]).toEqual({ url: "", shortLink: null });
expect(results[1]).toEqual({ url: "https://example.com", shortLink: "https://sink.test.com/xyz" });
expect(results[2]).toEqual({ url: "", shortLink: null });
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("should handle partial failures gracefully", async () => {
const mockSuccessResponse = {
link: {
id: "abc1",
url: "https://example1.com",
slug: "xyz1",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: "https://sink.test.com/xyz1",
};
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => mockSuccessResponse,
})
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Server error",
});
const testSink = new SinkClient();
const results = await testSink.createMany([
"https://example1.com",
"https://example2.com",
"https://example3.com",
]);
expect(results).toEqual([
{ url: "https://example1.com", shortLink: "https://sink.test.com/xyz1" },
{ url: "https://example2.com", shortLink: null },
{ url: "https://example3.com", shortLink: null },
]);
});
it("should handle empty array", async () => {
const testSink = new SinkClient();
const results = await testSink.createMany([]);
expect(results).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should process all URLs in parallel", async () => {
const mockResponse = {
link: {
id: "abc",
url: "https://example.com",
slug: "xyz",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
views: 0,
},
shortLink: "https://sink.test.com/xyz",
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const testSink = new SinkClient();
const urls = Array(10).fill("https://example.com");
await testSink.createMany(urls);
expect(global.fetch).toHaveBeenCalledTimes(10);
});
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SinkClient } from "../providers/SinkClient";
import { SinkShortener } from "../providers/SinkShortener";
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
function createMockClient(overrides: Partial<SinkClient> = {}): SinkClient {
return {
isConfigured: vi.fn().mockReturnValue(true),
createLink: vi.fn(),
createMany: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as SinkClient;
}
describe("SinkShortener", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
describe("isConfigured", () => {
it("returns true when both SINK_API_URL and SINK_API_KEY are set", () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
expect(SinkShortener.isConfigured()).toBe(true);
});
it("returns false when SINK_API_URL is missing", () => {
vi.stubEnv("SINK_API_URL", "");
vi.stubEnv("SINK_API_KEY", "test-key");
expect(SinkShortener.isConfigured()).toBe(false);
});
it("returns false when SINK_API_KEY is missing", () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "");
expect(SinkShortener.isConfigured()).toBe(false);
});
});
describe("shortenMany", () => {
it("returns shortened links from client", async () => {
const client = createMockClient({
createMany: vi
.fn()
.mockResolvedValue([{ url: "https://example.com", shortLink: "https://sink.test.com/abc" }]),
});
const shortener = new SinkShortener(client);
const result = await shortener.shortenMany(["https://example.com"]);
expect(result).toEqual([{ shortLink: "https://sink.test.com/abc" }]);
});
it("falls back to original URL when shortLink is null", async () => {
const client = createMockClient({
createMany: vi.fn().mockResolvedValue([{ url: "https://example.com", shortLink: null }]),
});
const shortener = new SinkShortener(client);
const result = await shortener.shortenMany(["https://example.com"]);
expect(result).toEqual([{ shortLink: "https://example.com" }]);
});
it("returns original URLs on client error", async () => {
const client = createMockClient({
createMany: vi.fn().mockRejectedValue(new Error("API error")),
});
const shortener = new SinkShortener(client);
const result = await shortener.shortenMany(["https://example1.com", "https://example2.com"]);
expect(result).toEqual([{ shortLink: "https://example1.com" }, { shortLink: "https://example2.com" }]);
});
it("ignores options parameter", async () => {
const mockCreateMany = vi
.fn()
.mockResolvedValue([{ url: "https://example.com", shortLink: "https://sink.test.com/abc" }]);
const client = createMockClient({ createMany: mockCreateMany });
const shortener = new SinkShortener(client);
await shortener.shortenMany(["https://example.com"], { domain: "custom.com", folderId: "folder123" });
expect(mockCreateMany).toHaveBeenCalledWith(["https://example.com"]);
});
});
});
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}));
vi.mock("@calcom/features/auth/lib/dub", () => ({
dub: {
links: {
createMany: vi.fn(),
},
},
}));
const mockCheckIfUserHasFeature = vi.fn();
const mockCheckIfTeamHasFeature = vi.fn();
const mockCheckIfFeatureIsEnabledGlobally = vi.fn();
vi.mock("@calcom/features/flags/features.repository", () => ({
FeaturesRepository: class {
checkIfUserHasFeature = mockCheckIfUserHasFeature;
checkIfTeamHasFeature = mockCheckIfTeamHasFeature;
checkIfFeatureIsEnabledGlobally = mockCheckIfFeatureIsEnabledGlobally;
},
}));
vi.mock("@calcom/prisma", () => ({
default: {},
}));
import { DubShortener } from "../providers/DubShortener";
import { NoopShortener } from "../providers/NoopShortener";
import { SinkShortener } from "../providers/SinkShortener";
import { UrlShortenerFactory } from "../UrlShortenerFactory";
describe("UrlShortenerFactory", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.stubEnv("SINK_API_URL", "");
vi.stubEnv("SINK_API_KEY", "");
vi.stubEnv("DUB_API_KEY", "");
mockCheckIfUserHasFeature.mockResolvedValue(false);
mockCheckIfTeamHasFeature.mockResolvedValue(false);
mockCheckIfFeatureIsEnabledGlobally.mockResolvedValue(false);
});
describe("global feature flag", () => {
it("returns SinkShortener when globally enabled (no userId or teamId)", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
mockCheckIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const provider = await UrlShortenerFactory.create();
expect(provider).toBeInstanceOf(SinkShortener);
expect(mockCheckIfUserHasFeature).not.toHaveBeenCalled();
expect(mockCheckIfTeamHasFeature).not.toHaveBeenCalled();
});
it("returns SinkShortener when globally enabled (skips user and team checks)", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
mockCheckIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const provider = await UrlShortenerFactory.create({ userId: 1, teamId: 2 });
expect(provider).toBeInstanceOf(SinkShortener);
expect(mockCheckIfUserHasFeature).not.toHaveBeenCalled();
expect(mockCheckIfTeamHasFeature).not.toHaveBeenCalled();
});
});
describe("user feature flag (global off)", () => {
it("returns SinkShortener when user has sink-shortener flag enabled", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
mockCheckIfUserHasFeature.mockResolvedValue(true);
const provider = await UrlShortenerFactory.create({ userId: 1 });
expect(provider).toBeInstanceOf(SinkShortener);
expect(mockCheckIfUserHasFeature).toHaveBeenCalledWith(1, "sink-shortener");
expect(mockCheckIfTeamHasFeature).not.toHaveBeenCalled();
});
it("skips user check when userId is null", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
vi.stubEnv("DUB_API_KEY", "dub-test-key");
await UrlShortenerFactory.create({ userId: null });
expect(mockCheckIfUserHasFeature).not.toHaveBeenCalled();
});
});
describe("team feature flag (global off, user off)", () => {
it("returns SinkShortener when team has sink-shortener flag enabled", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
mockCheckIfTeamHasFeature.mockResolvedValue(true);
const provider = await UrlShortenerFactory.create({ teamId: 2 });
expect(provider).toBeInstanceOf(SinkShortener);
expect(mockCheckIfTeamHasFeature).toHaveBeenCalledWith(2, "sink-shortener");
});
it("falls back to team check when user check fails", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
mockCheckIfUserHasFeature.mockResolvedValue(false);
mockCheckIfTeamHasFeature.mockResolvedValue(true);
const provider = await UrlShortenerFactory.create({ userId: 1, teamId: 2 });
expect(provider).toBeInstanceOf(SinkShortener);
expect(mockCheckIfUserHasFeature).toHaveBeenCalledWith(1, "sink-shortener");
expect(mockCheckIfTeamHasFeature).toHaveBeenCalledWith(2, "sink-shortener");
});
it("returns DubShortener when both user and team flags are off", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
vi.stubEnv("DUB_API_KEY", "dub-test-key");
const provider = await UrlShortenerFactory.create({ userId: 1, teamId: 2 });
expect(provider).toBeInstanceOf(DubShortener);
});
it("skips team check when teamId is null", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("SINK_API_KEY", "test-key");
vi.stubEnv("DUB_API_KEY", "dub-test-key");
await UrlShortenerFactory.create({ teamId: null });
expect(mockCheckIfTeamHasFeature).not.toHaveBeenCalled();
});
});
describe("Sink env vars not configured", () => {
it("returns DubShortener without checking any flags", async () => {
vi.stubEnv("DUB_API_KEY", "dub-test-key");
const provider = await UrlShortenerFactory.create({ userId: 1, teamId: 2 });
expect(provider).toBeInstanceOf(DubShortener);
expect(mockCheckIfUserHasFeature).not.toHaveBeenCalled();
expect(mockCheckIfTeamHasFeature).not.toHaveBeenCalled();
expect(mockCheckIfFeatureIsEnabledGlobally).not.toHaveBeenCalled();
});
it("returns NoopShortener when nothing is configured", async () => {
const provider = await UrlShortenerFactory.create();
expect(provider).toBeInstanceOf(NoopShortener);
});
it("returns DubShortener when Sink URL is set but key is missing", async () => {
vi.stubEnv("SINK_API_URL", "https://sink.test.com");
vi.stubEnv("DUB_API_KEY", "dub-test-key");
const provider = await UrlShortenerFactory.create({ userId: 1 });
expect(provider).toBeInstanceOf(DubShortener);
});
});
});
@@ -0,0 +1,35 @@
import { dub } from "@calcom/features/auth/lib/dub";
import logger from "@calcom/lib/logger";
import type { IUrlShortenerProvider, ShortenOptions, ShortenResult } from "../IUrlShortenerProvider";
const log = logger.getSubLogger({ prefix: ["dub-shortener"] });
export class DubShortener implements IUrlShortenerProvider {
static isConfigured(): boolean {
return Boolean(process.env.DUB_API_KEY);
}
async shortenMany(urls: string[], options?: ShortenOptions): Promise<ShortenResult[]> {
try {
const linksToShorten = urls.filter((link) => link);
const dubResults = await dub.links.createMany(
linksToShorten.map((url) => ({
domain: options?.domain,
url,
folderId: options?.folderId,
}))
);
return urls.map((url) => {
const createdLink = dubResults.find(
(result): result is Extract<typeof result, { url: string }> =>
!("error" in result) && result.url === url
);
return { shortLink: createdLink?.shortLink || url };
});
} catch (error) {
log.error("Dub shortening failed, returning original URLs", error);
return urls.map((url) => ({ shortLink: url }));
}
}
}
@@ -0,0 +1,7 @@
import type { IUrlShortenerProvider, ShortenOptions, ShortenResult } from "../IUrlShortenerProvider";
export class NoopShortener implements IUrlShortenerProvider {
async shortenMany(urls: string[], _options?: ShortenOptions): Promise<ShortenResult[]> {
return urls.map((url) => ({ shortLink: url }));
}
}
@@ -0,0 +1,113 @@
import logger from "@calcom/lib/logger";
const log = logger.getSubLogger({ prefix: ["sink-url-shortener"] });
interface SinkLinkCreateRequest {
url: string;
slug?: string;
comment?: string;
expiration?: string;
password?: string;
disable?: boolean;
}
interface SinkLinkResponse {
link: {
id: string;
url: string;
slug: string;
comment?: string;
createdAt: string;
updatedAt: string;
expiresAt?: string;
password?: string;
disabled?: boolean;
views: number;
};
shortLink: string;
}
export class SinkClient {
private baseUrl: string;
private apiKey?: string;
constructor() {
this.baseUrl = process.env.SINK_API_URL || "";
this.apiKey = process.env.SINK_API_KEY;
}
isConfigured(): boolean {
return Boolean(this.baseUrl && this.apiKey);
}
async createLink(url: string, options?: Partial<SinkLinkCreateRequest>): Promise<string | null> {
if (!this.isConfigured()) {
log.debug("Sink not configured, skipping link creation");
return null;
}
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
headers.Authorization = `Bearer ${this.apiKey}`;
const response = await fetch(`${this.baseUrl}/api/link/create`, {
method: "POST",
headers,
body: JSON.stringify({
url,
...options,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const errorText = await response.text();
log.error(`Sink API error: ${response.status} - ${errorText}`);
return null;
}
const data = (await response.json()) as SinkLinkResponse;
return data.shortLink;
} catch (error) {
log.error("Failed to create Sink link", error);
return null;
}
}
async createMany(
links: string[],
options?: Partial<SinkLinkCreateRequest>
): Promise<{ url: string; shortLink: string | null }[]> {
if (!this.isConfigured()) {
log.debug("Sink not configured, returning original links");
return links.map((url) => ({ url, shortLink: null }));
}
const promises = links.map(async (url) => {
if (!url) {
return { url, shortLink: null };
}
const shortLink = await this.createLink(url, options);
return { url, shortLink };
});
try {
const results = await Promise.allSettled(promises);
return results.map((result, index) => {
if (result.status === "fulfilled") {
return result.value;
} else {
log.error(`Failed to shorten link at index ${index}`, result.reason);
return { url: links[index], shortLink: null };
}
});
} catch (error) {
log.error("Failed to create multiple Sink links", error);
return links.map((url) => ({ url, shortLink: null }));
}
}
}
@@ -0,0 +1,25 @@
import logger from "@calcom/lib/logger";
import type { IUrlShortenerProvider, ShortenOptions, ShortenResult } from "../IUrlShortenerProvider";
import type { SinkClient } from "./SinkClient";
const log = logger.getSubLogger({ prefix: ["sink-shortener"] });
export class SinkShortener implements IUrlShortenerProvider {
constructor(private client: SinkClient) {}
static isConfigured(): boolean {
return Boolean(process.env.SINK_API_URL && process.env.SINK_API_KEY);
}
async shortenMany(urls: string[], _options?: ShortenOptions): Promise<ShortenResult[]> {
try {
const results = await this.client.createMany(urls);
return results.map((result) => ({
shortLink: result.shortLink || result.url,
}));
} catch (error) {
log.error("Sink shortening failed, returning original URLs", error);
return urls.map((url) => ({ shortLink: url }));
}
}
}
+3
View File
@@ -231,6 +231,9 @@ export const URL_SCANNING_ENABLED =
export const IS_DUB_REFERRALS_ENABLED =
!!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== "";
export const DUB_SMS_DOMAIN = process.env.DUB_SMS_DOMAIN;
export const DUB_SMS_FOLDER_ID = process.env.DUB_SMS_FOLDER_ID;
export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING;
export const IS_SMS_CREDITS_ENABLED =
@@ -0,0 +1,4 @@
INSERT INTO "Feature" ("slug", "enabled", "type", "description", "createdAt", "updatedAt")
VALUES
('sink-shortener', false, 'OPERATIONAL', 'Enable Sink URL shortener for SMS workflows', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;