From 8796678ca0e7b5e6b66493aa32e8055af0b14f5b Mon Sep 17 00:00:00 2001 From: Jagadish Date: Mon, 13 Jul 2026 15:11:37 +0530 Subject: [PATCH 1/4] removed extra debounce --- .../pages/CampaignListing.fetcher.ts | 5 +- src/services/api/SupabaseApi.ts | 169 ++++++++++++++---- 2 files changed, 141 insertions(+), 33 deletions(-) diff --git a/src/ops-console/pages/CampaignListing.fetcher.ts b/src/ops-console/pages/CampaignListing.fetcher.ts index ea62f8c144..789a772867 100644 --- a/src/ops-console/pages/CampaignListing.fetcher.ts +++ b/src/ops-console/pages/CampaignListing.fetcher.ts @@ -140,7 +140,6 @@ export const useCampaignListingPageState = (api: ServiceApi) => { const [searchTerm, setSearchTerm] = useState( () => queryParams.get('search') || '', ); - const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); const [page, setPage] = useState(() => { const pageParam = Number.parseInt(queryParams.get('page') || '', 10); return Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam; @@ -186,8 +185,8 @@ export const useCampaignListingPageState = (api: ServiceApi) => { pageSize: CAMPAIGN_LISTING_PAGE_SIZE, orderBy: sortBy, orderDir: sortOrder, - // Debounce keystrokes so the listing does not hit Supabase on every character typed. - searchTerm: debouncedSearchTerm, + // SearchAndFilter already debounces input, so send the settled value straight through. + searchTerm, }); useEffect(() => { diff --git a/src/services/api/SupabaseApi.ts b/src/services/api/SupabaseApi.ts index 608d861abe..2947a6be8f 100644 --- a/src/services/api/SupabaseApi.ts +++ b/src/services/api/SupabaseApi.ts @@ -9813,6 +9813,9 @@ export class SupabaseApi implements ServiceApi { const supabase = this.supabase; const normalizedSearchTerm = searchTerm.trim(); const now = new Date(); + const requiresMetricsForSort = + orderBy === 'avgWeeklyActiveUsers' || + orderBy === 'avgWeeklyEngagementTimeMinutes'; const fetchCampaignListingMetrics = async ( campaignIds: string[], ): Promise> => { @@ -9847,6 +9850,42 @@ export class SupabaseApi implements ServiceApi { return new Map(); } }; + const fetchCampaignListingDetails = async ( + campaignIds: string[], + ): Promise> => { + if (campaignIds.length === 0) { + return new Map(); + } + + try { + const { data: campaignRows, error: campaignRowsError } = await supabase + .from('campaign') + .select('*, manager:manager_id(*), program:program_id(*)') + .in('id', campaignIds) + .eq('is_deleted', false); + + if (campaignRowsError) { + logger.error( + 'Error fetching campaign listing detail rows:', + campaignRowsError, + ); + return new Map(); + } + + return new Map( + ((campaignRows ?? []) as CampaignListingQueryRow[]).map((row) => [ + row.id, + row, + ]), + ); + } catch (campaignRowsError) { + logger.error( + 'Unexpected error fetching campaign listing detail rows:', + campaignRowsError, + ); + return new Map(); + } + }; try { const { @@ -9986,12 +10025,9 @@ export class SupabaseApi implements ServiceApi { const nativeSortColumn = CAMPAIGN_LISTING_NATIVE_SORT_COLUMNS[orderBy]; const shouldUseDatabasePagination = !isFieldCoordinator && Boolean(nativeSortColumn); - const campaignListingSelect = `*, manager:manager_id(*), program:program_id(*), - target_audience:target_audience_id( - id, - is_all_schools, - campaign_target_audience_school(school_id) - )`; + const campaignListingSelect = isFieldCoordinator + ? '*,target_audience:target_audience_id(id,is_all_schools,campaign_target_audience_school(school_id))' + : '*'; const campaignQuery = this.supabase .from('campaign') @@ -10029,7 +10065,8 @@ export class SupabaseApi implements ServiceApi { return { data: [], totalCount: 0 }; } - const mappedCampaigns = (data ?? []) as CampaignListingQueryRow[]; + const mappedCampaigns = (data ?? + []) as unknown as CampaignListingQueryRow[]; // Apply field-coordinator visibility after the base campaign query so audience links can be inspected. const visibleCampaigns = shouldUseDatabasePagination @@ -10065,36 +10102,108 @@ export class SupabaseApi implements ServiceApi { const currentPage = Math.max(page, 1); const currentPageSize = Math.max(pageSize, 1); const from = (currentPage - 1) * currentPageSize; + const visibleCampaignIds = visibleCampaigns.map( + (campaign) => campaign.id, + ); + const visibleCampaignMap = new Map( + visibleCampaigns.map((campaign) => [campaign.id, campaign]), + ); let listingItems: CampaignListingItem[] = []; let totalCount = 0; - const campaignMetricsMap = await fetchCampaignListingMetrics( - visibleCampaigns.map((campaign) => campaign.id), - ); if (shouldUseDatabasePagination) { - listingItems = visibleCampaigns.map((campaign) => - mapCampaignListingItem( - campaign, - now, - campaignMetricsMap.get(campaign.id) ?? null, - ), - ); + const campaignDetailMap = + await fetchCampaignListingDetails(visibleCampaignIds); + const campaignMetricsMap = + await fetchCampaignListingMetrics(visibleCampaignIds); + listingItems = visibleCampaignIds + .map((campaignId) => { + const resolvedCampaign = + campaignDetailMap.get(campaignId) ?? + visibleCampaignMap.get(campaignId); + if (!resolvedCampaign) return null; + + return mapCampaignListingItem( + resolvedCampaign, + now, + campaignMetricsMap.get(campaignId) ?? null, + ); + }) + .filter( + (campaign): campaign is CampaignListingItem => campaign !== null, + ); totalCount = count ?? 0; } else { - const visibleListingItems = visibleCampaigns.map((campaign) => - mapCampaignListingItem( - campaign, - now, - campaignMetricsMap.get(campaign.id) ?? null, - ), - ); - totalCount = visibleListingItems.length; - listingItems = sortCampaignListingItems( - visibleListingItems, - orderBy, - orderDir, - ).slice(from, from + currentPageSize); + const campaignDetailMap = + await fetchCampaignListingDetails(visibleCampaignIds); + const resolveCampaignRow = (campaignId: string) => + campaignDetailMap.get(campaignId) ?? + visibleCampaignMap.get(campaignId); + + if (requiresMetricsForSort) { + const campaignMetricsMap = + await fetchCampaignListingMetrics(visibleCampaignIds); + const visibleListingItems = visibleCampaignIds + .map((campaignId) => { + const resolvedCampaign = resolveCampaignRow(campaignId); + if (!resolvedCampaign) return null; + + return mapCampaignListingItem( + resolvedCampaign, + now, + campaignMetricsMap.get(campaignId) ?? null, + ); + }) + .filter( + (campaign): campaign is CampaignListingItem => campaign !== null, + ); + + totalCount = visibleListingItems.length; + listingItems = sortCampaignListingItems( + visibleListingItems, + orderBy, + orderDir, + ).slice(from, from + currentPageSize); + } else { + const visibleListingItems = visibleCampaignIds + .map((campaignId) => { + const resolvedCampaign = resolveCampaignRow(campaignId); + if (!resolvedCampaign) return null; + + return mapCampaignListingItem(resolvedCampaign, now, null); + }) + .filter( + (campaign): campaign is CampaignListingItem => campaign !== null, + ); + + totalCount = visibleListingItems.length; + const pagedListingItems = sortCampaignListingItems( + visibleListingItems, + orderBy, + orderDir, + ).slice(from, from + currentPageSize); + const pageCampaignIds = pagedListingItems.map( + (campaign) => campaign.campaignId, + ); + const pageMetricsMap = + await fetchCampaignListingMetrics(pageCampaignIds); + + listingItems = pagedListingItems + .map((campaign) => { + const resolvedCampaign = resolveCampaignRow(campaign.campaignId); + if (!resolvedCampaign) return null; + + return mapCampaignListingItem( + resolvedCampaign, + now, + pageMetricsMap.get(campaign.campaignId) ?? null, + ); + }) + .filter( + (campaign): campaign is CampaignListingItem => campaign !== null, + ); + } } return { From b8f4a81d0602bb83be0ca73f9e9c68a9c9f54611 Mon Sep 17 00:00:00 2001 From: Jagadish Date: Mon, 13 Jul 2026 16:15:11 +0530 Subject: [PATCH 2/4] reduce debounce time --- src/ops-console/components/SearchAndFilter.tsx | 6 ++++-- src/ops-console/pages/CampaignListingPage.tsx | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ops-console/components/SearchAndFilter.tsx b/src/ops-console/components/SearchAndFilter.tsx index 233be26901..2c203d1264 100644 --- a/src/ops-console/components/SearchAndFilter.tsx +++ b/src/ops-console/components/SearchAndFilter.tsx @@ -24,6 +24,7 @@ interface SearchAndFilterProps { variantType?: 'outlined' | 'standard'; filterIconSrc?: string; searchPlaceholder?: string; + debounceMs?: number; } const DEBOUNCE_MS = 800; @@ -39,6 +40,7 @@ const SearchAndFilter: React.FC = ({ variantType, filterIconSrc, searchPlaceholder, + debounceMs = DEBOUNCE_MS, }) => { const { t } = useTranslation(); const isMobile = useMediaQuery('(max-width: 900px)'); @@ -61,9 +63,9 @@ const SearchAndFilter: React.FC = ({ target: { value: inputValue }, } as React.ChangeEvent); } - }, DEBOUNCE_MS); + }, debounceMs); return () => clearTimeout(handler); - }, [inputValue]); + }, [debounceMs, inputValue, onSearchChange, searchTerm]); const [showMobileSearch, setShowMobileSearch] = useState(forceOpenSearch); diff --git a/src/ops-console/pages/CampaignListingPage.tsx b/src/ops-console/pages/CampaignListingPage.tsx index c418de2852..13a47313bd 100644 --- a/src/ops-console/pages/CampaignListingPage.tsx +++ b/src/ops-console/pages/CampaignListingPage.tsx @@ -137,6 +137,7 @@ const CampaignListingPage: React.FC = () => { isFilter={false} variantType="outlined" searchPlaceholder={String(t('Search Campaigns'))} + debounceMs={250} /> From 0cefe0b0673a4bb4bdc61c33ffd92d6180da8619 Mon Sep 17 00:00:00 2001 From: Jagadish Date: Mon, 13 Jul 2026 16:41:03 +0530 Subject: [PATCH 3/4] refactor query --- src/services/api/SupabaseApi.ts | 60 +++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/src/services/api/SupabaseApi.ts b/src/services/api/SupabaseApi.ts index 2947a6be8f..6da332661d 100644 --- a/src/services/api/SupabaseApi.ts +++ b/src/services/api/SupabaseApi.ts @@ -379,6 +379,27 @@ const CAMPAIGN_LISTING_NATIVE_SORT_COLUMNS: Partial< endDate: 'end_date', }; +const CAMPAIGN_LISTING_DETAIL_SELECT = [ + 'id', + 'name', + 'objective', + 'start_date', + 'end_date', + 'updated_at', + 'campaign_status', + 'comments', + 'manager_id', + 'program_id', + 'target_audience_id', + 'manager:manager_id(name)', + 'program:program_id(name, institutes_count, students_count)', +].join(','); + +const CAMPAIGN_LISTING_VISIBILITY_SELECT = [ + CAMPAIGN_LISTING_DETAIL_SELECT, + 'target_audience:target_audience_id(id,is_all_schools,campaign_target_audience_school(school_id))', +].join(','); + type CampaignSchoolRow = Pick, 'id' | 'name' | 'group3'>; type CampaignGradeRow = Pick, 'id' | 'name' | 'sort_index'>; @@ -9860,7 +9881,7 @@ export class SupabaseApi implements ServiceApi { try { const { data: campaignRows, error: campaignRowsError } = await supabase .from('campaign') - .select('*, manager:manager_id(*), program:program_id(*)') + .select(CAMPAIGN_LISTING_DETAIL_SELECT) .in('id', campaignIds) .eq('is_deleted', false); @@ -9873,10 +9894,9 @@ export class SupabaseApi implements ServiceApi { } return new Map( - ((campaignRows ?? []) as CampaignListingQueryRow[]).map((row) => [ - row.id, - row, - ]), + ((campaignRows ?? []) as unknown as CampaignListingQueryRow[]).map( + (row) => [row.id, row], + ), ); } catch (campaignRowsError) { logger.error( @@ -10025,9 +10045,11 @@ export class SupabaseApi implements ServiceApi { const nativeSortColumn = CAMPAIGN_LISTING_NATIVE_SORT_COLUMNS[orderBy]; const shouldUseDatabasePagination = !isFieldCoordinator && Boolean(nativeSortColumn); - const campaignListingSelect = isFieldCoordinator - ? '*,target_audience:target_audience_id(id,is_all_schools,campaign_target_audience_school(school_id))' - : '*'; + const campaignListingSelect = shouldUseDatabasePagination + ? 'id' + : isFieldCoordinator + ? CAMPAIGN_LISTING_VISIBILITY_SELECT + : CAMPAIGN_LISTING_DETAIL_SELECT; const campaignQuery = this.supabase .from('campaign') @@ -10113,10 +10135,10 @@ export class SupabaseApi implements ServiceApi { let totalCount = 0; if (shouldUseDatabasePagination) { - const campaignDetailMap = - await fetchCampaignListingDetails(visibleCampaignIds); - const campaignMetricsMap = - await fetchCampaignListingMetrics(visibleCampaignIds); + const [campaignDetailMap, campaignMetricsMap] = await Promise.all([ + fetchCampaignListingDetails(visibleCampaignIds), + fetchCampaignListingMetrics(visibleCampaignIds), + ]); listingItems = visibleCampaignIds .map((campaignId) => { const resolvedCampaign = @@ -10135,18 +10157,12 @@ export class SupabaseApi implements ServiceApi { ); totalCount = count ?? 0; } else { - const campaignDetailMap = - await fetchCampaignListingDetails(visibleCampaignIds); - const resolveCampaignRow = (campaignId: string) => - campaignDetailMap.get(campaignId) ?? - visibleCampaignMap.get(campaignId); - if (requiresMetricsForSort) { const campaignMetricsMap = await fetchCampaignListingMetrics(visibleCampaignIds); const visibleListingItems = visibleCampaignIds .map((campaignId) => { - const resolvedCampaign = resolveCampaignRow(campaignId); + const resolvedCampaign = visibleCampaignMap.get(campaignId); if (!resolvedCampaign) return null; return mapCampaignListingItem( @@ -10168,7 +10184,7 @@ export class SupabaseApi implements ServiceApi { } else { const visibleListingItems = visibleCampaignIds .map((campaignId) => { - const resolvedCampaign = resolveCampaignRow(campaignId); + const resolvedCampaign = visibleCampaignMap.get(campaignId); if (!resolvedCampaign) return null; return mapCampaignListingItem(resolvedCampaign, now, null); @@ -10191,7 +10207,9 @@ export class SupabaseApi implements ServiceApi { listingItems = pagedListingItems .map((campaign) => { - const resolvedCampaign = resolveCampaignRow(campaign.campaignId); + const resolvedCampaign = visibleCampaignMap.get( + campaign.campaignId, + ); if (!resolvedCampaign) return null; return mapCampaignListingItem( From d9773993abc797a1e744f281b9611513b0fd0ee8 Mon Sep 17 00:00:00 2001 From: Jagadish Date: Mon, 13 Jul 2026 17:29:18 +0530 Subject: [PATCH 4/4] cache campaign listing access lookups --- src/services/api/SupabaseApi.ts | 194 ++++++++++++++++++++++---------- 1 file changed, 134 insertions(+), 60 deletions(-) diff --git a/src/services/api/SupabaseApi.ts b/src/services/api/SupabaseApi.ts index 6da332661d..bf37a67b9e 100644 --- a/src/services/api/SupabaseApi.ts +++ b/src/services/api/SupabaseApi.ts @@ -400,6 +400,15 @@ const CAMPAIGN_LISTING_VISIBILITY_SELECT = [ 'target_audience:target_audience_id(id,is_all_schools,campaign_target_audience_school(school_id))', ].join(','); +const CAMPAIGN_LISTING_ACCESS_CACHE_TTL_MS = 5 * 60 * 1000; + +type CachedCampaignAccessScope = { + accessibleProgramIds: Set; + accessibleSchoolIds: Set; + fieldCoordinatorProgramIds: Set; + fetchedAt: number; +}; + type CampaignSchoolRow = Pick, 'id' | 'name' | 'group3'>; type CampaignGradeRow = Pick, 'id' | 'name' | 'sort_index'>; @@ -791,6 +800,14 @@ export class SupabaseApi implements ServiceApi { private _currentCourse: | Map | undefined> | undefined; + private _specialRolesCache = new Map< + string, + { roles: string[]; fetchedAt: number } + >(); + private _campaignAccessScopeCache = new Map< + string, + CachedCampaignAccessScope + >(); public static getInstance(): SupabaseApi { if (!SupabaseApi.i) { @@ -9816,6 +9833,99 @@ export class SupabaseApi implements ServiceApi { }; } + private isCampaignListingAccessCacheFresh(fetchedAt: number) { + return Date.now() - fetchedAt < CAMPAIGN_LISTING_ACCESS_CACHE_TTL_MS; + } + + private async getCampaignAccessScope( + userId: string, + ): Promise { + const cachedAccessScope = this._campaignAccessScopeCache.get(userId); + if ( + cachedAccessScope && + this.isCampaignListingAccessCacheFresh(cachedAccessScope.fetchedAt) + ) { + return cachedAccessScope; + } + + if (!this.supabase) { + return { + accessibleProgramIds: new Set(), + accessibleSchoolIds: new Set(), + fieldCoordinatorProgramIds: new Set(), + fetchedAt: Date.now(), + }; + } + + const [schoolAccessResult, programAccessResult] = await Promise.all([ + this.supabase + .from(TABLES.SchoolUser) + .select('school_id, school:school_id(program_id)') + .eq('user_id', userId) + .eq('is_deleted', false), + this.supabase + .from(TABLES.ProgramUser) + .select('program_id') + .eq('user', userId) + .eq('role', RoleType.FIELD_COORDINATOR) + .eq('is_deleted', false), + ]); + + if (schoolAccessResult.error) { + logger.error( + 'Error fetching school_user campaign access list:', + schoolAccessResult.error, + ); + return { + accessibleProgramIds: new Set(), + accessibleSchoolIds: new Set(), + fieldCoordinatorProgramIds: new Set(), + fetchedAt: Date.now(), + }; + } + + if (programAccessResult.error) { + logger.error( + 'Error fetching program_user campaign access list:', + programAccessResult.error, + ); + return { + accessibleProgramIds: new Set(), + accessibleSchoolIds: new Set(), + fieldCoordinatorProgramIds: new Set(), + fetchedAt: Date.now(), + }; + } + + const accessibleSchoolIds = new Set( + ((schoolAccessResult.data ?? []) as CampaignAccessSchoolRow[]) + .map((row) => row.school_id) + .filter((id): id is string => !!id), + ); + const programIdsFromSchools = ( + (schoolAccessResult.data ?? []) as CampaignAccessSchoolRow[] + ) + .map((row) => getSingleRelationValue(row.school)?.program_id ?? null) + .filter((id): id is string => !!id); + const fieldCoordinatorProgramIds = new Set( + (programAccessResult.data ?? []) + .map((row) => row.program_id) + .filter((id): id is string => !!id), + ); + const accessScope: CachedCampaignAccessScope = { + accessibleProgramIds: new Set([ + ...programIdsFromSchools, + ...fieldCoordinatorProgramIds, + ]), + accessibleSchoolIds, + fieldCoordinatorProgramIds, + fetchedAt: Date.now(), + }; + + this._campaignAccessScopeCache.set(userId, accessScope); + return accessScope; + } + async getCampaignListing({ page = 1, pageSize = 10, @@ -9931,66 +10041,17 @@ export class SupabaseApi implements ServiceApi { !hasGlobalCampaignAccess; // Field coordinators can only see campaigns tied to their linked schools/programs. - const [schoolAccessResult, programAccessResult] = isFieldCoordinator - ? await Promise.all([ - supabase - .from(TABLES.SchoolUser) - .select('school_id, school:school_id(program_id)') - .eq('user_id', authUser.id) - .eq('is_deleted', false), - supabase - .from(TABLES.ProgramUser) - .select('program_id') - .eq('user', authUser.id) - .eq('role', RoleType.FIELD_COORDINATOR) - .eq('is_deleted', false), - ]) - : [ - { - data: [] as CampaignAccessSchoolRow[], - error: null, - }, - { - data: [] as Array<{ program_id?: string | null }>, - error: null, - }, - ]; - - if (schoolAccessResult.error) { - logger.error( - 'Error fetching school_user campaign access list:', - schoolAccessResult.error, - ); - return { data: [], totalCount: 0 }; - } - - if (programAccessResult.error) { - logger.error( - 'Error fetching program_user campaign access list:', - programAccessResult.error, - ); - return { data: [], totalCount: 0 }; - } - - const accessibleSchoolIds = new Set( - ((schoolAccessResult.data ?? []) as CampaignAccessSchoolRow[]) - .map((row) => row.school_id) - .filter((id): id is string => !!id), - ); - const programIdsFromSchools = ( - (schoolAccessResult.data ?? []) as CampaignAccessSchoolRow[] - ) - .map((row) => getSingleRelationValue(row.school)?.program_id ?? null) - .filter((id): id is string => !!id); - const fieldCoordinatorProgramIds = new Set( - (programAccessResult.data ?? []) - .map((row) => row.program_id) - .filter((id): id is string => !!id), - ); - const accessibleProgramIds = new Set([ - ...programIdsFromSchools, - ...fieldCoordinatorProgramIds, - ]); + const { + accessibleProgramIds, + accessibleSchoolIds, + fieldCoordinatorProgramIds, + } = isFieldCoordinator + ? await this.getCampaignAccessScope(authUser.id) + : { + accessibleProgramIds: new Set(), + accessibleSchoolIds: new Set(), + fieldCoordinatorProgramIds: new Set(), + }; if ( isFieldCoordinator && @@ -13321,6 +13382,14 @@ export class SupabaseApi implements ServiceApi { return []; } + const cachedRoles = this._specialRolesCache.get(userId); + if ( + cachedRoles && + this.isCampaignListingAccessCacheFresh(cachedRoles.fetchedAt) + ) { + return cachedRoles.roles; + } + try { const { data, error } = await this.supabase .from('special_users') @@ -13344,6 +13413,11 @@ export class SupabaseApi implements ServiceApi { .map((item) => item.role) .filter((role): role is NonNullable => role !== null); + this._specialRolesCache.set(userId, { + roles, + fetchedAt: Date.now(), + }); + return roles; } catch (e) { logger.error('Unexpected error while fetching user special roles:', e);