From cc1efe1298c0edc3e156afeca3435693451711f4 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 18 Nov 2025 22:11:52 -0300 Subject: [PATCH] fix: Conditional fetch cache (#24816) * Fix cache to fetch only when it's available * Non hierarchical feature check * Fix tests: Add missing mock method and comprehensive CalendarCacheWrapper tests - Add checkIfUserHasFeatureNonHierarchical to features.repository mock to fix failing GoogleCalendar tests - Add comprehensive unit tests for CalendarCacheWrapper covering: - Calendars with sync only (cache-only path) - Calendars without sync only (original calendar path) - Mixed calendars (both cache and original) - Timezone handling with UTC defaults - Edge cases (empty arrays, undefined methods, null ids) - Use proper types instead of 'as any' to satisfy lint rules Co-Authored-By: Volnei Munhoz * Fix: Sanitize logging to avoid exposing PII - Replace logging full selectedCalendars objects with only calendar IDs and count - Prevents exposure of email fields and other sensitive information in logs - Addresses AI code reviewer feedback Co-Authored-By: Volnei Munhoz * Apply suggestion from @volnei --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/app-store/_utils/getCalendar.ts | 2 +- .../lib/__mocks__/features.repository.ts | 1 + .../lib/cache/CalendarCacheWrapper.ts | 123 +++-- .../__tests__/CalendarCacheWrapper.test.ts | 463 ++++++++++++++++++ .../features/flags/features.repository.ts | 68 +++ 5 files changed, 625 insertions(+), 32 deletions(-) create mode 100644 packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheWrapper.test.ts diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 5eb9f2fc9d..c705038cb1 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -50,7 +50,7 @@ export const getCalendar = async ( featuresRepository.checkIfFeatureIsEnabledGlobally( CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ), - featuresRepository.checkIfUserHasFeature( + featuresRepository.checkIfUserHasFeatureNonHierarchical( credential.userId as number, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ), diff --git a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts index 5cdde79176..b09ffbee75 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts @@ -4,6 +4,7 @@ const featuresRepositoryModuleMock = { FeaturesRepository: vi.fn().mockImplementation(() => ({ checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true), })), }; diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts index 1f179f7e6c..0c4afee116 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -50,54 +50,115 @@ export class CalendarCacheWrapper implements Calendar { } /** - * Override this method to use cache + * Retrieves availability combining cache and live sources. * - * @param dateFrom - * @param dateTo - * @param selectedCalendars - * @param shouldServeCache + * - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache. + * - Calendars **without** one of them → fetched directly from the original calendar. + * - Results are merged into a single array. + * + * @param dateFrom - Start date (ISO string) + * @param dateTo - End date (ISO string) + * @param selectedCalendars - List of calendars to retrieve availability from + * @returns Combined array of busy date ranges */ async getAvailability( dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[] - // _shouldServeCache?: boolean - // _fallbackToPrimary?: boolean ): Promise { - log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars }); - const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); - if (!selectedCalendarIds.length) { - return Promise.resolve([]); + log.debug("getAvailability (mixed cache + original)", { + dateFrom, + dateTo, + calendarIds: selectedCalendars.map((c) => c.id), + calendarCount: selectedCalendars.length, + }); + + if (!selectedCalendars?.length) return []; + + const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt); + const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt); + + const results: EventBusyDate[] = []; + + // Fetch from cache for synced calendars + if (withSync.length) { + const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id)); + const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( + ids, + new Date(dateFrom), + new Date(dateTo) + ); + results.push(...cached); } - return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( - selectedCalendarIds, - new Date(dateFrom), - new Date(dateTo) - ); + + // Fetch from original calendar for unsynced ones + if (withoutSync.length) { + const original = await this.deps.originalCalendar.getAvailability(dateFrom, dateTo, withoutSync); + results.push(...original); + } + + return results; } /** - * Override this method to use cache + * Retrieves availability with time zones, combining cache and live data. * - * @param dateFrom - * @param dateTo - * @param selectedCalendars - * @returns + * - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache. + * - Calendars **without** one of them → fetched directly from the original calendar. + * - Results are merged into a single array with `{ start, end, timeZone }` format. + * + * @param dateFrom - Start date (ISO string) + * @param dateTo - End date (ISO string) + * @param selectedCalendars - List of calendars to retrieve availability from + * @returns Combined array of time-zone-aware availability ranges */ - async getAvailabilityWithTimeZones?( + async getAvailabilityWithTimeZones( dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[] - // _fallbackToPrimary?: boolean ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> { - log.debug("getAvailabilityWithTimeZones from cache", { dateFrom, dateTo, selectedCalendars }); - const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); - const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( - selectedCalendarIds, - new Date(dateFrom), - new Date(dateTo) - ); - return result.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC" })); + log.debug("getAvailabilityWithTimeZones (mixed cache + original)", { + dateFrom, + dateTo, + calendarIds: selectedCalendars.map((c) => c.id), + calendarCount: selectedCalendars.length, + }); + + if (!selectedCalendars?.length) return []; + + const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt); + const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt); + + const results: { start: Date | string; end: Date | string; timeZone: string }[] = []; + + // Fetch from cache for synced calendars + if (withSync.length) { + const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id)); + const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( + ids, + new Date(dateFrom), + new Date(dateTo) + ); + results.push( + ...cached.map(({ start, end, timeZone }) => ({ + start, + end, + timeZone: timeZone || "UTC", + })) + ); + } + + // Fetch from original calendar for unsynced ones + if (withoutSync.length) { + const original = await this.deps.originalCalendar.getAvailabilityWithTimeZones?.( + dateFrom, + dateTo, + withoutSync + ); + if (original?.length) results.push(...original); + } + + return results; } fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise { diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheWrapper.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheWrapper.test.ts new file mode 100644 index 0000000000..19e585a928 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheWrapper.test.ts @@ -0,0 +1,463 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; +import type { + Calendar, + CalendarEvent, + CalendarServiceEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, +} from "@calcom/types/Calendar"; + +import { CalendarCacheWrapper } from "../CalendarCacheWrapper"; + +describe("CalendarCacheWrapper", () => { + let mockOriginalCalendar: Calendar; + let mockRepository: ICalendarCacheEventRepository; + let wrapper: CalendarCacheWrapper; + + const createMockCalendar = (): Calendar => ({ + getAvailability: vi.fn().mockResolvedValue([]), + getAvailabilityWithTimeZones: vi.fn().mockResolvedValue([]), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + listCalendars: vi.fn(), + }); + + const createMockRepository = (): ICalendarCacheEventRepository => ({ + findAllBySelectedCalendarIdsBetween: vi.fn().mockResolvedValue([]), + }); + + beforeEach(() => { + mockOriginalCalendar = createMockCalendar(); + mockRepository = createMockRepository(); + wrapper = new CalendarCacheWrapper({ + originalCalendar: mockOriginalCalendar, + calendarCacheEventRepository: mockRepository, + }); + }); + + describe("getAvailability", () => { + it("should return empty array when no calendars provided", async () => { + const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", []); + + expect(result).toEqual([]); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled(); + expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled(); + }); + + it("should fetch only from cache when all calendars have syncToken and syncSubscribedAt", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + { + id: "cal-2", + externalId: "ext-2", + integration: "google_calendar", + syncToken: "token-2", + syncSubscribedAt: new Date(), + }, + ]; + + const cachedEvents: EventBusyDate[] = [ + { start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") }, + { start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") }, + ]; + + vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents); + + const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual(cachedEvents); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith( + ["cal-1", "cal-2"], + new Date("2025-01-01"), + new Date("2025-01-02") + ); + expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled(); + }); + + it("should fetch only from original calendar when all calendars lack syncToken or syncSubscribedAt", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + { + id: "cal-2", + externalId: "ext-2", + integration: "google_calendar", + syncToken: "token-2", + syncSubscribedAt: null, // Missing syncSubscribedAt + }, + ]; + + const originalEvents: EventBusyDate[] = [ + { start: new Date("2025-01-01T09:00:00Z"), end: new Date("2025-01-01T10:00:00Z") }, + ]; + + vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents); + + const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual(originalEvents); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled(); + expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith( + "2025-01-01", + "2025-01-02", + selectedCalendars + ); + }); + + it("should fetch from both cache and original calendar when calendars are mixed", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + { + id: "cal-2", + externalId: "ext-2", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + ]; + + const cachedEvents: EventBusyDate[] = [ + { start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") }, + ]; + + const originalEvents: EventBusyDate[] = [ + { start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") }, + ]; + + vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents); + vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents); + + const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual([...cachedEvents, ...originalEvents]); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith( + ["cal-1"], + new Date("2025-01-01"), + new Date("2025-01-02") + ); + expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith("2025-01-01", "2025-01-02", [ + selectedCalendars[1], + ]); + }); + + it("should filter out calendars without id when fetching from cache", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + { + id: null, + externalId: "ext-2", + integration: "google_calendar", + syncToken: "token-2", + syncSubscribedAt: new Date(), + }, + ]; + + await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars); + + expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith( + ["cal-1"], // Only cal-1 has a valid id + new Date("2025-01-01"), + new Date("2025-01-02") + ); + }); + }); + + describe("getAvailabilityWithTimeZones", () => { + it("should return empty array when no calendars provided", async () => { + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", []); + + expect(result).toEqual([]); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled(); + expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled(); + }); + + it("should fetch only from cache and apply UTC default timezone when all calendars are synced", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + ]; + + const cachedEvents: EventBusyDate[] = [ + { + start: new Date("2025-01-01T10:00:00Z"), + end: new Date("2025-01-01T11:00:00Z"), + timeZone: "America/New_York", + }, + { + start: new Date("2025-01-01T14:00:00Z"), + end: new Date("2025-01-01T15:00:00Z"), + timeZone: null, // Should default to UTC + }, + ]; + + vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents); + + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual([ + { + start: cachedEvents[0].start, + end: cachedEvents[0].end, + timeZone: "America/New_York", + }, + { + start: cachedEvents[1].start, + end: cachedEvents[1].end, + timeZone: "UTC", // Default applied + }, + ]); + expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled(); + }); + + it("should fetch only from original calendar when all calendars are unsynced", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + ]; + + const originalEvents = [ + { + start: new Date("2025-01-01T09:00:00Z"), + end: new Date("2025-01-01T10:00:00Z"), + timeZone: "Europe/London", + }, + ]; + + vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents); + + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual(originalEvents); + expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled(); + expect(mockOriginalCalendar.getAvailabilityWithTimeZones).toHaveBeenCalledWith( + "2025-01-01", + "2025-01-02", + selectedCalendars + ); + }); + + it("should fetch from both cache and original calendar when calendars are mixed", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + { + id: "cal-2", + externalId: "ext-2", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + ]; + + const cachedEvents: EventBusyDate[] = [ + { + start: new Date("2025-01-01T10:00:00Z"), + end: new Date("2025-01-01T11:00:00Z"), + timeZone: "America/New_York", + }, + ]; + + const originalEvents = [ + { + start: new Date("2025-01-01T14:00:00Z"), + end: new Date("2025-01-01T15:00:00Z"), + timeZone: "Europe/London", + }, + ]; + + vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents); + vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents); + + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual([ + { + start: cachedEvents[0].start, + end: cachedEvents[0].end, + timeZone: "America/New_York", + }, + ...originalEvents, + ]); + }); + + it("should handle when originalCalendar.getAvailabilityWithTimeZones is undefined", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: "token-1", + syncSubscribedAt: new Date(), + }, + { + id: "cal-2", + externalId: "ext-2", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + ]; + + const cachedEvents: EventBusyDate[] = [ + { + start: new Date("2025-01-01T10:00:00Z"), + end: new Date("2025-01-01T11:00:00Z"), + timeZone: "UTC", + }, + ]; + + vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents); + mockOriginalCalendar.getAvailabilityWithTimeZones = undefined; + + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual([ + { + start: cachedEvents[0].start, + end: cachedEvents[0].end, + timeZone: "UTC", + }, + ]); + }); + + it("should handle when originalCalendar.getAvailabilityWithTimeZones returns empty array", async () => { + const selectedCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + syncToken: null, + syncSubscribedAt: null, + }, + ]; + + vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue([]); + + const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars); + + expect(result).toEqual([]); + }); + }); + + describe("other Calendar methods", () => { + it("should delegate createEvent to original calendar", async () => { + const mockEvent: CalendarServiceEvent = { + title: "Test Event", + startTime: "2025-01-01T10:00:00Z", + endTime: "2025-01-01T11:00:00Z", + }; + const mockResult: NewCalendarEventType = { + uid: "test-uid", + id: "test-id", + type: "test-type", + password: "", + url: "", + additionalInfo: {}, + }; + + vi.mocked(mockOriginalCalendar.createEvent).mockResolvedValue(mockResult); + + const result = await wrapper.createEvent(mockEvent, 123, "cal-id"); + + expect(result).toBe(mockResult); + expect(mockOriginalCalendar.createEvent).toHaveBeenCalledWith(mockEvent, 123, "cal-id"); + }); + + it("should delegate updateEvent to original calendar", async () => { + const mockEvent: CalendarServiceEvent = { + title: "Updated Event", + startTime: "2025-01-01T10:00:00Z", + endTime: "2025-01-01T11:00:00Z", + }; + const mockResult: NewCalendarEventType = { + uid: "test-uid", + id: "test-id", + type: "test-type", + password: "", + url: "", + additionalInfo: {}, + }; + + vi.mocked(mockOriginalCalendar.updateEvent).mockResolvedValue(mockResult); + + const result = await wrapper.updateEvent("uid-123", mockEvent, "cal-id"); + + expect(result).toBe(mockResult); + expect(mockOriginalCalendar.updateEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id"); + }); + + it("should delegate deleteEvent to original calendar", async () => { + const mockEvent: CalendarEvent = { + uid: "test-uid", + title: "Test Event", + startTime: "2025-01-01T10:00:00Z", + endTime: "2025-01-01T11:00:00Z", + }; + + vi.mocked(mockOriginalCalendar.deleteEvent).mockResolvedValue(undefined); + + await wrapper.deleteEvent("uid-123", mockEvent, "cal-id"); + + expect(mockOriginalCalendar.deleteEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id"); + }); + + it("should delegate listCalendars to original calendar", async () => { + const mockCalendars: IntegrationCalendar[] = [ + { + id: "cal-1", + externalId: "ext-1", + integration: "google_calendar", + }, + ]; + + vi.mocked(mockOriginalCalendar.listCalendars).mockResolvedValue(mockCalendars); + + const result = await wrapper.listCalendars(); + + expect(result).toBe(mockCalendars); + expect(mockOriginalCalendar.listCalendars).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index c4baefb776..b6df40504e 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -142,6 +142,40 @@ export class FeaturesRepository implements IFeaturesRepository { } } + /** + * Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams. + * Only checks direct user assignments and direct team memberships — does not traverse parents. + * @param userId - The ID of the user to check + * @param slug - The feature identifier to check + * @returns Promise - True if the user has direct or same-level team access to the feature + * @throws Error if the feature access check fails + */ + async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) { + try { + // Prismock limitation: findUnique may fail, use findFirst instead + const userHasFeature = await this.prismaClient.userFeatures.findFirst({ + select: { + userId: true, + }, + where: { + userId, + featureId: slug, + }, + }); + if (userHasFeature) return true; + + const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical( + userId, + slug + ); + + return userBelongsToTeamWithFeature; + } catch (err) { + captureException(err); + throw err; + } + } + /** * Private helper method to check if a user belongs to any team that has access to a feature. * @param userId - The ID of the user to check @@ -190,6 +224,40 @@ export class FeaturesRepository implements IFeaturesRepository { } } + /** + * Checks if a user belongs to any direct team that has access to a feature. + * This version ignores parent/child team relationships — no recursion or hierarchy traversal. + * @param userId - The ID of the user to check + * @param slug - The feature identifier to check + * @returns Promise - True if the user belongs to a team with the feature (direct only) + * @throws Error if the query fails + * @private + */ + private async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string) { + try { + const query = Prisma.sql` + SELECT 1 + FROM "Team" t + INNER JOIN "Membership" m ON m."teamId" = t.id + WHERE m."userId" = ${userId} + AND m.accepted = true + AND EXISTS ( + SELECT 1 + FROM "TeamFeatures" tf + WHERE tf."teamId" = t.id + AND tf."featureId" = ${slug} + ) + LIMIT 1; + `; + + const result = await this.prismaClient.$queryRaw(query); + return result.length > 0; + } catch (err) { + captureException(err); + throw err; + } + } + /** * Enables a feature for a specific team. * @param teamId - The ID of the team to enable the feature for