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:
co-authored by
unknown <>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Keith Williams
parent
12446d826b
commit
353f71bdc6
@@ -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
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user