@adobe/spacecat-shared-data-access 3.22.0 → 3.24.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [@adobe/spacecat-shared-data-access-v3.24.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.23.0...@adobe/spacecat-shared-data-access-v3.24.0) (2026-03-19)
2
+
3
+ ### Features
4
+
5
+ * SITES-40623 - token system in Spacecat ([#1414](https://github.com/adobe/spacecat-shared/issues/1414)) ([9c540ba](https://github.com/adobe/spacecat-shared/commit/9c540babf47984e15737bde509b54df9b5233c54))
6
+
7
+ ## [@adobe/spacecat-shared-data-access-v3.23.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.22.0...@adobe/spacecat-shared-data-access-v3.23.0) (2026-03-18)
8
+
9
+ ### Features
10
+
11
+ * add cited-analsys as audit types ([#1444](https://github.com/adobe/spacecat-shared/issues/1444)) ([c87bf2e](https://github.com/adobe/spacecat-shared/commit/c87bf2e0d3ad77589fa44a1f9b9638ce0deaab53))
12
+
1
13
  ## [@adobe/spacecat-shared-data-access-v3.22.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.21.0...@adobe/spacecat-shared-data-access-v3.22.0) (2026-03-17)
2
14
 
3
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "3.22.0",
3
+ "version": "3.24.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@adobe/fetch": "^4.2.3",
44
- "@adobe/spacecat-shared-utils": "1.101.0",
44
+ "@adobe/spacecat-shared-utils": "1.105.0",
45
45
  "@supabase/postgrest-js": "2.99.1",
46
46
  "@aws-sdk/client-s3": "^3.940.0",
47
47
  "@types/joi": "17.2.3",
@@ -86,6 +86,7 @@ class Audit extends BaseModel {
86
86
  WIKIPEDIA_ANALYSIS: 'wikipedia-analysis',
87
87
  REDDIT_ANALYSIS: 'reddit-analysis',
88
88
  YOUTUBE_ANALYSIS: 'youtube-analysis',
89
+ CITED_ANALYSIS: 'cited-analysis',
89
90
  COMMERCE_PRODUCT_ENRICHMENTS: 'commerce-product-enrichments',
90
91
  COMMERCE_PRODUCT_ENRICHMENTS_YEARLY: 'commerce-product-enrichments-yearly',
91
92
  COMMERCE_PRODUCT_PAGE_ENRICHMENT: 'commerce-product-page-enrichment',
@@ -38,10 +38,12 @@ import SiteEnrollmentCollection from '../site-enrollment/site-enrollment.collect
38
38
  import SiteTopFormCollection from '../site-top-form/site-top-form.collection.js';
39
39
  import SiteTopPageCollection from '../site-top-page/site-top-page.collection.js';
40
40
  import SuggestionCollection from '../suggestion/suggestion.collection.js';
41
+ import SuggestionGrantCollection from '../suggestion-grant/suggestion-grant.collection.js';
41
42
  import PageIntentCollection from '../page-intent/page-intent.collection.js';
42
43
  import ReportCollection from '../report/report.collection.js';
43
44
  import TrialUserCollection from '../trial-user/trial-user.collection.js';
44
45
  import TrialUserActivityCollection from '../trial-user-activity/trial-user-activity.collection.js';
46
+ import TokenCollection from '../token/token.collection.js';
45
47
  import PageCitabilityCollection from '../page-citability/page-citability.collection.js';
46
48
  import PlgOnboardingCollection from '../plg-onboarding/plg-onboarding.collection.js';
47
49
  import SentimentGuidelineCollection from '../sentiment-guideline/sentiment-guideline.collection.js';
@@ -71,10 +73,12 @@ import SiteEnrollmentSchema from '../site-enrollment/site-enrollment.schema.js';
71
73
  import SiteTopFormSchema from '../site-top-form/site-top-form.schema.js';
72
74
  import SiteTopPageSchema from '../site-top-page/site-top-page.schema.js';
73
75
  import SuggestionSchema from '../suggestion/suggestion.schema.js';
76
+ import SuggestionGrantSchema from '../suggestion-grant/suggestion-grant.schema.js';
74
77
  import PageIntentSchema from '../page-intent/page-intent.schema.js';
75
78
  import ReportSchema from '../report/report.schema.js';
76
79
  import TrialUserSchema from '../trial-user/trial-user.schema.js';
77
80
  import TrialUserActivitySchema from '../trial-user-activity/trial-user-activity.schema.js';
81
+ import TokenSchema from '../token/token.schema.js';
78
82
  import PageCitabilitySchema from '../page-citability/page-citability.schema.js';
79
83
  import PlgOnboardingSchema from '../plg-onboarding/plg-onboarding.schema.js';
80
84
  import SentimentGuidelineSchema from '../sentiment-guideline/sentiment-guideline.schema.js';
@@ -199,10 +203,12 @@ EntityRegistry.registerEntity(SiteEnrollmentSchema, SiteEnrollmentCollection);
199
203
  EntityRegistry.registerEntity(SiteTopFormSchema, SiteTopFormCollection);
200
204
  EntityRegistry.registerEntity(SiteTopPageSchema, SiteTopPageCollection);
201
205
  EntityRegistry.registerEntity(SuggestionSchema, SuggestionCollection);
206
+ EntityRegistry.registerEntity(SuggestionGrantSchema, SuggestionGrantCollection);
202
207
  EntityRegistry.registerEntity(PageIntentSchema, PageIntentCollection);
203
208
  EntityRegistry.registerEntity(ReportSchema, ReportCollection);
204
209
  EntityRegistry.registerEntity(TrialUserSchema, TrialUserCollection);
205
210
  EntityRegistry.registerEntity(TrialUserActivitySchema, TrialUserActivityCollection);
211
+ EntityRegistry.registerEntity(TokenSchema, TokenCollection);
206
212
  EntityRegistry.registerEntity(PageCitabilitySchema, PageCitabilityCollection);
207
213
  EntityRegistry.registerEntity(PlgOnboardingSchema, PlgOnboardingCollection);
208
214
  EntityRegistry.registerEntity(SentimentGuidelineSchema, SentimentGuidelineCollection);
@@ -36,10 +36,12 @@ export * from './site-top-form/index.js';
36
36
  export * from './site-top-page/index.js';
37
37
  export * from './site/index.js';
38
38
  export * from './suggestion/index.js';
39
+ export * from './suggestion-grant/index.js';
39
40
  export * from './page-intent/index.js';
40
41
  export * from './report/index.js';
41
42
  export * from './trial-user/index.js';
42
43
  export * from './trial-user-activity/index.js';
44
+ export * from './token/index.js';
43
45
  export * from './page-citability/index.js';
44
46
  export * from './plg-onboarding/index.js';
45
47
  export * from './sentiment-guideline/index.js';
@@ -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
+ };
@@ -0,0 +1,71 @@
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, getTokenGrantConfig } 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
+ * TokenCollection - Manages Token entities (per-site, per-tokenType, per-cycle).
20
+ * Uses PostgREST table `tokens`.
21
+ *
22
+ * @class TokenCollection
23
+ * @extends BaseCollection
24
+ */
25
+ class TokenCollection extends BaseCollection {
26
+ static COLLECTION_NAME = 'TokenCollection';
27
+
28
+ /**
29
+ * Finds a Token for the current cycle by siteId and tokenType. The cycle is
30
+ * derived from the token-grant-config's cycleFormat. If no token exists for
31
+ * the current cycle and options.createIfNotFound is true, creates one. The
32
+ * token total is the minimum of options.total (if supplied) and the config
33
+ * tokensPerCycle, clamped to at least 1.
34
+ *
35
+ * @param {string} siteId - Site ID (UUID).
36
+ * @param {string} tokenType - Token type (e.g. grant_cwv,
37
+ * grant_broken_backlinks).
38
+ * @param {Object} [options={}] - Options.
39
+ * @param {boolean} [options.createIfNotFound=false] - If true, create a token when none exists.
40
+ * @param {number} [options.total] - Optional supplied total;
41
+ * actual total is min(options.total, config.tokensPerCycle), at least 1.
42
+ * @returns {Promise<import('./token.model.js').default|null>} Token instance
43
+ * (existing or newly created), or null when none exists and createIfNotFound is false.
44
+ */
45
+ async findBySiteIdAndTokenType(siteId, tokenType, options = {}) {
46
+ if (!hasText(siteId) || !hasText(tokenType)) {
47
+ throw new DataAccessError('TokenCollection.findBySiteIdAndTokenType: siteId and tokenType are required');
48
+ }
49
+ const config = getTokenGrantConfig(tokenType);
50
+ if (!config) {
51
+ throw new DataAccessError(`TokenCollection.findBySiteIdAndTokenType: no token grant config for tokenType: ${tokenType}`);
52
+ }
53
+ const { currentCycle: cycle } = config;
54
+ const existing = await this.findByIndexKeys({ siteId, tokenType, cycle }, { limit: 1 });
55
+ if (existing || options.createIfNotFound !== true) {
56
+ return existing || null;
57
+ }
58
+ const total = options.total != null
59
+ ? Math.max(1, Math.min(Number(options.total), config.tokensPerCycle))
60
+ : config.tokensPerCycle;
61
+ return this.create({
62
+ siteId,
63
+ tokenType,
64
+ cycle,
65
+ total,
66
+ used: 0,
67
+ });
68
+ }
69
+ }
70
+
71
+ export default TokenCollection;
@@ -0,0 +1,36 @@
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
+ * Token - Tracks token allocation and consumption per site, token type (opportunity type),
17
+ * and cycle. Used to enforce per-opportunity monthly (or cycle) limits for granted suggestions.
18
+ *
19
+ * @class Token
20
+ * @extends BaseModel
21
+ */
22
+ class Token extends BaseModel {
23
+ static ENTITY_NAME = 'Token';
24
+
25
+ /**
26
+ * Returns the number of tokens remaining in this cycle (total - used).
27
+ * @returns {number}
28
+ */
29
+ getRemaining() {
30
+ const total = this.getTotal();
31
+ const used = this.getUsed();
32
+ return Math.max(0, total - used);
33
+ }
34
+ }
35
+
36
+ export default Token;
@@ -0,0 +1,57 @@
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 Token from './token.model.js';
17
+ import TokenCollection from './token.collection.js';
18
+
19
+ /*
20
+ * Token entity: per-site, per-tokenType (opportunity type), per-cycle token allocation.
21
+ * Postgres table: tokens with primary key id, unique (site_id, token_type, cycle).
22
+ * Data access: findBySiteIdAndTokenType(siteId, tokenType).
23
+ * Consume: one token per grant_suggestions call (whole list of IDs)
24
+ * via SuggestionGrantCollection.grantSuggestions().
25
+ */
26
+
27
+ const schema = new SchemaBuilder(Token, TokenCollection)
28
+ .addIndex({ composite: ['siteId', 'tokenType'] }, { composite: ['cycle'] })
29
+ .addAttribute('siteId', {
30
+ type: 'string',
31
+ required: true,
32
+ readOnly: true,
33
+ validate: (value) => isValidUUID(value),
34
+ postgrestField: 'site_id',
35
+ })
36
+ .addAttribute('tokenType', {
37
+ type: 'string',
38
+ required: true,
39
+ postgrestField: 'token_type',
40
+ })
41
+ .addAttribute('cycle', {
42
+ type: 'string',
43
+ required: true,
44
+ readOnly: true,
45
+ })
46
+ .addAttribute('total', {
47
+ type: 'number',
48
+ required: true,
49
+ readOnly: true,
50
+ })
51
+ .addAttribute('used', {
52
+ type: 'number',
53
+ required: true,
54
+ default: 0,
55
+ });
56
+
57
+ export default schema.build();
@@ -16,6 +16,7 @@ const DEFAULT_PAGE_SIZE = 1000;
16
16
 
17
17
  const ENTITY_TABLE_OVERRIDES = {
18
18
  LatestAudit: 'audits',
19
+ SuggestionGrant: 'suggestion_grants',
19
20
  };
20
21
 
21
22
  const camelToSnake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();