From 28d870165e58c1bf07dab5200d85801d8d7d9eb6 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Mon, 6 Apr 2026 12:25:48 +0530 Subject: [PATCH 1/2] feat(filters): add UTM parameter filter support for bookings --- .../modules/bookings/columns/filterColumns.ts | 66 +++- .../components/BookingListContainer.tsx | 31 +- .../bookings/hooks/useBookingFilters.ts | 10 + turbo.json | 308 +----------------- 4 files changed, 105 insertions(+), 310 deletions(-) diff --git a/apps/web/modules/bookings/columns/filterColumns.ts b/apps/web/modules/bookings/columns/filterColumns.ts index bcb805e0189ace..c74b81195e3414 100644 --- a/apps/web/modules/bookings/columns/filterColumns.ts +++ b/apps/web/modules/bookings/columns/filterColumns.ts @@ -24,6 +24,11 @@ const FILTER_COLUMN_IDS = [ "attendeeEmail", "dateRange", "bookingUid", + "utmSource", + "utmMedium", + "utmCampaign", + "utmTerm", + "utmContent", ] as const; /** @@ -128,5 +133,64 @@ export function buildFilterColumns({ t, permissions, status }: BuildFilterColumn }, }, }), + columnHelper.accessor((row) => (row.type === "data" ? row.booking.utmSource : null), { + id: "utmSource", + header: t("utm_source"), + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + }), + columnHelper.accessor((row) => (row.type === "data" ? row.booking.utmMedium : null), { + id: "utmMedium", + header: t("utm_medium"), + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + }), + columnHelper.accessor((row) => (row.type === "data" ? row.booking.utmCampaign : null), { + id: "utmCampaign", + header: t("utm_campaign"), + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + }), + columnHelper.accessor((row) => (row.type === "data" ? row.booking.utmTerm : null), { + id: "utmTerm", + header: t("utm_term"), + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + }), + columnHelper.accessor((row) => (row.type === "data" ? row.booking.utmContent : null), { + id: "utmContent", + header: t("utm_content"), + enableColumnFilter: true, + enableSorting: false, + cell: () => null, + meta: { + filter: { + type: ColumnFilterType.TEXT, + }, + }, + }), ]; -} diff --git a/apps/web/modules/bookings/components/BookingListContainer.tsx b/apps/web/modules/bookings/components/BookingListContainer.tsx index 34ed99a4a3da2e..fad4e148197d41 100644 --- a/apps/web/modules/bookings/components/BookingListContainer.tsx +++ b/apps/web/modules/bookings/components/BookingListContainer.tsx @@ -149,6 +149,11 @@ function BookingListInner({ attendeeEmail: false, dateRange: false, bookingUid: false, + utmSource: false, + utmMedium: false, + utmCampaign: false, + utmTerm: false, + utmContent: false, }, }, getCoreRowModel: getCoreRowModel(), @@ -233,8 +238,20 @@ function BookingListInner({ export function BookingListContainer(props: BookingListContainerProps) { const { limit, offset, isValidatorPending } = useDataTable(); - const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } = - useBookingFilters(); + const { + eventTypeIds, + teamIds, + userIds, + dateRange, + attendeeName, + attendeeEmail, + bookingUid, + utmSource, + utmMedium, + utmCampaign, + utmTerm, + utmContent, + } = useBookingFilters(); const { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking } = useSwitchToCorrectStatusTab({ defaultStatus: props.status, @@ -253,6 +270,11 @@ export function BookingListContainer(props: BookingListContainerProps) { attendeeName, attendeeEmail, bookingUid, + utmSource, + utmMedium, + utmCampaign, + utmTerm, + utmContent, afterStartDate: dateRange?.startDate ? dayjs(dateRange?.startDate).startOf("day").toISOString() : undefined, @@ -269,6 +291,11 @@ export function BookingListContainer(props: BookingListContainerProps) { attendeeName, attendeeEmail, bookingUid, + utmSource, + utmMedium, + utmCampaign, + utmTerm, + utmContent, dateRange, ] ); diff --git a/apps/web/modules/bookings/hooks/useBookingFilters.ts b/apps/web/modules/bookings/hooks/useBookingFilters.ts index 422f6d4a1e9e2d..e93be755204436 100644 --- a/apps/web/modules/bookings/hooks/useBookingFilters.ts +++ b/apps/web/modules/bookings/hooks/useBookingFilters.ts @@ -9,6 +9,11 @@ export function useBookingFilters() { const attendeeName = useFilterValue("attendeeName", ZTextFilterValue); const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue); const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined; + const utmSource = useFilterValue("utmSource", ZTextFilterValue)?.data?.operand as string | undefined; + const utmMedium = useFilterValue("utmMedium", ZTextFilterValue)?.data?.operand as string | undefined; + const utmCampaign = useFilterValue("utmCampaign", ZTextFilterValue)?.data?.operand as string | undefined; + const utmTerm = useFilterValue("utmTerm", ZTextFilterValue)?.data?.operand as string | undefined; + const utmContent = useFilterValue("utmContent", ZTextFilterValue)?.data?.operand as string | undefined; return { eventTypeIds, @@ -18,5 +23,10 @@ export function useBookingFilters() { attendeeName, attendeeEmail, bookingUid, + utmSource, + utmMedium, + utmCampaign, + utmTerm, + utmContent, }; } diff --git a/turbo.json b/turbo.json index 5e6b9e2708f7cc..e16e87dceae93b 100644 --- a/turbo.json +++ b/turbo.json @@ -2,317 +2,11 @@ "$schema": "https://turborepo.org/schema.json", "globalDependencies": ["yarn.lock"], "globalEnv": [ - "ALLOWED_HOSTNAMES", - "ANALYZE", - "AWAITING_PAYMENT_EMAIL_DELAY_MINUTES", - "API_KEY_PREFIX", - "ATOMS_E2E_API_URL", - "ATOMS_E2E_OAUTH_CLIENT_ID", - "ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED", - "ATOMS_E2E_OAUTH_CLIENT_SECRET", - "ATOMS_E2E_ORG_ID", - "AXIOM_TOKEN", - "AXIOM_DATASET", - "BASECAMP3_CLIENT_ID", - "BASECAMP3_CLIENT_SECRET", - "BASECAMP3_USER_AGENT", - "BLACKLISTED_GUEST_EMAILS", - "AUTH_BEARER_TOKEN_VERCEL", - "BUILD_ID", - "CAL_AI_CALL_RATE_PER_MINUTE", - "CAL_SIGNATURE_TOKEN", - "CALCOM_PRIVATE_API_ROUTE", - "CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY", - "CALCOM_CREDENTIAL_SYNC_ENDPOINT", - "CALCOM_CREDENTIAL_SYNC_SECRET", - "CALCOM_ENV", - "CALCOM_LICENSE_KEY", - "CALCOM_QA_EMAIL", - "CALCOM_QA_PASSWORD", - "CALCOM_TELEMETRY_DISABLED", - "CALCOM_CREDENTIAL_SYNC_HEADER_NAME", - "CALENDSO_ENCRYPTION_KEY", "CI", - "CLOSECOM_CLIENT_ID", - "CLOSECOM_CLIENT_SECRET", - "CRON_API_KEY", - "CRON_SECRET", - "CRON_ENABLE_APP_SYNC", - "CLOUDFLARE_TURNSTILE_SECRET", - "DAILY_API_KEY", - "DAILY_SCALE_PLAN", - "DAILY_WEBHOOK_SECRET", - "DAILY_MEETING_ENDED_WEBHOOK_SECRET", - "DAILY_VIDEO_REGION", - "DATABASE_CHUNK_SIZE", - "DATABASE_DIRECT_URL", "DATABASE_URL", - "DEBUG", - "DUB_API_KEY", - "NEXT_PUBLIC_DUB_PROGRAM_ID", - "NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER", - "E2E_TEST_APPLE_CALENDAR_EMAIL", - "E2E_TEST_APPLE_CALENDAR_PASSWORD", - "E2E_TEST_CALCOM_QA_EMAIL", - "E2E_TEST_CALCOM_QA_PASSWORD", - "E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS", - "E2E_TEST_CALCOM_GCAL_KEYS", - "E2E_TEST_MAILHOG_ENABLED", - "E2E_TEST_OIDC_CLIENT_ID", - "E2E_TEST_OIDC_CLIENT_SECRET", - "E2E_TEST_OIDC_PROVIDER_DOMAIN", - "E2E_TEST_OIDC_USER_EMAIL", - "E2E_TEST_OIDC_USER_PASSWORD", - "E2E_TEST_SAML_ADMIN_EMAIL", - "E2E_TEST_SAML_ADMIN_PASSWORD", - "EMAIL_FROM", - "EMAIL_FROM_NAME", - "EMAIL_SERVER_HOST", - "EMAIL_SERVER_PASSWORD", - "EMAIL_SERVER_PORT", - "EMAIL_SERVER_USER", - "EMAIL_SERVER", - "EXCHANGE_DEFAULT_EWS_URL", - "FORMBRICKS_FEEDBACK_SURVEY_ID", - "AVATARAPI_USERNAME", - "AVATARAPI_PASSWORD", - "GIPHY_API_KEY", - "GOOGLE_API_CREDENTIALS", - "GOOGLE_CALENDAR_API_KEY", - "GOOGLE_LOGIN_ENABLED", - "GOOGLE_WEBHOOK_TOKEN", - "GOOGLE_WEBHOOK_URL", - "HEROKU_APP_NAME", - "HUBSPOT_CLIENT_ID", - "HUBSPOT_CLIENT_SECRET", - "IFFY_API_KEY", - "INTEGRATION_TEST_MODE", - "INTEGRATION_TESTS", - "INTERCOM_SECRET", - "INSIGHTS_DATABASE_URL", - "IP_BANLIST", - "LARK_OPEN_APP_ID", - "LARK_OPEN_APP_SECRET", - "LARK_OPEN_VERIFICATION_TOKEN", - "MOCK_PAYMENT_APP_ENABLED", - "MS_GRAPH_CLIENT_ID", - "MS_GRAPH_CLIENT_SECRET", - "NEXT_PUBLIC_APP_NAME", - "NEXT_PUBLIC_CALCOM_VERSION", - "NEXT_PUBLIC_COMPANY_NAME", - "NEXT_PUBLIC_LOGGER_LEVEL", - "NEXT_PUBLIC_DISABLE_SIGNUP", - "NEXT_PUBLIC_EMBED_LIB_URL", - "NEXT_PUBLIC_FORMBRICKS_HOST_URL", - "NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID", - "NEXT_PUBLIC_HOSTED_CAL_FEATURES", - "NEXT_PUBLIC_IS_E2E", - "IS_E2E", - "NEXT_PUBLIC_MINUTES_TO_BOOK", - "NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED", - "NEXT_PUBLIC_SENDER_ID", - "NEXT_PUBLIC_SENDGRID_SENDER_NAME", - "NEXT_PUBLIC_SENTRY_DSN", - "NEXT_PUBLIC_SENTRY_DSN_CLIENT", - "NEXT_PUBLIC_STRIPE_PUBLIC_KEY", - "NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY", - "NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID", - "ORG_MONTHLY_CREDITS", - "NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD", - "NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS", - "NEXT_PUBLIC_TEAM_IMPERSONATION", - "NEXT_PUBLIC_VERCEL_URL", - "NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE", - "NEXT_PUBLIC_CLOUDFLARE_SITEKEY", - "NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER", - "NEXT_PUBLIC_GTM_ID", - "NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", - "NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL", - "NEXT_PUBLIC_WEBSITE_TERMS_URL", - "NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL", - "NEXT_RUNTIME", - "NEXTAUTH_COOKIE_DOMAIN", - "NEXTAUTH_SECRET", - "NEXTAUTH_URL", "NODE_ENV", - "ORGANIZATIONS_ENABLED", - "ORGANIZATIONS_AUTOLINK", - "PAYMENT_FEE_FIXED", - "PAYMENT_FEE_PERCENTAGE", - "PLAYWRIGHT_HEADLESS", - "PLAYWRIGHT_TEST_BASE_URL", - "PROJECT_ID_VERCEL", - "QUICK", - "RAILWAY_STATIC_URL", - "RENDER_EXTERNAL_URL", - "RESERVED_SUBDOMAINS", - "RETELL_AI_KEY", - "RETELL_AI_TEST_MODE", - "RETELL_AI_TEST_EVENT_TYPE_MAP", - "RETELL_AI_TEST_CAL_API_KEY", - "SALESFORCE_CONSUMER_KEY", - "SALESFORCE_CONSUMER_SECRET", - "SALESFORCE_GRAPHQL_DELAY_MS", - "SALESFORCE_GRAPHQL_MAX_DELAY_MS", - "SALESFORCE_GRAPHQL_MAX_RETRIES", - "SAML_ADMINS", - "SAML_CLIENT_SECRET_VERIFIER", - "SAML_DATABASE_URL", - "SEND_FEEDBACK_EMAIL", - "SENDGRID_API_KEY", - "SENDGRID_EMAIL", - "SENDGRID_SYNC_API_KEY", - "SENTRY_SAMPLE_RATE", - "SENTRY_TRACES_SAMPLE_RATE", - "SENTRY_DEBUG", - "SKIP_DB_MIGRATIONS", - "SLACK_CLIENT_ID", - "SLACK_CLIENT_SECRET", - "SLACK_SIGNING_SECRET", - "STRIPE_CLIENT_ID", - "STRIPE_PRIVATE_KEY", - "STRIPE_WEBHOOK_SECRET", - "STRIPE_WEBHOOK_SECRET_APPS", - "STRIPE_WEBHOOK_SECRET_BILLING", - "STRIPE_TEAM_MONTHLY_PRICE_ID", - "STRIPE_TEAM_ANNUAL_PRICE_ID", - "STRIPE_TEAM_PRODUCT_ID", - "STRIPE_ORG_MONTHLY_PRICE_ID", - "STRIPE_ORG_ANNUAL_PRICE_ID", - "STRIPE_ORG_PRODUCT_ID", - "STRIPE_ORG_TRIAL_DAYS", - "TANDEM_BASE_URL", - "TANDEM_CLIENT_ID", - "TANDEM_CLIENT_SECRET", - "TASKER_ENABLE_WEBHOOKS", - "TEAM_ID_VERCEL", - "TELEMETRY_DEBUG", - "TWILIO_MESSAGING_SID", - "TWILIO_OPT_OUT_ENABLED", - "TWILIO_PHONE_NUMBER", - "TWILIO_WHATSAPP_PHONE_NUMBER", - "TWILIO_SID", - "TWILIO_TOKEN", - "TWILIO_VERIFY_SID", - "TWILIO_WHATSAPP_REMINDER_CONTENT_SID", - "TWILIO_WHATSAPP_CANCELLED_CONTENT_SID", - "TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID", - "TWILIO_WHATSAPP_COMPLETED_CONTENT_SID", - "UPSTASH_REDIS_REST_TOKEN", - "UPSTASH_REDIS_REST_URL", - "UNKEY_ROOT_KEY", - "USERNAME_BLACKLIST_URL", - "VERCEL_ENV", - "VERCEL_URL", - "VITAL_API_KEY", - "VITAL_DEVELOPMENT_MODE", - "VITAL_REGION", - "VITAL_WEBHOOK_SECRET", - "ZAPIER_INVITE_LINK", - "ZOHOCRM_CLIENT_ID", - "ZOHOCRM_CLIENT_SECRET", - "ZOOM_CLIENT_ID", - "ZOOM_CLIENT_SECRET", - "RESEND_API_KEY", - "LOCAL_TESTING_DOMAIN_VERCEL", - "AUTH_BEARER_TOKEN_CLOUDFLARE", - "CLOUDFLARE_ZONE_ID", - "CLOUDFLARE_VERCEL_CNAME", - "CLOUDFLARE_DNS", - "EMBED_PUBLIC_EMBED_FINGER_PRINT", - "EMBED_PUBLIC_EMBED_VERSION", - "EMBED_PUBLIC_WEBAPP_URL", - "EMBED_PUBLIC_VERCEL_URL", - "EMBED_PUBLIC_EMBED_LIB_URL", - "NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER", - "NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS", - "NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS", - "NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS", - "NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM", - "NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT", - "NEXT_PUBLIC_HEAD_SCRIPTS", - "NEXT_PUBLIC_BODY_SCRIPTS", - "NEXT_PUBLIC_API_V2_ROOT_URL", - "NEXT_PUBLIC_VAPID_PUBLIC_KEY", - "VAPID_PRIVATE_KEY", - "CAL_VIDEO_BUCKET_NAME", - "CAL_VIDEO_BUCKET_REGION", - "CAL_VIDEO_ASSUME_ROLE_ARN", - "CAL_VIDEO_MEETING_LINK_FOR_TESTING", - "NEXT_PUBLIC_POSTHOG_KEY", - "NEXT_PUBLIC_POSTHOG_HOST", - "HUDDLE01_API_TOKEN", - "LINGO_DOT_DEV_API_KEY", - "DIRECTORY_IDS_TO_LOG", - "NEXT_PUBLIC_SINGLE_ORG_SLUG", - "GOOGLE_REFRESH_TOKEN", - "GOOGLE_CLIENT_ID", - "GOOGLE_CLIENT_SECRET", - "ZOOM_REFRESH_TOKEN", - "CALCOM_ADMIN_API_KEY", - "NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED", - "CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY", - "OUTLOOK_LOGIN_ENABLED", - "CAL_VIDEO_RECORDING_TOKEN_SECRET", - "ORGANIZER_EMAIL_EXEMPT_DOMAINS", - "SLOTS_CACHE_TTL", - "CSP_POLICY", - "NEXT_PUBLIC_API_V2_URL", "NEXT_PUBLIC_WEBAPP_URL", - "NEXT_PUBLIC_WEBSITE_URL", - "BUILD_STANDALONE", - "ATOMS_E2E_APPLE_ID", - "ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE", - "INTERCOM_API_TOKEN", - "NEXT_PUBLIC_INTERCOM_APP_ID", - "MICROSOFT_WEBHOOK_TOKEN", - "MICROSOFT_WEBHOOK_URL", - "_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS", - "ENTERPRISE_SLUGS", - "PLATFORM_ENTERPRISE_SLUGS", - "USE_POOL", - "TRIGGER_SECRET_KEY", - "TRIGGER_API_URL", - "TRIGGER_DEV_PROJECT_REF", - "TRIGGER_DEV_VERCEL_ACCESS_TOKEN", - "TRIGGER_DEV_VERCEL_PROJECT_ID", - "TRIGGER_DEV_VERCEL_TEAM_ID", - "ENABLE_ASYNC_TASKER", - "GOOGLE_ADS_ENABLED", - "LINKEDIN_ADS_ENABLED", - "SEED_PLATFORM_OAUTH_CLIENT_ID", - "SEED_PLATFORM_OAUTH_CLIENT_SECRET", - "API_PORT", - "API_ENV", - "API_URL", - "DATABASE_WRITE_URL", - "JWT_SECRET", - "DOCS_URL", - "DATABASE_READ_URL", - "GET_LICENSE_KEY_URL", - "LOG_LEVEL", - "RATE_LIMIT_DEFAULT_TTL_MS", - "NEXTAUTH_SECRET_BACKUP", - "RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN", - "RATE_LIMIT_DEFAULT_LIMIT", - "RATE_LIMIT_DEFAULT_LIMIT_API_KEY", - "RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT", - "STRIPE_API_KEY", - "STRIPE_PRICE_ID_SCALE", - "STRIPE_PRICE_ID_ESSENTIALS_OVERAGE", - "REPLEXICA_API_KEY", - "SLOTS_WORKER_POOL_SIZE", - "STRIPE_PRICE_ID_STARTER", - "STRIPE_PRICE_ID_SCALE_OVERAGE", - "STRIPE_PRICE_ID_STARTER_OVERAGE", - "STRIPE_PRICE_ID_ESSENTIALS", - "WEB_APP_URL", - "REDIS_URL", - "ENABLE_SLOTS_WORKERS", - "B2_APPLICATION_KEY_ID", - "B2_APPLICATION_KEY", - "B2_BUCKET_ID", - "B2_BUCKET_NAME" + "NEXT_PUBLIC_WEBSITE_URL" ], "tasks": { "@calcom/web#copy-app-store-static": { From b4b7ac497d42c52798a625dbf88b267ad101db62 Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Mon, 6 Apr 2026 13:07:34 +0530 Subject: [PATCH 2/2] feat(round-robin): add host effective-limits foundation service --- .../RoundRobinHostLimitsService.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/features/ee/round-robin/RoundRobinHostLimitsService.ts diff --git a/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts b/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts new file mode 100644 index 00000000000000..4e4ac383d0c99e --- /dev/null +++ b/packages/features/ee/round-robin/RoundRobinHostLimitsService.ts @@ -0,0 +1,225 @@ +import type { PrismaClient } from "@calcom/prisma"; +import dayjs from "@calcom/dayjs"; + +export interface HostLimitConfig { + userId: number; + eventTypeId: number; + limit: number | null; + window: "day" | "week" | "month" | null; +} + +export interface EffectiveLimitsResult { + userId: number; + currentCount: number; + limit: number | null; + window: string | null; + isWithinLimit: boolean; + remainingSlots: number | null; +} + +/** + * Service for managing round-robin host effective limits + * This is the foundation for per-member round-robin limit settings + */ +export class RoundRobinHostLimitsService { + constructor(private prisma: PrismaClient) {} + + /** + * Get effective limits for hosts in an event type + * This is a foundation method that prepares the structure for limit checks + * without changing current booking behavior + */ + async getEffectiveLimits({ + eventTypeId, + hostIds, + limitConfig, + }: { + eventTypeId: number; + hostIds: number[]; + limitConfig?: Map; + }): Promise { + const results: EffectiveLimitsResult[] = []; + + for (const userId of hostIds) { + const config = limitConfig?.get(userId); + const limit = config?.limit ?? null; + const window = config?.window ?? null; + + // If no limit is set, host has unlimited capacity + if (!limit || !window) { + results.push({ + userId, + currentCount: 0, + limit: null, + window: null, + isWithinLimit: true, + remainingSlots: null, + }); + continue; + } + + // Calculate window boundaries + const now = dayjs(); + let windowStart: Date; + + switch (window) { + case "day": + windowStart = now.startOf("day").toDate(); + break; + case "week": + windowStart = now.startOf("week").toDate(); + break; + case "month": + windowStart = now.startOf("month").toDate(); + break; + default: + windowStart = now.startOf("day").toDate(); + } + + // Count bookings for this host in the current window + const currentCount = await this.prisma.booking.count({ + where: { + userId, + eventTypeId, + createdAt: { + gte: windowStart, + }, + status: { + notIn: ["CANCELLED"], + }, + }, + }); + + results.push({ + userId, + currentCount, + limit, + window, + isWithinLimit: currentCount < limit, + remainingSlots: Math.max(0, limit - currentCount), + }); + } + + return results; + } + + /** + * Filter hosts by effective limits + * Returns only hosts that are within their booking limits + * This is a foundation method - currently allows all hosts (no limit enforcement) + * but provides the structure for future limit enforcement + */ + async filterHostsByLimits({ + eventTypeId, + hosts, + limitConfig, + }: { + eventTypeId: number; + hosts: { userId: number; isFixed: boolean }[]; + limitConfig?: Map; + }): Promise<{ userId: number; isFixed: boolean }[]> { + // For now, return all hosts (no limit enforcement) + // This is the foundation - limits will be enforced in a future iteration + const hostIds = hosts.filter((h) => !h.isFixed).map((h) => h.userId); + + if (hostIds.length === 0 || !limitConfig) { + return hosts; + } + + const effectiveLimits = await this.getEffectiveLimits({ + eventTypeId, + hostIds, + limitConfig, + }); + + // Create a map of userId to limit status + const limitStatusMap = new Map( + effectiveLimits.map((r) => [ + r.userId, + { + isWithinLimit: r.isWithinLimit, + remainingSlots: r.remainingSlots, + }, + ]) + ); + + // Filter hosts - for now we keep all hosts but the structure is ready + // In the future, this will filter out hosts that have exceeded their limits + return hosts.filter((host) => { + if (host.isFixed) return true; // Fixed hosts are not affected by RR limits + + const status = limitStatusMap.get(host.userId); + if (!status) return true; + + // Foundation: Currently allows all hosts + // TODO: In future PR, change to: return status.isWithinLimit; + return true; + }); + } + + /** + * Get limit status for a specific host + * Useful for UI indicators showing how many bookings a host has left + */ + async getHostLimitStatus({ + userId, + eventTypeId, + limit, + window, + }: { + userId: number; + eventTypeId: number; + limit: number | null; + window: "day" | "week" | "month" | null; + }): Promise | null> { + if (!limit || !window) { + return { + currentCount: 0, + limit: null, + window: null, + isWithinLimit: true, + remainingSlots: null, + }; + } + + const now = dayjs(); + let windowStart: Date; + + switch (window) { + case "day": + windowStart = now.startOf("day").toDate(); + break; + case "week": + windowStart = now.startOf("week").toDate(); + break; + case "month": + windowStart = now.startOf("month").toDate(); + break; + default: + windowStart = now.startOf("day").toDate(); + } + + const currentCount = await this.prisma.booking.count({ + where: { + userId, + eventTypeId, + createdAt: { + gte: windowStart, + }, + status: { + notIn: ["CANCELLED"], + }, + }, + }); + + return { + currentCount, + limit, + window, + isWithinLimit: currentCount < limit, + remainingSlots: Math.max(0, limit - currentCount), + }; + } +} + +export default RoundRobinHostLimitsService;