@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.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/src/models/access-grant-log/access-grant-log.collection.js +26 -0
- package/src/models/access-grant-log/access-grant-log.model.js +31 -0
- package/src/models/access-grant-log/access-grant-log.schema.js +62 -0
- package/src/models/access-grant-log/index.d.ts +37 -0
- package/src/models/access-grant-log/index.js +15 -0
- package/src/models/base/base.model.js +14 -0
- package/src/models/base/entity.registry.js +12 -0
- package/src/models/index.d.ts +2 -0
- package/src/models/index.js +4 -0
- package/src/models/sentiment-topic/index.d.ts +0 -9
- package/src/models/sentiment-topic/sentiment-topic.schema.js +0 -19
- package/src/models/site/site.schema.js +5 -0
- package/src/models/site-ims-org-access/index.d.ts +65 -0
- package/src/models/site-ims-org-access/index.js +15 -0
- package/src/models/site-ims-org-access/site-ims-org-access.collection.js +190 -0
- package/src/models/site-ims-org-access/site-ims-org-access.model.js +32 -0
- package/src/models/site-ims-org-access/site-ims-org-access.schema.js +63 -0
- package/src/models/suggestion-grant/index.d.ts +30 -0
- package/src/models/suggestion-grant/index.js +19 -0
- package/src/models/suggestion-grant/suggestion-grant.collection.js +177 -0
- package/src/models/suggestion-grant/suggestion-grant.model.js +26 -0
- package/src/models/suggestion-grant/suggestion-grant.schema.js +74 -0
- package/src/models/token/index.js +19 -0
- package/src/models/token/token.collection.js +71 -0
- package/src/models/token/token.model.js +36 -0
- package/src/models/token/token.schema.js +57 -0
- 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
|
+
};
|