@adobe/spacecat-shared-data-access 4.8.0 → 4.10.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-v4.10.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.9.0...@adobe/spacecat-shared-data-access-v4.10.0) (2026-07-20)
2
+
3
+ ### Features
4
+
5
+ * Add allSiteIdsByTier and allByEnrollmentAndTier collection meth… ([#1569](https://github.com/adobe/spacecat-shared/issues/1569)) ([3ac585b](https://github.com/adobe/spacecat-shared/commit/3ac585bd1b3fe93d32b8d0ad027af81cf6e37830))
6
+
7
+ ## [@adobe/spacecat-shared-data-access-v4.9.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.8.0...@adobe/spacecat-shared-data-access-v4.9.0) (2026-07-15)
8
+
9
+ ### Features
10
+
11
+ * **data-access:** guidance + feedback_subject_id + code-patch export gate for feedback ([#1809](https://github.com/adobe/spacecat-shared/issues/1809)) ([941b9f9](https://github.com/adobe/spacecat-shared/commit/941b9f92621df09a9ce887b70307f4c1261bfd37))
12
+
1
13
  ## [@adobe/spacecat-shared-data-access-v4.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.7.0...@adobe/spacecat-shared-data-access-v4.8.0) (2026-07-14)
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": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,16 +79,19 @@ export const EXPORT_EXCLUDED_REJECTION_CATEGORIES = Object.freeze([
79
79
  ]);
80
80
 
81
81
  /**
82
- * Customer-derived fields stripped from the JSONL export for organizations with
83
- * `training_opt_in = false`. Verdict / signal / category / identity metadata
84
- * still ship. snake_case to match the JSONL row shape (and the feedback_event
85
- * DB columns) {@link toJsonlRow} uses this list to do the stripping, so the
86
- * opt-out boundary is single-sourced here.
82
+ * HUMAN-authored fields stripped from the JSONL export for organizations with
83
+ * `training_opt_in = false`: the reviewer's written feedback (`detail_markdown`)
84
+ * and their hand-corrected patch (`edited_fix`). The AI-generated fields the
85
+ * issue context (`guidance_markdown`) and the generated patch (`previous_fix`)
86
+ * are ALWAYS exported (the Learning Agent needs the issue + generated patch to
87
+ * map a verdict to what was generated), as are verdict / signal / category /
88
+ * identity metadata. snake_case to match the JSONL row shape (and the
89
+ * feedback_event DB columns) — {@link toJsonlRow} uses this list to do the
90
+ * stripping, so the opt-out boundary is single-sourced here (SITES-43974).
87
91
  */
88
92
  export const OPT_OUT_STRIPPED_FIELDS = Object.freeze([
89
- 'previous_fix',
90
- 'edited_fix',
91
93
  'detail_markdown',
94
+ 'edited_fix',
92
95
  ]);
93
96
 
94
97
  /**
@@ -163,11 +166,17 @@ export function toReviewView(row, { includePatches = false } = {}) {
163
166
  rejectionCategory: row.rejection_category ?? null,
164
167
  stateTransition: row.state_transition ?? null,
165
168
  tier: row.tier,
169
+ // Sub-item id (e.g. a CWV issue) this review is about; the Backoffice groups
170
+ // its per-issue "previous feedback" table on it. Always in the base view (the
171
+ // UI filters on it) — not gated behind includePatches. Null for
172
+ // whole-suggestion reviews.
173
+ feedbackSubjectId: row.feedback_subject_id ?? null,
166
174
  };
167
175
 
168
176
  if (includePatches) {
169
177
  view.previousFix = row.previous_fix ?? null;
170
178
  view.editedFix = row.edited_fix ?? null;
179
+ view.guidanceMarkdown = row.guidance_markdown ?? null;
171
180
  }
172
181
 
173
182
  return view;
@@ -175,14 +184,21 @@ export function toReviewView(row, { includePatches = false } = {}) {
175
184
 
176
185
  /**
177
186
  * Whether a raw `feedback_event` row should be exported to the Learning Agent
178
- * corpus. `product_bug` rejections are excluded (routed to Jira); NULL-category
179
- * rows ARE exported. See {@link EXPORT_EXCLUDED_REJECTION_CATEGORIES}.
187
+ * corpus. Two gates (SITES-43974):
188
+ * 1. It must carry a generated code patch (`previous_fix`). The corpus is
189
+ * code-patch feedback; text-guidance-only reviews (no patch) are retained
190
+ * in Postgres but NOT exported — reserved for a future text-guidance corpus.
191
+ * 2. `product_bug` rejections are excluded (routed to Jira). NULL-category rows
192
+ * (approvals / uncategorised) ARE exported. See
193
+ * {@link EXPORT_EXCLUDED_REJECTION_CATEGORIES}.
180
194
  *
181
195
  * @param {object} row - a raw feedback_event row from PostgREST.
182
196
  * @returns {boolean} true if the row should be exported.
183
197
  */
184
198
  export function shouldExport(row) {
185
- return !EXPORT_EXCLUDED_REJECTION_CATEGORIES.includes(row.rejection_category);
199
+ const hasCodePatch = row.previous_fix != null;
200
+ return hasCodePatch
201
+ && !EXPORT_EXCLUDED_REJECTION_CATEGORIES.includes(row.rejection_category);
186
202
  }
187
203
 
188
204
  /**
@@ -190,8 +206,10 @@ export function shouldExport(row) {
190
206
  * object — the canonical Learning Agent export contract. The row mirrors the
191
207
  * feedback_event columns (snake_case), stamped with {@link SCHEMA_VERSION} and
192
208
  * the derived `verdict`. For organizations that have NOT opted into training,
193
- * the customer-derived fields in {@link OPT_OUT_STRIPPED_FIELDS} are nulled;
194
- * verdict / signal / category / identity metadata still ship (§10.5.7).
209
+ * the HUMAN-authored fields in {@link OPT_OUT_STRIPPED_FIELDS} are nulled; the
210
+ * AI-generated issue context (`guidance_markdown`) + generated patch
211
+ * (`previous_fix`) and the verdict / signal / category / identity metadata still
212
+ * ship (§10.5.7).
195
213
  *
196
214
  * This is the single source of truth for the JSONL row shape — the exporter
197
215
  * (spacecat-jobs-dispatcher) calls it rather than re-deriving the contract.
@@ -218,6 +236,7 @@ export function toJsonlRow(row, { optedIn = false } = {}) {
218
236
  state_transition: row.state_transition ?? null,
219
237
  tier: row.tier,
220
238
  detail_markdown: row.detail_markdown ?? null,
239
+ guidance_markdown: row.guidance_markdown ?? null,
221
240
  previous_fix: row.previous_fix ?? null,
222
241
  edited_fix: row.edited_fix ?? null,
223
242
  };
@@ -53,8 +53,10 @@ export interface ReviewView {
53
53
  rejectionCategory: string | null;
54
54
  stateTransition: string | null;
55
55
  tier: string;
56
+ feedbackSubjectId: string | null;
56
57
  previousFix?: unknown;
57
58
  editedFix?: unknown;
59
+ guidanceMarkdown?: string | null;
58
60
  }
59
61
 
60
62
  /**
@@ -477,6 +477,16 @@ export const configSchema = Joi.object({
477
477
  storeCode: Joi.string().required(),
478
478
  storeViewCode: Joi.string().required(),
479
479
  hostName: Joi.string().optional(),
480
+ catalogFieldConfig: Joi.object({
481
+ name: Joi.object({
482
+ enabled: Joi.boolean().required(),
483
+ maxLength: Joi.number().integer().min(0).optional(),
484
+ }).optional(),
485
+ description: Joi.object({
486
+ enabled: Joi.boolean().required(),
487
+ maxLength: Joi.number().integer().min(0).optional(),
488
+ }).optional(),
489
+ }).optional(),
480
490
  }).options({ stripUnknown: true }),
481
491
  ).optional(),
482
492
  contentAiConfig: Joi.object({
@@ -409,6 +409,8 @@ export interface SiteCollection extends BaseCollection<Site> {
409
409
  allByProjectName(projectName: string): Promise<Site[]>;
410
410
  allByOrganizationIdAndProjectId(organizationId: string, projectId: string): Promise<Site[]>;
411
411
  allByOrganizationIdAndProjectName(organizationId: string, projectName: string): Promise<Site[]>;
412
+ allByEnrollmentProductCode(productCode: string, options?: object): Promise<Site[]>;
413
+ allByEnrollmentAndTier(tier: string, productCode?: string, options?: object): Promise<Site[]>;
412
414
  allSitesToAudit(): Promise<string[]>;
413
415
  allWithLatestAudit(auditType: string, order?: string, deliveryType?: string): Promise<Site[]>;
414
416
  findByBaseURL(baseURL: string): Promise<Site | null>;
@@ -174,6 +174,38 @@ class SiteCollection extends BaseCollection {
174
174
  return sites;
175
175
  }
176
176
 
177
+ /**
178
+ * Returns all sites enrolled at a given entitlement tier (e.g. 'PAID',
179
+ * 'FREE_TRIAL', 'PLG'). Optionally narrows the result to a single product
180
+ * code (e.g. 'LLMO').
181
+ *
182
+ * Uses entityRegistry to chain through SiteEnrollmentCollection, then
183
+ * batch-fetches full Site objects.
184
+ *
185
+ * @param {string} tier - Entitlement tier to filter by.
186
+ * @param {string} [productCode] - Optional product code to further filter by.
187
+ * @param {object} [options] - batchGetByKeys options (e.g. attribute projection).
188
+ * @returns {Promise<Site[]>}
189
+ */
190
+ async allByEnrollmentAndTier(tier, productCode, options = {}) {
191
+ if (!hasText(tier)) {
192
+ throw new DataAccessError('tier is required', this);
193
+ }
194
+
195
+ const siteEnrollmentCollection = this.entityRegistry.getCollection('SiteEnrollmentCollection');
196
+
197
+ const siteIds = await siteEnrollmentCollection.allSiteIdsByTier(tier, productCode);
198
+ if (siteIds.length === 0) {
199
+ return [];
200
+ }
201
+
202
+ const { data: sites } = await this.batchGetByKeys(
203
+ siteIds.map((siteId) => ({ siteId })),
204
+ options,
205
+ );
206
+ return sites;
207
+ }
208
+
177
209
  async allByOrganizationIdAndProjectName(organizationId, projectName) {
178
210
  if (!hasText(organizationId)) {
179
211
  throw new DataAccessError('organizationId is required', this);
@@ -27,6 +27,8 @@ export interface SiteEnrollmentCollection extends
27
27
  BaseCollection<SiteEnrollment> {
28
28
  allBySiteId(siteId: string): Promise<SiteEnrollment[]>;
29
29
  allByEntitlementId(entitlementId: string): Promise<SiteEnrollment[]>;
30
+ allSiteIdsByProductCode(productCode: string): Promise<string[]>;
31
+ allSiteIdsByTier(tier: string, productCode?: string): Promise<string[]>;
30
32
 
31
33
  findBySiteId(siteId: string): Promise<SiteEnrollment | null>;
32
34
  findByEntitlementId(entitlementId: string): Promise<SiteEnrollment | null>;
@@ -10,6 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import { hasText } from '@adobe/spacecat-shared-utils';
13
14
  import BaseCollection from '../base/base.collection.js';
14
15
  import DataAccessError from '../../errors/data-access.error.js';
15
16
 
@@ -47,6 +48,39 @@ class SiteEnrollmentCollection extends BaseCollection {
47
48
  return (data || []).map((row) => row.site_id);
48
49
  }
49
50
 
51
+ /**
52
+ * Returns all site IDs enrolled at a given entitlement tier (e.g. 'PAID',
53
+ * 'FREE_TRIAL', 'PLG') in a single JOIN query. Optionally narrows the tier
54
+ * match to a single product code.
55
+ *
56
+ * @param {string} tier - Entitlement tier to filter by.
57
+ * @param {string} [productCode] - Optional product code to further filter by.
58
+ * @returns {Promise<string[]>} Array of siteId strings.
59
+ */
60
+ async allSiteIdsByTier(tier, productCode) {
61
+ if (!hasText(tier)) {
62
+ throw new DataAccessError('tier is required', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' });
63
+ }
64
+
65
+ let query = this.postgrestService
66
+ .from(this.tableName)
67
+ .select('site_id, entitlements!inner(tier, product_code)')
68
+ .eq('entitlements.tier', tier);
69
+
70
+ if (productCode) {
71
+ query = query.eq('entitlements.product_code', productCode);
72
+ }
73
+
74
+ const { data, error } = await query;
75
+
76
+ if (error) {
77
+ this.log.error(`[SiteEnrollmentCollection] Failed to query site_enrollments by tier - ${error.message}`, error);
78
+ throw new DataAccessError('Failed to query site_enrollments by tier', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' }, error);
79
+ }
80
+
81
+ return [...new Set((data || []).map((row) => row.site_id))];
82
+ }
83
+
50
84
  async create(item, options = {}) {
51
85
  if (item?.siteId && item?.entitlementId) {
52
86
  const existing = await this.findByIndexKeys({