diff --git a/.env.example b/.env.example index e1995a4cc5..5baff90ab3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/web/modules/feature-flags/hooks/useFlags.ts b/apps/web/modules/feature-flags/hooks/useFlags.ts index 9038e72b23..5bf52ababa 100644 --- a/apps/web/modules/feature-flags/hooks/useFlags.ts +++ b/apps/web/modules/feature-flags/hooks/useFlags.ts @@ -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) { diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.test.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.test.ts index df0ea69a0d..1b6b53782e 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.test.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.test.ts @@ -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) => 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 = {}) { + 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 = {}): Record { +function createMockReminderForFallback(overrides: Record = {}): Record { const now = new Date(); const scheduledDate = new Date(now.getTime() + 60 * 60 * 1000); @@ -168,14 +773,17 @@ function createMockReminder(overrides: Record = {}): Record { 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), 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; - 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), 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; - const reminder = createMockReminder({ + const reminder = createMockReminderForFallback({ booking: { ...booking, smsReminderNumber: "+5555555555", diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index 01a9c58533..9880ef6c03 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -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, diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 66c1fa7a94..d7506ecc27 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -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( diff --git a/packages/features/ee/workflows/lib/reminders/utils.test.ts b/packages/features/ee/workflows/lib/reminders/utils.test.ts new file mode 100644 index 0000000000..2aeb6bb044 --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/utils.test.ts @@ -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: "

Final SMS text

" }); +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 { + 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 { + 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: "

Final SMS text

" }); + 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); + }); + }); + }); +}); diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 259d5bb3e1..c6f522ef7b 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -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 => - !("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 diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 2430d39ba0..512e2e87f2 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -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; diff --git a/packages/features/url-shortener/IUrlShortenerProvider.ts b/packages/features/url-shortener/IUrlShortenerProvider.ts new file mode 100644 index 0000000000..d9e8068c1c --- /dev/null +++ b/packages/features/url-shortener/IUrlShortenerProvider.ts @@ -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; +} diff --git a/packages/features/url-shortener/UrlShortenerFactory.ts b/packages/features/url-shortener/UrlShortenerFactory.ts new file mode 100644 index 0000000000..373c0d8d37 --- /dev/null +++ b/packages/features/url-shortener/UrlShortenerFactory.ts @@ -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 { + 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(); + } +} diff --git a/packages/features/url-shortener/__tests__/DubShortener.test.ts b/packages/features/url-shortener/__tests__/DubShortener.test.ts new file mode 100644 index 0000000000..fa1128477c --- /dev/null +++ b/packages/features/url-shortener/__tests__/DubShortener.test.ts @@ -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: "" }, + ]); + }); + }); +}); diff --git a/packages/features/url-shortener/__tests__/NoopShortener.test.ts b/packages/features/url-shortener/__tests__/NoopShortener.test.ts new file mode 100644 index 0000000000..ad6022f757 --- /dev/null +++ b/packages/features/url-shortener/__tests__/NoopShortener.test.ts @@ -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" }]); + }); +}); diff --git a/packages/features/url-shortener/__tests__/SinkClient.test.ts b/packages/features/url-shortener/__tests__/SinkClient.test.ts new file mode 100644 index 0000000000..93474600d5 --- /dev/null +++ b/packages/features/url-shortener/__tests__/SinkClient.test.ts @@ -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); + }); + }); +}); diff --git a/packages/features/url-shortener/__tests__/SinkShortener.test.ts b/packages/features/url-shortener/__tests__/SinkShortener.test.ts new file mode 100644 index 0000000000..49b7157ff9 --- /dev/null +++ b/packages/features/url-shortener/__tests__/SinkShortener.test.ts @@ -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 { + 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"]); + }); + }); +}); diff --git a/packages/features/url-shortener/__tests__/UrlShortenerFactory.test.ts b/packages/features/url-shortener/__tests__/UrlShortenerFactory.test.ts new file mode 100644 index 0000000000..2006ce90b0 --- /dev/null +++ b/packages/features/url-shortener/__tests__/UrlShortenerFactory.test.ts @@ -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); + }); + }); +}); diff --git a/packages/features/url-shortener/providers/DubShortener.ts b/packages/features/url-shortener/providers/DubShortener.ts new file mode 100644 index 0000000000..6020529bb0 --- /dev/null +++ b/packages/features/url-shortener/providers/DubShortener.ts @@ -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 { + 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 => + !("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 })); + } + } +} diff --git a/packages/features/url-shortener/providers/NoopShortener.ts b/packages/features/url-shortener/providers/NoopShortener.ts new file mode 100644 index 0000000000..68eb487308 --- /dev/null +++ b/packages/features/url-shortener/providers/NoopShortener.ts @@ -0,0 +1,7 @@ +import type { IUrlShortenerProvider, ShortenOptions, ShortenResult } from "../IUrlShortenerProvider"; + +export class NoopShortener implements IUrlShortenerProvider { + async shortenMany(urls: string[], _options?: ShortenOptions): Promise { + return urls.map((url) => ({ shortLink: url })); + } +} diff --git a/packages/features/url-shortener/providers/SinkClient.ts b/packages/features/url-shortener/providers/SinkClient.ts new file mode 100644 index 0000000000..bbb6dc1f83 --- /dev/null +++ b/packages/features/url-shortener/providers/SinkClient.ts @@ -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): Promise { + if (!this.isConfigured()) { + log.debug("Sink not configured, skipping link creation"); + return null; + } + + try { + const headers: Record = { + "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 + ): 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 })); + } + } +} diff --git a/packages/features/url-shortener/providers/SinkShortener.ts b/packages/features/url-shortener/providers/SinkShortener.ts new file mode 100644 index 0000000000..a4397c5096 --- /dev/null +++ b/packages/features/url-shortener/providers/SinkShortener.ts @@ -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 { + 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 })); + } + } +} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index ccdf451341..beec49b7d2 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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 = diff --git a/packages/prisma/migrations/20260226000000_seed_sink_shortener_feature/migration.sql b/packages/prisma/migrations/20260226000000_seed_sink_shortener_feature/migration.sql new file mode 100644 index 0000000000..0068c18f89 --- /dev/null +++ b/packages/prisma/migrations/20260226000000_seed_sink_shortener_feature/migration.sql @@ -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;