@@ -79,11 +79,16 @@ class SiteImsOrgAccessCollection extends BaseCollection {
7979 }
8080
8181 /**
82+ * Shared pagination loop for PostgREST embedding queries. Fetches all pages and maps
83+ * each row using the provided mapper function.
84+ *
8285 * @param {object } query - PostgREST query builder (result of .from(...).select(...))
83- * @returns {Promise<Array<{grant: object, targetOrganization: object}>> }
86+ * @param {Function } mapRow - Maps a raw row to the desired return shape
87+ * @param {string } errorMessage - Used for logging and DataAccessError message
88+ * @returns {Promise<Array> }
8489 * @private
8590 */
86- async #fetchGrantsWithTargetOrg ( query ) {
91+ async #fetchPaginatedGrants ( query , mapRow , errorMessage ) {
8792 const allResults = [ ] ;
8893 let offset = 0 ;
8994 let keepGoing = true ;
@@ -94,9 +99,9 @@ class SiteImsOrgAccessCollection extends BaseCollection {
9499 const { data, error } = await orderedQuery . range ( offset , offset + DEFAULT_PAGE_SIZE - 1 ) ;
95100
96101 if ( error ) {
97- this . log . error ( `[SiteImsOrgAccess] Failed to query grants with target org - ${ error . message } ` , error ) ;
102+ this . log . error ( `[SiteImsOrgAccess] ${ errorMessage } - ${ error . message } ` , error ) ;
98103 throw new DataAccessError (
99- 'Failed to query grants with target organization' ,
104+ errorMessage ,
100105 { entityName : 'SiteImsOrgAccess' , tableName : 'site_ims_org_accesses' } ,
101106 error ,
102107 ) ;
@@ -111,22 +116,23 @@ class SiteImsOrgAccessCollection extends BaseCollection {
111116 }
112117 }
113118
114- return allResults . map ( ( row ) => ( {
115- grant : {
116- id : row . id ,
117- siteId : row . site_id ,
118- organizationId : row . organization_id ,
119- targetOrganizationId : row . target_organization_id ,
120- productCode : row . product_code ,
121- role : row . role ,
122- grantedBy : row . granted_by ,
123- expiresAt : row . expires_at ,
124- } ,
125- targetOrganization : {
126- id : row . organizations . id ,
127- imsOrgId : row . organizations . ims_org_id ,
128- } ,
129- } ) ) ;
119+ return allResults . map ( mapRow ) ;
120+ }
121+
122+ /**
123+ * @param {object } query - PostgREST query builder
124+ * @returns {Promise<Array<{grant: SiteImsOrgAccess, targetOrganization: object}>> }
125+ * @private
126+ */
127+ async #fetchGrantsWithTargetOrg( query ) {
128+ return this . #fetchPaginatedGrants(
129+ query ,
130+ ( row ) => ( {
131+ grant : this . createInstanceFromRow ( row ) ,
132+ targetOrganization : { id : row . organizations . id , imsOrgId : row . organizations . ims_org_id } ,
133+ } ) ,
134+ 'Failed to query grants with target organization' ,
135+ ) ;
130136 }
131137
132138 /**
@@ -185,6 +191,74 @@ class SiteImsOrgAccessCollection extends BaseCollection {
185191 this . postgrestService . from ( 'site_ims_org_accesses' ) . select ( select ) . in ( 'organization_id' , organizationIds ) ,
186192 ) ;
187193 }
194+
195+ /**
196+ * Finds a single grant by the compound key (siteId, organizationId, productCode).
197+ * Used by hasAccess() in the api-service to verify a grant still exists (Path A revocation
198+ * check) or to perform a direct DB lookup when the JWT list was truncated (Path B).
199+ *
200+ * Returns a model instance so callers can use getExpiresAt(), getRole(), etc.
201+ * Returns null when no matching grant exists.
202+ *
203+ * @param {string } siteId - UUID of the site.
204+ * @param {string } organizationId - UUID of the delegate organization.
205+ * @param {string } productCode - Product code (e.g. 'LLMO', 'ASO').
206+ * @returns {Promise<SiteImsOrgAccess|null> }
207+ */
208+ async findBySiteIdAndOrganizationIdAndProductCode ( siteId , organizationId , productCode ) {
209+ if ( ! siteId || ! organizationId || ! productCode ) {
210+ throw new DataAccessError (
211+ 'siteId, organizationId and productCode are required' ,
212+ { entityName : 'SiteImsOrgAccess' , tableName : 'site_ims_org_accesses' } ,
213+ ) ;
214+ }
215+ return this . findByIndexKeys ( { siteId, organizationId, productCode } ) ;
216+ }
217+
218+ /**
219+ * Returns all grants for the given delegate organization with the full site row embedded
220+ * via PostgREST resource embedding (INNER JOIN on site_id FK). This is a single round-trip
221+ * query — no N+1 — suitable for populating the site dropdown for delegated users.
222+ *
223+ * Returns plain objects, not model instances. The `site` field contains the raw PostgREST
224+ * row for the joined site (snake_case column names). It is null only when the FK is broken,
225+ * which should not occur given ON DELETE CASCADE on site_id.
226+ *
227+ * @param {string } organizationId - UUID of the delegate organization.
228+ * @returns {Promise<Array<{
229+ * grant: {id: string, siteId: string, organizationId: string,
230+ * targetOrganizationId: string, productCode: string, role: string,
231+ * grantedBy: string|null, expiresAt: string|null},
232+ * site: object|null
233+ * }>> }
234+ */
235+ async allByOrganizationIdWithSites ( organizationId ) {
236+ if ( ! organizationId ) {
237+ throw new DataAccessError ( 'organizationId is required' , { entityName : 'SiteImsOrgAccess' , tableName : 'site_ims_org_accesses' } ) ;
238+ }
239+ // eslint-disable-next-line max-len
240+ const select = 'id, site_id, organization_id, target_organization_id, product_code, role, granted_by, expires_at, sites!site_ims_org_accesses_site_id_fkey(*)' ;
241+ return this . #fetchGrantsWithSite(
242+ this . postgrestService . from ( 'site_ims_org_accesses' ) . select ( select ) . eq ( 'organization_id' , organizationId ) ,
243+ ) ;
244+ }
245+
246+ /**
247+ * @param {object } query - PostgREST query builder
248+ * @returns {Promise<Array<{grant: SiteImsOrgAccess, site: Site|null}>> }
249+ * @private
250+ */
251+ async #fetchGrantsWithSite( query ) {
252+ const siteCollection = this . entityRegistry . getCollection ( 'SiteCollection' ) ;
253+ return this . #fetchPaginatedGrants(
254+ query ,
255+ ( row ) => ( {
256+ grant : this . createInstanceFromRow ( row ) ,
257+ site : row . sites ? siteCollection . createInstanceFromRow ( row . sites ) : null ,
258+ } ) ,
259+ 'Failed to query grants with site' ,
260+ ) ;
261+ }
188262}
189263
190264export default SiteImsOrgAccessCollection ;
0 commit comments