@adobe/spacecat-shared-data-access 3.23.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/models/access-grant-log/access-grant-log.collection.js +26 -0
  4. package/src/models/access-grant-log/access-grant-log.model.js +31 -0
  5. package/src/models/access-grant-log/access-grant-log.schema.js +62 -0
  6. package/src/models/access-grant-log/index.d.ts +37 -0
  7. package/src/models/access-grant-log/index.js +15 -0
  8. package/src/models/base/base.model.js +14 -0
  9. package/src/models/base/entity.registry.js +12 -0
  10. package/src/models/index.d.ts +2 -0
  11. package/src/models/index.js +4 -0
  12. package/src/models/sentiment-topic/index.d.ts +0 -9
  13. package/src/models/sentiment-topic/sentiment-topic.schema.js +0 -19
  14. package/src/models/site/site.schema.js +5 -0
  15. package/src/models/site-ims-org-access/index.d.ts +65 -0
  16. package/src/models/site-ims-org-access/index.js +15 -0
  17. package/src/models/site-ims-org-access/site-ims-org-access.collection.js +190 -0
  18. package/src/models/site-ims-org-access/site-ims-org-access.model.js +32 -0
  19. package/src/models/site-ims-org-access/site-ims-org-access.schema.js +63 -0
  20. package/src/models/suggestion-grant/index.d.ts +30 -0
  21. package/src/models/suggestion-grant/index.js +19 -0
  22. package/src/models/suggestion-grant/suggestion-grant.collection.js +177 -0
  23. package/src/models/suggestion-grant/suggestion-grant.model.js +26 -0
  24. package/src/models/suggestion-grant/suggestion-grant.schema.js +74 -0
  25. package/src/models/token/index.js +19 -0
  26. package/src/models/token/token.collection.js +71 -0
  27. package/src/models/token/token.model.js +36 -0
  28. package/src/models/token/token.schema.js +57 -0
  29. package/src/util/postgrest.utils.js +1 -0
@@ -0,0 +1,190 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import BaseCollection from '../base/base.collection.js';
14
+ import DataAccessError from '../../errors/data-access.error.js';
15
+ import { DEFAULT_PAGE_SIZE } from '../../util/postgrest.utils.js';
16
+
17
+ /**
18
+ * SiteImsOrgAccessCollection - Collection of cross-org delegation grants.
19
+ * Provides idempotent create and the 50-delegate-per-site limit.
20
+ *
21
+ * @class SiteImsOrgAccessCollection
22
+ * @extends BaseCollection
23
+ */
24
+ class SiteImsOrgAccessCollection extends BaseCollection {
25
+ static COLLECTION_NAME = 'SiteImsOrgAccessCollection';
26
+
27
+ static MAX_DELEGATES_PER_SITE = 50;
28
+
29
+ /**
30
+ * Idempotent create: if a grant already exists for (siteId, organizationId, productCode),
31
+ * return the existing record. Otherwise, enforce the 50-delegate-per-site limit and create.
32
+ * Follows the SiteEnrollment pattern (site-enrollment.collection.js:25-32).
33
+ *
34
+ * Note: the findByIndexKeys + allBySiteId + super.create sequence is not atomic. Concurrent
35
+ * requests can both pass the idempotency check (creating duplicates) or both pass the limit
36
+ * check (exceeding it). A DB-level unique constraint on (siteId, organizationId, productCode)
37
+ * is the authoritative guard against duplicates.
38
+ */
39
+ async create(item, options = {}) {
40
+ if (item?.organizationId && item?.targetOrganizationId
41
+ && item.organizationId === item.targetOrganizationId) {
42
+ const message = 'Cannot create self-delegation: organizationId and targetOrganizationId must differ';
43
+ this.log.warn(`[SiteImsOrgAccess] Self-delegation rejected: org=${item.organizationId}`);
44
+ const err = new DataAccessError(message);
45
+ err.status = 409;
46
+ throw err;
47
+ }
48
+
49
+ if (item?.siteId && item?.organizationId && item?.productCode) {
50
+ const existing = await this.findByIndexKeys({
51
+ siteId: item.siteId,
52
+ organizationId: item.organizationId,
53
+ productCode: item.productCode,
54
+ });
55
+ if (existing) {
56
+ this.log.info(`[SiteImsOrgAccess] Idempotent create: returning existing grant for site=${item.siteId} org=${item.organizationId} product=${item.productCode}`);
57
+ return existing;
58
+ }
59
+ }
60
+
61
+ // Enforce 50-active-delegate-per-site limit; expired grants do not count.
62
+ if (item?.siteId) {
63
+ const allGrants = await this.allBySiteId(item.siteId);
64
+ const activeGrants = allGrants.filter(
65
+ (g) => !g.getExpiresAt() || new Date(g.getExpiresAt()) > new Date(),
66
+ );
67
+ if (activeGrants.length >= SiteImsOrgAccessCollection.MAX_DELEGATES_PER_SITE) {
68
+ const message = `Cannot add delegate: site already has ${activeGrants.length}/${SiteImsOrgAccessCollection.MAX_DELEGATES_PER_SITE} active delegates`;
69
+ this.log.warn(`[SiteImsOrgAccess] Delegate limit reached for site=${item.siteId}`);
70
+ const err = new DataAccessError(message);
71
+ err.status = 409;
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ const created = await super.create(item, options);
77
+ this.log.info(`[SiteImsOrgAccess] New grant created: id=${created.getId()} site=${item.siteId} org=${item.organizationId} product=${item.productCode}`);
78
+ return created;
79
+ }
80
+
81
+ /**
82
+ * @param {object} query - PostgREST query builder (result of .from(...).select(...))
83
+ * @returns {Promise<Array<{grant: object, targetOrganization: object}>>}
84
+ * @private
85
+ */
86
+ async #fetchGrantsWithTargetOrg(query) {
87
+ const allResults = [];
88
+ let offset = 0;
89
+ let keepGoing = true;
90
+ const orderedQuery = query.order('id');
91
+
92
+ while (keepGoing) {
93
+ // eslint-disable-next-line no-await-in-loop
94
+ const { data, error } = await orderedQuery.range(offset, offset + DEFAULT_PAGE_SIZE - 1);
95
+
96
+ if (error) {
97
+ this.log.error(`[SiteImsOrgAccess] Failed to query grants with target org - ${error.message}`, error);
98
+ throw new DataAccessError(
99
+ 'Failed to query grants with target organization',
100
+ { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' },
101
+ error,
102
+ );
103
+ }
104
+
105
+ if (!data || data.length === 0) {
106
+ keepGoing = false;
107
+ } else {
108
+ allResults.push(...data);
109
+ keepGoing = data.length >= DEFAULT_PAGE_SIZE;
110
+ offset += DEFAULT_PAGE_SIZE;
111
+ }
112
+ }
113
+
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
+ }));
130
+ }
131
+
132
+ /**
133
+ * Returns all grants for the given delegate organization with the target organization's
134
+ * id and imsOrgId embedded via PostgREST resource embedding (INNER JOIN). This avoids
135
+ * a separate batch query to resolve target org IMS identifiers.
136
+ *
137
+ * Returns plain objects, not model instances. Access properties directly
138
+ * (e.g., `entry.grant.productCode`, `entry.targetOrganization.imsOrgId`).
139
+ *
140
+ * @param {string} organizationId - UUID of the delegate organization.
141
+ * @returns {Promise<Array<{
142
+ * grant: {id: string, siteId: string, organizationId: string,
143
+ * targetOrganizationId: string, productCode: string, role: string,
144
+ * grantedBy: string|null, expiresAt: string|null},
145
+ * targetOrganization: {id: string, imsOrgId: string}
146
+ * }>>}
147
+ */
148
+ async allByOrganizationIdWithTargetOrganization(organizationId) {
149
+ if (!organizationId) {
150
+ throw new DataAccessError('organizationId is required', { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' });
151
+ }
152
+ // eslint-disable-next-line max-len
153
+ const select = 'id, site_id, organization_id, target_organization_id, product_code, role, granted_by, expires_at, organizations!site_ims_org_accesses_target_organization_id_fkey(id, ims_org_id)';
154
+ return this.#fetchGrantsWithTargetOrg(
155
+ this.postgrestService.from('site_ims_org_accesses').select(select).eq('organization_id', organizationId),
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Bulk variant of allByOrganizationIdWithTargetOrganization. Fetches grants for multiple
161
+ * delegate organizations in a single PostgREST IN query with target org embedding.
162
+ * Returns an empty array when organizationIds is empty.
163
+ *
164
+ * @param {string[]} organizationIds - UUIDs of the delegate organizations.
165
+ * @returns {Promise<Array<{
166
+ * grant: {id: string, siteId: string, organizationId: string,
167
+ * targetOrganizationId: string, productCode: string, role: string,
168
+ * grantedBy: string|null, expiresAt: string|null},
169
+ * targetOrganization: {id: string, imsOrgId: string}
170
+ * }>>}
171
+ */
172
+ async allByOrganizationIdsWithTargetOrganization(organizationIds) {
173
+ if (!organizationIds || organizationIds.length === 0) {
174
+ return [];
175
+ }
176
+ if (organizationIds.length > SiteImsOrgAccessCollection.MAX_DELEGATES_PER_SITE) {
177
+ throw new DataAccessError(
178
+ `allByOrganizationIdsWithTargetOrganization: organizationIds array exceeds maximum of ${SiteImsOrgAccessCollection.MAX_DELEGATES_PER_SITE}`,
179
+ { entityName: 'SiteImsOrgAccess', tableName: 'site_ims_org_accesses' },
180
+ );
181
+ }
182
+ // eslint-disable-next-line max-len
183
+ const select = 'id, site_id, organization_id, target_organization_id, product_code, role, granted_by, expires_at, organizations!site_ims_org_accesses_target_organization_id_fkey(id, ims_org_id)';
184
+ return this.#fetchGrantsWithTargetOrg(
185
+ this.postgrestService.from('site_ims_org_accesses').select(select).in('organization_id', organizationIds),
186
+ );
187
+ }
188
+ }
189
+
190
+ export default SiteImsOrgAccessCollection;
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import BaseModel from '../base/base.model.js';
14
+
15
+ /**
16
+ * SiteImsOrgAccess - Maps delegate orgs to sites they can access, per product.
17
+ * Part of the cross-org delegation feature (Option 2a Phase 1).
18
+ *
19
+ * @class SiteImsOrgAccess
20
+ * @extends BaseModel
21
+ */
22
+ class SiteImsOrgAccess extends BaseModel {
23
+ static ENTITY_NAME = 'SiteImsOrgAccess';
24
+
25
+ static DELEGATION_ROLES = {
26
+ COLLABORATOR: 'collaborator',
27
+ AGENCY: 'agency',
28
+ VIEWER: 'viewer',
29
+ };
30
+ }
31
+
32
+ export default SiteImsOrgAccess;
@@ -0,0 +1,63 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { isIsoDate, isValidUUID } from '@adobe/spacecat-shared-utils';
14
+ import { Entitlement } from '../entitlement/index.js';
15
+ import SchemaBuilder from '../base/schema.builder.js';
16
+ import SiteImsOrgAccess from './site-ims-org-access.model.js';
17
+ import SiteImsOrgAccessCollection from './site-ims-org-access.collection.js';
18
+
19
+ const GRANTED_BY_PATTERN = /^(ims:\S+|slack:\S+|system)$/;
20
+
21
+ const schema = new SchemaBuilder(SiteImsOrgAccess, SiteImsOrgAccessCollection)
22
+ .addReference('belongs_to', 'Site')
23
+ .addReference('belongs_to', 'Organization') // the agency/delegate org receiving access
24
+ // targetOrganizationId is addAttribute (not belongs_to) because SchemaBuilder's
25
+ // belongs_to uses the referenced model name as the FK column prefix (organization_id).
26
+ // We already have belongs_to Organization for the delegate org, so a second belongs_to
27
+ // would conflict. addAttribute with UUID validation achieves the same FK semantics
28
+ // without the naming collision. readOnly: true prevents the setter from being generated
29
+ // since the target org is part of the grant's identity — a different target means a
30
+ // different grant.
31
+ .addAttribute('targetOrganizationId', {
32
+ type: 'string',
33
+ required: true,
34
+ readOnly: true,
35
+ validate: (v) => isValidUUID(v),
36
+ })
37
+ // organizationId (delegate org, from belongs_to) and siteId are also part of the grant's
38
+ // identity and are readOnly by virtue of their belongs_to FK nature.
39
+ .addAttribute('productCode', {
40
+ type: Object.values(Entitlement.PRODUCT_CODES),
41
+ required: true,
42
+ readOnly: true,
43
+ })
44
+ .addAttribute('role', {
45
+ type: Object.values(SiteImsOrgAccess.DELEGATION_ROLES),
46
+ required: true,
47
+ default: SiteImsOrgAccess.DELEGATION_ROLES.AGENCY,
48
+ })
49
+ .addAttribute('grantedBy', {
50
+ type: 'string',
51
+ required: false,
52
+ validate: (v) => !v || GRANTED_BY_PATTERN.test(v),
53
+ })
54
+ .addAttribute('expiresAt', {
55
+ type: 'string',
56
+ required: false,
57
+ validate: (v) => !v || isIsoDate(v),
58
+ })
59
+ .addAllIndex(['organizationId'])
60
+ // Index for "show all delegations granted to org Z across all sites" admin query
61
+ .addAllIndex(['targetOrganizationId']);
62
+
63
+ export default schema.build();
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import type { BaseCollection, BaseModel } from '../index';
14
+
15
+ export interface SuggestionGrant extends BaseModel {
16
+ getGrantId(): string;
17
+ getSuggestionId(): string;
18
+ getSiteId(): string;
19
+ getTokenId(): string;
20
+ getTokenType(): string;
21
+ getGrantedAt(): string;
22
+ }
23
+
24
+ export interface SuggestionGrantCollection extends BaseCollection<SuggestionGrant> {
25
+ findBySuggestionIds(suggestionIds: string[]): Promise<{ data: Array<{ suggestion_id: string; grant_id: string }>; error: object | null }>;
26
+ invokeGrantSuggestionsRpc(suggestionIds: string[], siteId: string, tokenType: string, cycle: string): Promise<{ data: Array | null; error: object | null }>;
27
+ splitSuggestionsByGrantStatus(suggestionIds: string[]): Promise<{ grantedIds: string[]; notGrantedIds: string[]; grantIds: string[] }>;
28
+ isSuggestionGranted(suggestionId: string): Promise<boolean>;
29
+ grantSuggestions(suggestionIds: string[], siteId: string, tokenType: string): Promise<{ success: boolean; reason?: string; grantedSuggestions?: Array }>;
30
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import SuggestionGrant from './suggestion-grant.model.js';
14
+ import SuggestionGrantCollection from './suggestion-grant.collection.js';
15
+
16
+ export {
17
+ SuggestionGrant,
18
+ SuggestionGrantCollection,
19
+ };
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText } from '@adobe/spacecat-shared-utils';
14
+
15
+ import BaseCollection from '../base/base.collection.js';
16
+ import DataAccessError from '../../errors/data-access.error.js';
17
+
18
+ /**
19
+ * SuggestionGrantCollection - Manages SuggestionGrant records (suggestion_grants table).
20
+ * Table is insert-only; inserts happen via grant_suggestions RPC. This collection
21
+ * provides read-only lookup by suggestion IDs.
22
+ *
23
+ * @class SuggestionGrantCollection
24
+ * @extends BaseCollection
25
+ */
26
+ class SuggestionGrantCollection extends BaseCollection {
27
+ static COLLECTION_NAME = 'SuggestionGrantCollection';
28
+
29
+ /**
30
+ * Finds all grant rows for the given suggestion IDs (suggestion_id, grant_id only).
31
+ *
32
+ * @async
33
+ * @param {string[]} suggestionIds - Suggestion IDs to look up.
34
+ * @returns {Promise<Array<{suggestion_id: string, grant_id: string}>>}
35
+ * @throws {DataAccessError} - On query failure.
36
+ */
37
+ async findBySuggestionIds(suggestionIds) {
38
+ if (!Array.isArray(suggestionIds) || suggestionIds.length === 0) {
39
+ return [];
40
+ }
41
+ const { data, error } = await this.postgrestService
42
+ .from(this.tableName)
43
+ .select('suggestion_id,grant_id')
44
+ .in('suggestion_id', suggestionIds);
45
+
46
+ if (error) {
47
+ throw new DataAccessError('Failed to find grants by suggestion IDs', this, error);
48
+ }
49
+
50
+ return data ?? [];
51
+ }
52
+
53
+ /**
54
+ * Invokes the grant_suggestions RPC. Inserts suggestion_grants rows and consumes one token.
55
+ * RPC name and parameter shape live in this collection (suggestion_grants).
56
+ *
57
+ * @async
58
+ * @param {string[]} suggestionIds - Suggestion IDs to grant.
59
+ * @param {string} siteId - Site ID.
60
+ * @param {string} tokenType - Token type.
61
+ * @param {string} cycle - Token cycle (e.g. '2025-01').
62
+ * @returns {Promise<{ data: Array|null, error: object|null }>}
63
+ */
64
+ async invokeGrantSuggestionsRpc(suggestionIds, siteId, tokenType, cycle) {
65
+ return this.postgrestService.rpc('grant_suggestions', {
66
+ p_suggestion_ids: suggestionIds,
67
+ p_site_id: siteId,
68
+ p_token_type: tokenType,
69
+ p_cycle: cycle,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Splits suggestion IDs into those that are granted and those that are not.
75
+ * A suggestion is considered granted if it has at least one row in suggestion_grants.
76
+ *
77
+ * @async
78
+ * @param {string[]} suggestionIds - Suggestion IDs to check.
79
+ * @returns {Promise<{ grantedIds: string[], notGrantedIds: string[], grantIds: string[] }>}
80
+ * @throws {DataAccessError} - On invalid input or query failure.
81
+ */
82
+ async splitSuggestionsByGrantStatus(suggestionIds) {
83
+ if (!Array.isArray(suggestionIds)) {
84
+ throw new DataAccessError('splitSuggestionsByGrantStatus: suggestionIds must be an array', this);
85
+ }
86
+
87
+ const deduped = [...new Set(suggestionIds.filter((id) => hasText(id)))];
88
+
89
+ if (deduped.length === 0) {
90
+ return { grantedIds: [], notGrantedIds: [], grantIds: [] };
91
+ }
92
+
93
+ try {
94
+ const rows = await this.findBySuggestionIds(deduped);
95
+ const grantedIdSet = new Set(rows.map((r) => r.suggestion_id));
96
+ const grantedIds = deduped.filter((id) => grantedIdSet.has(id));
97
+ const notGrantedIds = deduped.filter((id) => !grantedIdSet.has(id));
98
+ const grantIds = [...new Set(rows.map((r) => r.grant_id).filter(Boolean))];
99
+
100
+ return { grantedIds, notGrantedIds, grantIds };
101
+ } catch (err) {
102
+ if (err instanceof DataAccessError) throw err;
103
+ this.log.error('splitSuggestionsByGrantStatus failed', err);
104
+ throw new DataAccessError('Failed to split suggestions by grant status', this, err);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns whether a single suggestion is granted (has at least one row in suggestion_grants).
110
+ *
111
+ * @async
112
+ * @param {string} suggestionId - Suggestion ID to check.
113
+ * @returns {Promise<boolean>} True if the suggestion is granted,
114
+ * false otherwise or if id is empty.
115
+ */
116
+ async isSuggestionGranted(suggestionId) {
117
+ if (!hasText(suggestionId)) return false;
118
+ const { grantedIds } = await this.splitSuggestionsByGrantStatus([suggestionId]);
119
+ return grantedIds.length > 0;
120
+ }
121
+
122
+ /**
123
+ * Grants one or more suggestions by consuming a single token for the given token type.
124
+ * Resolves the current cycle token via TokenCollection#findBySiteIdAndTokenType
125
+ * (which auto-creates if missing), checks that at least one token remains, then calls the
126
+ * grant_suggestions RPC to atomically consume one token and insert suggestion grants for
127
+ * the entire list of IDs.
128
+ *
129
+ * @async
130
+ * @param {string[]} suggestionIds - Suggestion IDs to grant (one token consumed for the list).
131
+ * @param {string} siteId - The site ID that owns the token allocation.
132
+ * @param {string} tokenType - Token type (e.g. 'grant_cwv').
133
+ * @returns {Promise<{ success: boolean, reason?: string, grantedSuggestions?: Array }>}
134
+ * @throws {DataAccessError} - On missing inputs or RPC failure.
135
+ */
136
+ async grantSuggestions(suggestionIds, siteId, tokenType) {
137
+ if (!Array.isArray(suggestionIds) || suggestionIds.some((id) => !hasText(id))) {
138
+ throw new DataAccessError('grantSuggestions: suggestionIds must be an array of non-empty strings', this);
139
+ }
140
+ if (!hasText(siteId)) {
141
+ throw new DataAccessError('grantSuggestions: siteId is required', this);
142
+ }
143
+ if (!hasText(tokenType)) {
144
+ throw new DataAccessError('grantSuggestions: tokenType is required', this);
145
+ }
146
+
147
+ const tokenCollection = this.entityRegistry.getCollection('TokenCollection');
148
+ const token = await tokenCollection.findBySiteIdAndTokenType(siteId, tokenType);
149
+
150
+ if (!token || token.getRemaining() < 1) {
151
+ return { success: false, reason: 'no_tokens' };
152
+ }
153
+
154
+ const cycle = token.getCycle();
155
+ const rpcResult = await this.invokeGrantSuggestionsRpc(
156
+ suggestionIds,
157
+ siteId,
158
+ tokenType,
159
+ cycle,
160
+ );
161
+ const { data, error } = rpcResult;
162
+
163
+ if (error) {
164
+ this.log.error('grantSuggestions: RPC failed', error);
165
+ throw new DataAccessError('Failed to grant suggestions (grant_suggestions)', this, error);
166
+ }
167
+
168
+ const row = Array.isArray(data) && data.length > 0 ? data[0] : null;
169
+ if (!row || !row.success) {
170
+ return { success: false, reason: row?.reason || 'rpc_no_result' };
171
+ }
172
+
173
+ return { success: true, grantedSuggestions: row.granted_suggestions };
174
+ }
175
+ }
176
+
177
+ export default SuggestionGrantCollection;
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import BaseModel from '../base/base.model.js';
14
+
15
+ /**
16
+ * SuggestionGrant - Record that a suggestion was granted (has a row in suggestion_grants).
17
+ * Table is insert-only; rows are created via grant_suggestions RPC.
18
+ *
19
+ * @class SuggestionGrant
20
+ * @extends BaseModel
21
+ */
22
+ class SuggestionGrant extends BaseModel {
23
+ static ENTITY_NAME = 'SuggestionGrant';
24
+ }
25
+
26
+ export default SuggestionGrant;
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { isValidUUID } from '@adobe/spacecat-shared-utils';
14
+
15
+ import SchemaBuilder from '../base/schema.builder.js';
16
+ import SuggestionGrant from './suggestion-grant.model.js';
17
+ import SuggestionGrantCollection from './suggestion-grant.collection.js';
18
+
19
+ /*
20
+ * SuggestionGrant: suggestion_grants table (insert-only via grant_suggestions RPC).
21
+ * Columns: id, grant_id, suggestion_id, site_id, token_id, token_type, created_at, granted_at.
22
+ * Table has no updated_at/updated_by; those defaults are ignored for PostgREST.
23
+ */
24
+
25
+ const schema = new SchemaBuilder(SuggestionGrant, SuggestionGrantCollection)
26
+ .addAttribute('updatedAt', {
27
+ type: 'string', required: true, readOnly: true, postgrestIgnore: true,
28
+ })
29
+ .addAttribute('updatedBy', {
30
+ type: 'string', required: false, postgrestIgnore: true,
31
+ })
32
+ .addIndex({ composite: ['suggestionId'] }, { composite: [] })
33
+ .addAttribute('suggestionId', {
34
+ type: 'string',
35
+ required: true,
36
+ readOnly: true,
37
+ validate: (value) => isValidUUID(value),
38
+ postgrestField: 'suggestion_id',
39
+ })
40
+ .addAttribute('grantId', {
41
+ type: 'string',
42
+ required: true,
43
+ readOnly: true,
44
+ validate: (value) => isValidUUID(value),
45
+ postgrestField: 'grant_id',
46
+ })
47
+ .addAttribute('siteId', {
48
+ type: 'string',
49
+ required: true,
50
+ readOnly: true,
51
+ validate: (value) => isValidUUID(value),
52
+ postgrestField: 'site_id',
53
+ })
54
+ .addAttribute('tokenId', {
55
+ type: 'string',
56
+ required: true,
57
+ readOnly: true,
58
+ validate: (value) => isValidUUID(value),
59
+ postgrestField: 'token_id',
60
+ })
61
+ .addAttribute('tokenType', {
62
+ type: 'string',
63
+ required: true,
64
+ readOnly: true,
65
+ postgrestField: 'token_type',
66
+ })
67
+ .addAttribute('grantedAt', {
68
+ type: 'string',
69
+ required: true,
70
+ readOnly: true,
71
+ postgrestField: 'granted_at',
72
+ });
73
+
74
+ export default schema.build();
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import Token from './token.model.js';
14
+ import TokenCollection from './token.collection.js';
15
+
16
+ export {
17
+ Token,
18
+ TokenCollection,
19
+ };