@adobe/spacecat-shared-data-access 4.2.0 → 4.3.1

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.3.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.3.0...@adobe/spacecat-shared-data-access-v4.3.1) (2026-07-09)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **ticket-suggestion:** use createdAt as belongs_to sort key instead of updatedAt ([#1798](https://github.com/adobe/spacecat-shared/issues/1798)) ([711eecd](https://github.com/adobe/spacecat-shared/commit/711eecdc187ec9a21a79dedca808e6622bd18933)), closes [#2661](https://github.com/adobe/spacecat-shared/issues/2661)
6
+
7
+ ## [@adobe/spacecat-shared-data-access-v4.3.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.2.0...@adobe/spacecat-shared-data-access-v4.3.0) (2026-07-09)
8
+
9
+ ### Features
10
+
11
+ * **data-access:** status transition guard + deriveSuggestionStatus (SITES-47091) ([#1748](https://github.com/adobe/spacecat-shared/issues/1748)) ([0888866](https://github.com/adobe/spacecat-shared/commit/0888866df45778a8e28a86ce2b11c721b81cf0c0))
12
+
1
13
  ## [@adobe/spacecat-shared-data-access-v4.2.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.1.1...@adobe/spacecat-shared-data-access-v4.2.0) (2026-07-07)
2
14
 
3
15
  ### Features
package/CLAUDE.md CHANGED
@@ -213,6 +213,38 @@ const liveSites = await dataAccess.Site.all(
213
213
  | `POSTGREST_API_KEY` | No | JWT for `postgrest_writer` role (enables UPDATE/DELETE) |
214
214
  | `S3_CONFIG_BUCKET` | No | Only for `Configuration` entity |
215
215
  | `AWS_REGION` | No | Only for `Configuration` entity |
216
+ | `STATUS_TRANSITION_ENFORCEMENT` | No | Status-transition guard mode: `off` \| `warn` \| `enforce`. Default `warn`. See "Status Transition Lifecycle". |
217
+
218
+ ## Status Transition Lifecycle (SITES-47091)
219
+
220
+ `Suggestion.status` and `FixEntity.status` are governed by a transition guard
221
+ (canonical design: ADR [adobe/mysticat-architecture#174]). The guard runs on the
222
+ `setStatus` override of both models — so **every** writer (api-service single
223
+ PATCH, `SuggestionCollection.bulkUpdateStatus`, autofix-worker) is checked at one
224
+ chokepoint.
225
+
226
+ **Enforcement is env-controlled** via `STATUS_TRANSITION_ENFORCEMENT`, read at call time:
227
+
228
+ | Mode | Behavior |
229
+ |------|----------|
230
+ | `off` | no check |
231
+ | `warn` (**default**) | logs a violation (`<Entity> <id> <from> -> <to>`) and **still applies** the change — byte-equivalent to the old setter |
232
+ | `enforce` | throws `ValidationError` on a disallowed transition |
233
+
234
+ Rollout intent: ship `warn` to surface today's illegal transitions in logs for
235
+ ~1–2 weeks, then flip to `enforce`. A no-op (`from === to`) always passes.
236
+
237
+ **APIs** (exported from the package root):
238
+ - `setStatus(value)` / `transitionStatus(to)` on `Suggestion` and `FixEntity` — both guard the transition; `transitionStatus` is the intention-revealing alias for new code.
239
+ - `isAllowedFixTransition(from, to)` / `isAllowedSuggestionTransition(from, to)` + the `FIX_ENTITY_TRANSITIONS` / `SUGGESTION_TRANSITIONS` tables (a null/undefined `from` means entity creation).
240
+ - `deriveSuggestionStatus(fixes, issues = [])` — derives a Suggestion status from its fix entities. **Implements the non-CWV 1:1 bubble-up only** (`DEPLOYED`/`PUBLISHED`→`FIXED`, `FAILED`→`ERROR`, `PENDING`→`IN_PROGRESS`, `ROLLED_BACK`→`SKIPPED`; severity-collapses for >1 fix). **Throws** if a non-empty `issues` array is passed — the CWV multi-issue bubble-up is deferred (SITES-47285), because the per-issue codefix vocabulary (`PATCH_*`/`GUIDANCE_GENERATED`, written by mystique into the overloaded `issue.status` field) is not yet reconciled with the JS `ISSUE_STATUSES` enum.
241
+
242
+ Notes: the `FixEntity` table is the canonical one from the ADR; the `Suggestion`
243
+ table is intentionally **permissive in V1** (tune it from the warn-log findings
244
+ before enforcing). `FixEntity.REJECTED` is omitted (not in `FixEntity.STATUSES`).
245
+ Consumer adoption (bump + route writes + warn→enforce) is tracked in SITES-47286.
246
+
247
+ [adobe/mysticat-architecture#174]: https://github.com/adobe/mysticat-architecture/blob/main/platform/decisions/design-suggestion-fix-entity-status-lifecycle.md
216
248
 
217
249
  ## Site Config: Import Types
218
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -10,6 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  import BaseModel from '../base/base.model.js';
13
+ import { guardTransition } from '../../util/status-transition-guard.js';
14
+ import { isAllowedFixTransition } from './fix-entity.transitions.js';
13
15
 
14
16
  /**
15
17
  * FixEntity - A class representing a FixEntity for a Suggestion.
@@ -39,6 +41,40 @@ class FixEntity extends BaseModel {
39
41
  REPORTING: 'reporting',
40
42
  };
41
43
 
44
+ /**
45
+ * Sets the fix status, guarding the transition against the canonical table
46
+ * (fix-entity.transitions.js). Overrides the auto-generated setter so EVERY
47
+ * writer is checked at one chokepoint. Behavior is governed by
48
+ * STATUS_TRANSITION_ENFORCEMENT (default `warn` — logs violations, still
49
+ * applies; `enforce` — throws ValidationError; `off` — no check).
50
+ *
51
+ * @param {string} value - target status
52
+ * @returns {this}
53
+ */
54
+ setStatus(value) {
55
+ guardTransition({
56
+ entityName: FixEntity.ENTITY_NAME,
57
+ entityId: this.getId(),
58
+ from: this.getStatus(),
59
+ to: value,
60
+ isAllowed: isAllowedFixTransition,
61
+ log: this.log,
62
+ });
63
+ this.patcher.patchValue('status', value, false);
64
+ return this;
65
+ }
66
+
67
+ /**
68
+ * Explicit, intention-revealing alias for setStatus for new callers that want
69
+ * to signal a guarded lifecycle transition. Same behavior as setStatus.
70
+ *
71
+ * @param {string} to - target status
72
+ * @returns {this}
73
+ */
74
+ transitionStatus(to) {
75
+ return this.setStatus(to);
76
+ }
77
+
42
78
  async getSuggestions() {
43
79
  const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection');
44
80
  return fixEntityCollection
@@ -0,0 +1,65 @@
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
+ * FixEntity status literals.
14
+ *
15
+ * Duplicated from `FixEntity.STATUSES` (fix-entity.model.js) to avoid a circular
16
+ * import — fix-entity.model.js imports this module for its setStatus guard.
17
+ * Keep in sync if the enum ever changes. (Same pattern as suggestion.data-schemas.js.)
18
+ */
19
+ const STATUSES = {
20
+ PENDING: 'PENDING',
21
+ DEPLOYED: 'DEPLOYED',
22
+ PUBLISHED: 'PUBLISHED',
23
+ FAILED: 'FAILED',
24
+ ROLLED_BACK: 'ROLLED_BACK',
25
+ };
26
+
27
+ /**
28
+ * Canonical FixEntity status transition table.
29
+ *
30
+ * Source of truth: ADR adobe/mysticat-architecture#174
31
+ * (platform/decisions/design-suggestion-fix-entity-status-lifecycle.md, §"Proposed rollout").
32
+ *
33
+ * The `<create>` case (no prior status) is represented by the CREATE key and is
34
+ * matched when `from` is null/undefined.
35
+ *
36
+ * NOTE: the ADR table also lists a `REJECTED` terminal fix status, but
37
+ * `FixEntity.STATUSES` does not (yet) define REJECTED. It is intentionally
38
+ * omitted here until the enum + the post-merge/publish detector that would set
39
+ * it exist (tracked under SITES-47076).
40
+ */
41
+ export const FIX_ENTITY_CREATE = Symbol('FIX_ENTITY_CREATE');
42
+
43
+ export const FIX_ENTITY_TRANSITIONS = {
44
+ [FIX_ENTITY_CREATE]: [STATUSES.PENDING, STATUSES.DEPLOYED, STATUSES.FAILED],
45
+ [STATUSES.PENDING]: [STATUSES.DEPLOYED, STATUSES.FAILED],
46
+ [STATUSES.DEPLOYED]: [STATUSES.PUBLISHED, STATUSES.ROLLED_BACK],
47
+ // bounded retry; the attempts cap (SITES-46548) is not enforced here.
48
+ [STATUSES.FAILED]: [STATUSES.PENDING],
49
+ [STATUSES.PUBLISHED]: [STATUSES.ROLLED_BACK],
50
+ [STATUSES.ROLLED_BACK]: [], // terminal
51
+ };
52
+
53
+ /**
54
+ * Returns true if the FixEntity status transition `from` -> `to` is allowed.
55
+ * A null/undefined `from` is treated as entity creation.
56
+ *
57
+ * @param {string|null|undefined} from - current status (null/undefined => create)
58
+ * @param {string} to - target status
59
+ * @returns {boolean}
60
+ */
61
+ export const isAllowedFixTransition = (from, to) => {
62
+ const key = (from === null || from === undefined) ? FIX_ENTITY_CREATE : from;
63
+ const allowed = FIX_ENTITY_TRANSITIONS[key];
64
+ return Array.isArray(allowed) && allowed.includes(to);
65
+ };
@@ -30,6 +30,7 @@ export interface FixEntity extends BaseModel {
30
30
  setPublishedAt(value: string): this;
31
31
  getStatus(): string;
32
32
  setStatus(value: string): this;
33
+ transitionStatus(to: string): this;
33
34
  getType(): string;
34
35
  }
35
36
 
@@ -43,3 +44,11 @@ export interface FixEntityCollection extends BaseCollection<FixEntity> {
43
44
  getAllFixesWithSuggestionByCreatedAt(opportunityId: string, fixEntityCreatedDate: string): Promise<Array<{fixEntity: FixEntity, suggestions: Array<Suggestion>}>>;
44
45
  getAllFixesWithSuggestionsByOpportunityId(opportunityId: string): Promise<Array<{fixEntity: FixEntity, suggestions: Array<Suggestion>}>>;
45
46
  }
47
+
48
+ // Canonical FixEntity status transition table + predicate (SITES-47091).
49
+ export declare const FIX_ENTITY_CREATE: unique symbol;
50
+ export declare const FIX_ENTITY_TRANSITIONS: Record<string | symbol, string[]>;
51
+ export declare function isAllowedFixTransition(
52
+ from: string | null | undefined,
53
+ to: string,
54
+ ): boolean;
@@ -17,3 +17,10 @@ export {
17
17
  FixEntity,
18
18
  FixEntityCollection,
19
19
  };
20
+
21
+ // Canonical FixEntity status transition table + predicate (SITES-47091).
22
+ export {
23
+ FIX_ENTITY_TRANSITIONS,
24
+ FIX_ENTITY_CREATE,
25
+ isAllowedFixTransition,
26
+ } from './fix-entity.transitions.js';
@@ -0,0 +1,112 @@
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
+ /**
14
+ * Suggestion + FixEntity status literals.
15
+ *
16
+ * Duplicated from the model enums to keep this a dependency-light pure module.
17
+ * Keep in sync if either enum changes. (Same pattern as suggestion.data-schemas.js.)
18
+ */
19
+ const SUGGESTION = {
20
+ NEW: 'NEW',
21
+ IN_PROGRESS: 'IN_PROGRESS',
22
+ SKIPPED: 'SKIPPED',
23
+ FIXED: 'FIXED',
24
+ ERROR: 'ERROR',
25
+ };
26
+
27
+ const FIX = {
28
+ PENDING: 'PENDING',
29
+ DEPLOYED: 'DEPLOYED',
30
+ PUBLISHED: 'PUBLISHED',
31
+ FAILED: 'FAILED',
32
+ ROLLED_BACK: 'ROLLED_BACK',
33
+ };
34
+
35
+ /**
36
+ * Normalizes a fix entry to its status string. Accepts a FixEntity model
37
+ * instance (`getStatus()`), a plain `{ status }` object, or a status string.
38
+ */
39
+ const toStatus = (fix) => {
40
+ if (typeof fix === 'string') {
41
+ return fix;
42
+ }
43
+ if (fix && typeof fix.getStatus === 'function') {
44
+ return fix.getStatus();
45
+ }
46
+ return fix?.status;
47
+ };
48
+
49
+ /**
50
+ * Derives a Suggestion's status from its fix entities — the **non-CWV (1:1)**
51
+ * bubble-up from ADR adobe/mysticat-architecture#174 §"Bubble-up rule (non-CWV
52
+ * opportunities)":
53
+ *
54
+ * FAILED -> ERROR
55
+ * PENDING -> IN_PROGRESS
56
+ * DEPLOYED -> FIXED
57
+ * PUBLISHED -> FIXED
58
+ * ROLLED_BACK -> SKIPPED
59
+ *
60
+ * For a single fix this is exactly the ADR map. For multiple fixes it collapses
61
+ * by severity (ERROR > IN_PROGRESS > FIXED > SKIPPED) so partial failure wins —
62
+ * consistent with the CWV severity ordering. `Fix.REJECTED` is intentionally
63
+ * absent: it is not in `FixEntity.STATUSES` (see SITES-47076).
64
+ *
65
+ * The **CWV multi-issue** bubble-up is deliberately NOT implemented here: the
66
+ * ADR's per-issue vocabulary (`cwvIssueStatus` = PATCH_GENERATED / GUIDANCE_*
67
+ * / PATCH_FAILED_*) does not match the data layer (mystique writes those into
68
+ * the overloaded per-issue `status` field; the JS `ISSUE_STATUSES` enum lists
69
+ * only Suggestion statuses). Reconciling that is a follow-up sub-task,
70
+ * SITES-47285. Passing a non-empty `issues` argument throws to prevent a caller
71
+ * from silently receiving an unimplemented CWV result.
72
+ *
73
+ * @param {Array<object|string>} fixes - fix entities / `{status}` / status strings
74
+ * @param {Array<object>} [issues] - CWV per-issue data; MUST be empty for now
75
+ * @param {string|null} [currentStatus] - the Suggestion's current status, returned as the
76
+ * fallback when nothing is derivable (no fixes / all-null / no matching rule) so a derive
77
+ * call never clobbers a status set elsewhere. Defaults to null (prior behavior).
78
+ * @returns {string|null} derived Suggestion status, or `currentStatus` when nothing is derivable
79
+ * @throws {Error} if a non-empty `issues` array is supplied (CWV deferred)
80
+ */
81
+ export const deriveSuggestionStatus = (fixes, issues = [], currentStatus = null) => {
82
+ if (Array.isArray(issues) && issues.length > 0) {
83
+ throw new Error('deriveSuggestionStatus: CWV multi-issue bubble-up is not yet implemented (deferred to SITES-47285)');
84
+ }
85
+
86
+ if (!Array.isArray(fixes) || fixes.length === 0) {
87
+ return currentStatus;
88
+ }
89
+
90
+ // Drop null/undefined entries (e.g. a sparse junction result) so one malformed
91
+ // entry cannot nullify an otherwise well-defined derivation; degrades to the
92
+ // same "no fixes" behavior as an empty array.
93
+ const statuses = fixes.map(toStatus).filter((s) => s != null);
94
+ if (statuses.length === 0) {
95
+ return currentStatus;
96
+ }
97
+
98
+ if (statuses.includes(FIX.FAILED)) {
99
+ return SUGGESTION.ERROR;
100
+ }
101
+ if (statuses.includes(FIX.PENDING)) {
102
+ return SUGGESTION.IN_PROGRESS;
103
+ }
104
+ if (statuses.includes(FIX.DEPLOYED) || statuses.includes(FIX.PUBLISHED)) {
105
+ return SUGGESTION.FIXED;
106
+ }
107
+ if (statuses.every((s) => s === FIX.ROLLED_BACK)) {
108
+ return SUGGESTION.SKIPPED;
109
+ }
110
+
111
+ return currentStatus;
112
+ };
@@ -25,6 +25,7 @@ export interface Suggestion extends BaseModel {
25
25
  setOpportunityId(opportunityId: string): Suggestion;
26
26
  setRank(rank: number): Suggestion;
27
27
  setStatus(status: string): Suggestion;
28
+ transitionStatus(to: string): Suggestion;
28
29
  }
29
30
 
30
31
  export interface SuggestionCollection extends BaseCollection<Suggestion> {
@@ -35,3 +36,15 @@ export interface SuggestionCollection extends BaseCollection<Suggestion> {
35
36
  findByOpportunityIdAndStatus(opportunityId: string, status: string, options?: QueryOptions): Promise<Suggestion | null>;
36
37
  getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array<FixEntity>, unprocessed: Array<string>}>;
37
38
  }
39
+
40
+ // Status transition table + predicate, and the 1:1 bubble-up (SITES-47091).
41
+ export declare const SUGGESTION_CREATE: unique symbol;
42
+ export declare const SUGGESTION_TRANSITIONS: Record<string | symbol, string[]>;
43
+ export declare function isAllowedSuggestionTransition(
44
+ from: string | null | undefined,
45
+ to: string,
46
+ ): boolean;
47
+ export declare function deriveSuggestionStatus(
48
+ fixes: Array<FixEntity | { status?: string } | string>,
49
+ issues?: Array<object>,
50
+ ): string | null;
@@ -28,3 +28,12 @@ export {
28
28
  ISSUE_STATUSES,
29
29
  ISSUE_SKIP_REASONS,
30
30
  } from './suggestion.data-schemas.js';
31
+
32
+ // Suggestion status transition table + predicate, and the 1:1 bubble-up
33
+ // (SITES-47091). CWV multi-issue bubble-up deferred (see derive-status.js).
34
+ export {
35
+ SUGGESTION_TRANSITIONS,
36
+ SUGGESTION_CREATE,
37
+ isAllowedSuggestionTransition,
38
+ } from './suggestion.transitions.js';
39
+ export { deriveSuggestionStatus } from './derive-status.js';
@@ -13,6 +13,8 @@
13
13
  import BaseModel from '../base/base.model.js';
14
14
  import { DATA_SCHEMAS } from './suggestion.data-schemas.js';
15
15
  import { FIELD_TRANSFORMERS, FALLBACK_PROJECTION } from './suggestion.projection-utils.js';
16
+ import { guardTransition } from '../../util/status-transition-guard.js';
17
+ import { isAllowedSuggestionTransition } from './suggestion.transitions.js';
16
18
 
17
19
  /**
18
20
  * Suggestion - A class representing a Suggestion entity.
@@ -120,6 +122,40 @@ class Suggestion extends BaseModel {
120
122
  }
121
123
  }
122
124
 
125
+ /**
126
+ * Sets the suggestion status, guarding the transition against the (V1
127
+ * permissive) table in suggestion.transitions.js. Overrides the auto-generated
128
+ * setter so every writer — single PATCH, bulkUpdateStatus, autofix-worker —
129
+ * is checked at one chokepoint. Behavior is governed by
130
+ * STATUS_TRANSITION_ENFORCEMENT (default `warn`; `enforce` throws; `off` skips).
131
+ *
132
+ * @param {string} value - target status
133
+ * @returns {this}
134
+ */
135
+ setStatus(value) {
136
+ guardTransition({
137
+ entityName: Suggestion.ENTITY_NAME,
138
+ entityId: this.getId(),
139
+ from: this.getStatus(),
140
+ to: value,
141
+ isAllowed: isAllowedSuggestionTransition,
142
+ log: this.log,
143
+ });
144
+ this.patcher.patchValue('status', value, false);
145
+ return this;
146
+ }
147
+
148
+ /**
149
+ * Explicit, intention-revealing alias for setStatus for new callers that want
150
+ * to signal a guarded lifecycle transition. Same behavior as setStatus.
151
+ *
152
+ * @param {string} to - target status
153
+ * @returns {this}
154
+ */
155
+ transitionStatus(to) {
156
+ return this.setStatus(to);
157
+ }
158
+
123
159
  // add your customized method here
124
160
  }
125
161
 
@@ -0,0 +1,83 @@
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
+ /**
14
+ * Suggestion status literals.
15
+ *
16
+ * Duplicated from `Suggestion.STATUSES` (suggestion.model.js) to avoid a circular
17
+ * import — suggestion.model.js imports this module for its setStatus guard.
18
+ * Keep in sync if the enum ever changes. (Same pattern as suggestion.data-schemas.js.)
19
+ */
20
+ const S = {
21
+ NEW: 'NEW',
22
+ APPROVED: 'APPROVED',
23
+ IN_PROGRESS: 'IN_PROGRESS',
24
+ SKIPPED: 'SKIPPED',
25
+ FIXED: 'FIXED',
26
+ ERROR: 'ERROR',
27
+ OUTDATED: 'OUTDATED',
28
+ PENDING_VALIDATION: 'PENDING_VALIDATION',
29
+ REJECTED: 'REJECTED',
30
+ };
31
+
32
+ export const SUGGESTION_CREATE = Symbol('SUGGESTION_CREATE');
33
+
34
+ /**
35
+ * Suggestion status transition table.
36
+ *
37
+ * Source of truth: ADR adobe/mysticat-architecture#174. Unlike FixEntity, the
38
+ * Suggestion lifecycle is mostly *derived* (bubble-up from fix entities) plus a
39
+ * few direct writes from audit-worker / ESE-review / UI / api-service. This map
40
+ * is intentionally **permissive** in V1: it encodes the transitions we believe
41
+ * are legitimate today so the warn-only guard surfaces genuine anomalies without
42
+ * drowning real flows in false warnings. The warn logs from the rollout period
43
+ * inform which entries to tighten before flipping to enforce.
44
+ *
45
+ * The one hard rule already enforced by api-service is preserved exactly:
46
+ * REJECTED is only reachable from PENDING_VALIDATION (suggestions.js).
47
+ */
48
+ export const SUGGESTION_TRANSITIONS = {
49
+ // audit-worker creates as NEW (non-paid) or PENDING_VALIDATION (paid); OUTDATED at audit time.
50
+ [SUGGESTION_CREATE]: [S.NEW, S.PENDING_VALIDATION, S.OUTDATED],
51
+ // ESE/TBYB review (-> NEW), mystique (-> IN_PROGRESS), bubble-up, UI skip, re-audit.
52
+ [S.NEW]: [S.APPROVED, S.IN_PROGRESS, S.FIXED, S.ERROR, S.SKIPPED, S.OUTDATED],
53
+ // paid-review gate: approve -> NEW, decline -> REJECTED (the one hard rule), or skip/outdate.
54
+ // IN_PROGRESS: api-service autofixSuggestions accepts PENDING_VALIDATION and sets IN_PROGRESS.
55
+ [S.PENDING_VALIDATION]: [S.NEW, S.IN_PROGRESS, S.REJECTED, S.SKIPPED, S.OUTDATED],
56
+ [S.APPROVED]: [S.IN_PROGRESS, S.FIXED, S.ERROR, S.SKIPPED, S.NEW, S.OUTDATED],
57
+ // bubble-up after fix-entity transitions.
58
+ [S.IN_PROGRESS]: [S.FIXED, S.ERROR, S.SKIPPED, S.NEW, S.OUTDATED],
59
+ // re-detection can reopen a fixed suggestion (-> NEW), retry (-> IN_PROGRESS), or outdate it.
60
+ [S.FIXED]: [S.NEW, S.IN_PROGRESS, S.ERROR, S.OUTDATED],
61
+ // errors are recoverable: re-attempt or reopen.
62
+ [S.ERROR]: [S.NEW, S.IN_PROGRESS, S.FIXED, S.SKIPPED, S.OUTDATED],
63
+ // un-skip / re-detect.
64
+ [S.SKIPPED]: [S.NEW, S.OUTDATED],
65
+ // re-detection reopens an outdated suggestion.
66
+ [S.OUTDATED]: [S.NEW],
67
+ // near-terminal: allow reopen to NEW to correct an incorrect classification (sandsinh review).
68
+ [S.REJECTED]: [S.NEW],
69
+ };
70
+
71
+ /**
72
+ * Returns true if the Suggestion status transition `from` -> `to` is allowed.
73
+ * A null/undefined `from` is treated as suggestion creation.
74
+ *
75
+ * @param {string|null|undefined} from - current status (null/undefined => create)
76
+ * @param {string} to - target status
77
+ * @returns {boolean}
78
+ */
79
+ export const isAllowedSuggestionTransition = (from, to) => {
80
+ const key = (from === null || from === undefined) ? SUGGESTION_CREATE : from;
81
+ const allowed = SUGGESTION_TRANSITIONS[key];
82
+ return Array.isArray(allowed) && allowed.includes(to);
83
+ };
@@ -29,7 +29,10 @@ const schema = new SchemaBuilder(TicketSuggestion, TicketSuggestionCollection)
29
29
  type: 'string', required: false, readOnly: true, postgrestIgnore: true,
30
30
  })
31
31
  .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true })
32
- .addReference('belongs_to', 'Ticket')
32
+ // Sort key uses createdAt (not the default updatedAt) because ticket_suggestions is
33
+ // append-only — the table has no updated_at column, so ORDER BY updated_at would
34
+ // cause PostgREST to error on allByTicketId queries.
35
+ .addReference('belongs_to', 'Ticket', ['createdAt'])
33
36
  .addAttribute('suggestionId', {
34
37
  type: 'string',
35
38
  required: true,
@@ -0,0 +1,77 @@
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
+ import { ValidationError } from '../errors/index.js';
13
+
14
+ export const ENFORCEMENT_MODES = {
15
+ OFF: 'off',
16
+ WARN: 'warn',
17
+ ENFORCE: 'enforce',
18
+ };
19
+
20
+ /**
21
+ * Resolves the status-transition enforcement mode from the environment.
22
+ *
23
+ * `STATUS_TRANSITION_ENFORCEMENT` ∈ { off, warn, enforce }; defaults to `warn`.
24
+ * Read at call time so deployments (and tests) can flip it without a re-import.
25
+ * Rollout (SITES-47091): ship `warn` to surface today's illegal transitions in
26
+ * logs for ~1-2 weeks, then flip to `enforce`.
27
+ *
28
+ * @returns {string} one of ENFORCEMENT_MODES
29
+ */
30
+ export const getEnforcementMode = () => {
31
+ const raw = (process.env.STATUS_TRANSITION_ENFORCEMENT || '').trim().toLowerCase();
32
+ return Object.values(ENFORCEMENT_MODES).includes(raw) ? raw : ENFORCEMENT_MODES.WARN;
33
+ };
34
+
35
+ /**
36
+ * Guards a status transition. A no-op (`from === to`) and any allowed transition
37
+ * pass silently. An illegal transition is, depending on the enforcement mode,
38
+ * ignored (`off`), logged without blocking (`warn`), or rejected (`enforce`).
39
+ *
40
+ * @param {object} params
41
+ * @param {string} params.entityName - e.g. 'FixEntity' / 'Suggestion' (for the message)
42
+ * @param {string} [params.entityId] - entity id (for the message)
43
+ * @param {string|null|undefined} params.from - current status (null/undefined => create)
44
+ * @param {string} params.to - target status
45
+ * @param {(from: string|null|undefined, to: string) => boolean} params.isAllowed
46
+ * - transition predicate (isAllowedFixTransition / isAllowedSuggestionTransition)
47
+ * @param {object} [params.log] - logger with a `warn` method; the detailed
48
+ * violation is logged in both `warn` and `enforce` modes
49
+ * @throws {ValidationError} in `enforce` mode when the transition is not allowed
50
+ */
51
+ export const guardTransition = ({
52
+ entityName, entityId, from, to, isAllowed, log,
53
+ }) => {
54
+ if (from === to) {
55
+ return;
56
+ }
57
+
58
+ const mode = getEnforcementMode();
59
+ if (mode === ENFORCEMENT_MODES.OFF) {
60
+ return;
61
+ }
62
+ if (isAllowed(from, to)) {
63
+ return;
64
+ }
65
+
66
+ // Detailed message goes to the server-side log (both warn and enforce) for ops/debugging.
67
+ const message = `status transition violation: ${entityName} ${entityId ?? '<unknown>'} ${from ?? '<create>'} -> ${to}`;
68
+ if (log) {
69
+ log.warn(message);
70
+ }
71
+
72
+ // enforce: throw a sanitized error (no entity id / current status) so an illegal
73
+ // transition surfaced via the API layer cannot be used as an existence/state oracle.
74
+ if (mode === ENFORCEMENT_MODES.ENFORCE) {
75
+ throw new ValidationError(`Invalid status transition for ${entityName}: cannot transition to ${to}`);
76
+ }
77
+ };