From 0d752bc0ff822e17351a20004e78f4b72dfc78e7 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 22 Jun 2026 20:36:10 +0530 Subject: [PATCH 01/32] feat(SITES-44690): add TaskManagementConnection and Ticket data models TaskManagementConnection: - stores per-org Jira OAuth connection state (active / requires_reauth / disconnected) - metadata jsonb carries cloudId, siteUrl, scopeKey without schema churn - schema GSI on (organizationId, provider, status) powers the O(1) lookup the ticket-creation API needs before every request - isActive() / markRequiresReauth() business-level methods encode the connection lifecycle in one place Ticket: - records each Jira issue created by ASO: ticketId (Jira numeric ID for future updates), ticketKey (human key), ticketUrl, ticketStatus - optional opportunityId FK keeps door open for ticket-less future flows Both models follow monorepo conventions: SchemaBuilder, BaseModel/Collection, index.js + index.d.ts re-exports, registered in EntityRegistry. Co-authored-by: Cursor --- .../src/models/base/entity.registry.js | 6 ++ .../src/models/index.d.ts | 2 + .../src/models/index.js | 2 + .../task-management-connection/index.d.ts | 59 ++++++++++++++++ .../task-management-connection/index.js | 19 ++++++ .../task-management-connection.collection.js | 61 +++++++++++++++++ .../task-management-connection.model.js | 67 +++++++++++++++++++ .../task-management-connection.schema.js | 41 ++++++++++++ .../src/models/ticket/index.d.ts | 32 +++++++++ .../src/models/ticket/index.js | 19 ++++++ .../src/models/ticket/ticket.collection.js | 30 +++++++++ .../src/models/ticket/ticket.model.js | 37 ++++++++++ .../src/models/ticket/ticket.schema.js | 48 +++++++++++++ 13 files changed, 423 insertions(+) create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/index.js create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/models/ticket/index.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index a8cfcd385..b8f68dd5b 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -55,6 +55,8 @@ import SentimentGuidelineCollection from '../sentiment-guideline/sentiment-guide import SentimentTopicCollection from '../sentiment-topic/sentiment-topic.collection.js'; import AccessGrantLogCollection from '../access-grant-log/access-grant-log.collection.js'; import SiteImsOrgAccessCollection from '../site-ims-org-access/site-ims-org-access.collection.js'; +import TaskManagementConnectionCollection from '../task-management-connection/task-management-connection.collection.js'; +import TicketCollection from '../ticket/ticket.collection.js'; import ApiKeySchema from '../api-key/api-key.schema.js'; import AsyncJobSchema from '../async-job/async-job.schema.js'; @@ -97,6 +99,8 @@ import SentimentGuidelineSchema from '../sentiment-guideline/sentiment-guideline import SentimentTopicSchema from '../sentiment-topic/sentiment-topic.schema.js'; import AccessGrantLogSchema from '../access-grant-log/access-grant-log.schema.js'; import SiteImsOrgAccessSchema from '../site-ims-org-access/site-ims-org-access.schema.js'; +import TaskManagementConnectionSchema from '../task-management-connection/task-management-connection.schema.js'; +import TicketSchema from '../ticket/ticket.schema.js'; /** * EntityRegistry - A registry class responsible for managing entities, their schema and collection. @@ -234,6 +238,8 @@ EntityRegistry.registerEntity(SentimentGuidelineSchema, SentimentGuidelineCollec EntityRegistry.registerEntity(SentimentTopicSchema, SentimentTopicCollection); EntityRegistry.registerEntity(AccessGrantLogSchema, AccessGrantLogCollection); EntityRegistry.registerEntity(SiteImsOrgAccessSchema, SiteImsOrgAccessCollection); +EntityRegistry.registerEntity(TaskManagementConnectionSchema, TaskManagementConnectionCollection); +EntityRegistry.registerEntity(TicketSchema, TicketCollection); EntityRegistry.defaultEntities = { ...EntityRegistry.entities }; export default EntityRegistry; diff --git a/packages/spacecat-shared-data-access/src/models/index.d.ts b/packages/spacecat-shared-data-access/src/models/index.d.ts index f50fd367e..b542459b7 100755 --- a/packages/spacecat-shared-data-access/src/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/index.d.ts @@ -40,6 +40,8 @@ export type * from './scrape-job'; export type * from './scrape-url'; export type * from './sentiment-guideline'; export type * from './sentiment-topic'; +export type * from './task-management-connection'; +export type * from './ticket'; export type * from './site'; export type * from './site-candidate'; export type * from './site-enrollment'; diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index 06191ccb7..0346235b4 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -53,3 +53,5 @@ export * from './page-citability/index.js'; export * from './plg-onboarding/index.js'; export * from './sentiment-guideline/index.js'; export * from './sentiment-topic/index.js'; +export * from './task-management-connection/index.js'; +export * from './ticket/index.js'; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts new file mode 100644 index 000000000..e3d4669bd --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Ticket } from '../index'; + +export interface TaskManagementConnection extends BaseModel { + /** Returns true when the connection is healthy and ready to create tickets. */ + isActive(): boolean; + /** + * Persists status = 'requires_reauth'. Call this after a failed token refresh + * so the UI can prompt the user to reconnect. + */ + markRequiresReauth(): Promise; + + getMetadata(): object; + getOrganizationId(): string; + getProvider(): string; + getStatus(): string; + getTickets(): Promise; + + setMetadata(metadata: object): TaskManagementConnection; + setStatus(status: string): TaskManagementConnection; +} + +export interface TaskManagementConnectionCollection extends BaseCollection { + /** + * Returns the active connection for an org + provider pair used by the + * ticket-creation API before every ticket request, or null if none exists. + */ + findActiveByOrganizationAndProvider( + organizationId: string, + provider: string, + ): Promise; + + allByOrganizationId(organizationId: string): Promise; + allByOrganizationIdAndProvider( + organizationId: string, + provider: string, + ): Promise; + allByOrganizationIdAndProviderAndStatus( + organizationId: string, + provider: string, + status: string, + ): Promise; + findByOrganizationIdAndProviderAndStatus( + organizationId: string, + provider: string, + status: string, + ): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js new file mode 100644 index 000000000..18bf2b804 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import TaskManagementConnection from './task-management-connection.model.js'; +import TaskManagementConnectionCollection from './task-management-connection.collection.js'; + +export { + TaskManagementConnection, + TaskManagementConnectionCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js new file mode 100644 index 000000000..15cedb233 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { isValidUUID } from '@adobe/spacecat-shared-utils'; + +import { ValidationError } from '../../errors/index.js'; +import BaseCollection from '../base/base.collection.js'; +import TaskManagementConnection from './task-management-connection.model.js'; + +/** + * TaskManagementConnectionCollection — manages TaskManagementConnection entities. + * + * Key query the ticket-creation API relies on: + * `findActiveByOrganizationAndProvider(orgId, provider)` — returns the single + * active connection for a given org + provider pair, or null if none exists. + * + * @class TaskManagementConnectionCollection + * @extends BaseCollection + */ +class TaskManagementConnectionCollection extends BaseCollection { + static COLLECTION_NAME = 'TaskManagementConnectionCollection'; + + /** + * Returns the single active connection for an organization and provider, or + * null when the org has not connected that provider (or the connection is + * in a degraded / disconnected state). + * + * The API layer calls this before every ticket-creation request and returns + * 409 Conflict when no active connection is found. + * + * @param {string} organizationId - The organization UUID. + * @param {string} provider - The provider key, e.g. 'jira_cloud'. + * @returns {Promise} + * @throws {ValidationError} When organizationId or provider is missing. + */ + async findActiveByOrganizationAndProvider(organizationId, provider) { + if (!isValidUUID(organizationId)) { + throw new ValidationError('organizationId must be a valid UUID', this); + } + if (!provider) { + throw new ValidationError('provider is required', this); + } + + return this.findByOrganizationIdAndProviderAndStatus( + organizationId, + provider, + TaskManagementConnection.STATUSES.ACTIVE, + ); + } +} + +export default TaskManagementConnectionCollection; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js new file mode 100644 index 000000000..ab2aa69d8 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * TaskManagementConnection — one OAuth connection from an organization to a + * task-management provider (e.g. Jira Cloud). + * + * Status lifecycle: + * active → tokens are valid, tickets can be created + * requires_reauth → refresh token expired, user must reconnect + * disconnected → explicitly disconnected by the user + * + * Provider-specific config (cloudId, siteUrl, scopeKey) lives in `metadata` + * as jsonb so new fields never require a schema change. + * + * @class TaskManagementConnection + * @extends BaseModel + */ +class TaskManagementConnection extends BaseModel { + static ENTITY_NAME = 'TaskManagementConnection'; + + /** Supported task-management providers. */ + static PROVIDERS = { + JIRA_CLOUD: 'jira_cloud', + }; + + /** Connection health statuses. */ + static STATUSES = { + ACTIVE: 'active', + REQUIRES_REAUTH: 'requires_reauth', + DISCONNECTED: 'disconnected', + }; + + /** + * Returns true when this connection is healthy and ready to create tickets. + * + * @returns {boolean} + */ + isActive() { + return this.getStatus() === TaskManagementConnection.STATUSES.ACTIVE; + } + + /** + * Marks the connection as requiring re-authentication (e.g. after a failed + * token refresh). Persists the status immediately so other services see the + * degraded state without waiting for the next GC cycle. + * + * @returns {Promise} + */ + async markRequiresReauth() { + this.setStatus(TaskManagementConnection.STATUSES.REQUIRES_REAUTH); + return this.save(); + } +} + +export default TaskManagementConnection; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js new file mode 100644 index 000000000..0e18e259d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import SchemaBuilder from '../base/schema.builder.js'; +import TaskManagementConnection from './task-management-connection.model.js'; +import TaskManagementConnectionCollection from './task-management-connection.collection.js'; + +// Sort key [provider, status] on the Organization GSI lets the collection method +// findActiveByOrganizationAndProvider() resolve to a single DB call: +// allByOrganizationIdAndProviderAndStatus(orgId, 'jira_cloud', 'active') +const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnectionCollection) + .addReference('belongs_to', 'Organization', ['provider', 'status']) + .addReference('has_many', 'Tickets', ['updatedAt'], { removeDependents: true }) + .addAttribute('provider', { + type: Object.values(TaskManagementConnection.PROVIDERS), + required: true, + readOnly: true, + }) + .addAttribute('status', { + type: Object.values(TaskManagementConnection.STATUSES), + required: true, + default: TaskManagementConnection.STATUSES.ACTIVE, + }) + .addAttribute('metadata', { + type: 'any', + required: true, + default: {}, + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts new file mode 100644 index 000000000..317ba36a3 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Opportunity, TaskManagementConnection } from '../index'; + +export interface Ticket extends BaseModel { + getOpportunityId(): string | undefined; + getOrganizationId(): string; + getTaskManagementConnectionId(): string; + getTicketId(): string; + getTicketKey(): string; + getTicketStatus(): string; + getTicketUrl(): string; + + setTicketStatus(status: string): Ticket; +} + +export interface TicketCollection extends BaseCollection { + allByOrganizationId(organizationId: string): Promise; + allByTaskManagementConnectionId(connectionId: string): Promise; + findByOpportunityId(opportunityId: string): Promise; + findByOpportunityIdAndTicketKey(opportunityId: string, ticketKey: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.js b/packages/spacecat-shared-data-access/src/models/ticket/index.js new file mode 100644 index 000000000..b97b72261 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import Ticket from './ticket.model.js'; +import TicketCollection from './ticket.collection.js'; + +export { + Ticket, + TicketCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js new file mode 100644 index 000000000..fe01c7a62 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * TicketCollection — manages Ticket entities. + * + * Auto-generated index query methods (via schema GSIs): + * allByOrganizationId(orgId) + * allByTaskManagementConnectionId(connectionId) + * findByOpportunityId(opportunityId) — optional FK, nullable + * + * @class TicketCollection + * @extends BaseCollection + */ +class TicketCollection extends BaseCollection { + static COLLECTION_NAME = 'TicketCollection'; +} + +export default TicketCollection; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js new file mode 100644 index 000000000..94234af06 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * Ticket — one Jira issue created by ASO via a TaskManagementConnection. + * + * Preserves the three provider-side identifiers needed for future operations: + * ticketId — Jira's internal numeric ID (data.id). Required for PATCH/update calls. + * ticketKey — Human-readable issue key, e.g. 'ASO-42'. Used in UI links. + * ticketUrl — Direct browser URL, e.g. 'https://acme.atlassian.net/browse/ASO-42'. + * + * ticketStatus mirrors the Jira column heading ('To Do', 'In Progress', 'Done'). + * It is updated asynchronously by a future status-sync job; write directly via + * setTicketStatus() + save() only from that job. + * + * @class Ticket + * @extends BaseModel + */ +class Ticket extends BaseModel { + static ENTITY_NAME = 'Ticket'; + + /** Default status assigned to every newly created ticket. */ + static DEFAULT_STATUS = 'To Do'; +} + +export default Ticket; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js new file mode 100644 index 000000000..3735a5029 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isValidUrl } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import Ticket from './ticket.model.js'; +import TicketCollection from './ticket.collection.js'; + +const schema = new SchemaBuilder(Ticket, TicketCollection) + .addReference('belongs_to', 'Organization') + .addReference('belongs_to', 'TaskManagementConnection') + // Optional FK — a ticket may not be linked to an opportunity in future flows. + .addReference('belongs_to', 'Opportunity', ['ticketKey'], { required: false }) + .addAttribute('ticketId', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('ticketKey', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('ticketUrl', { + type: 'string', + required: true, + readOnly: true, + validate: (value) => isValidUrl(value), + }) + .addAttribute('ticketStatus', { + type: 'string', + required: true, + default: Ticket.DEFAULT_STATUS, + }); + +export default schema.build(); From 1670b2734bc52bcb8058473bcf7a2f2f21a5a4dc Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 22 Jun 2026 21:04:52 +0530 Subject: [PATCH 02/32] docs(data-models): clarify STATUSES.DISCONNECTED v1 scope intent Add JSDoc note explaining that DISCONNECTED unifies the architecture spec's 'disabled' (admin action) and 'error' (irrecoverable failure) into a single terminal state for v1 simplicity; the two-state distinction is deferred to v2 when admin controls are added. Co-authored-by: Cursor --- .../task-management-connection.model.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js index ab2aa69d8..3ff74e840 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -35,7 +35,14 @@ class TaskManagementConnection extends BaseModel { JIRA_CLOUD: 'jira_cloud', }; - /** Connection health statuses. */ + /** + * Connection health statuses. + * + * v1 note: `DISCONNECTED` covers what the architecture spec calls both `disabled` + * (admin-disabled) and `error` (irrecoverable failure). v1 unifies them into a + * single terminal state for simplicity; the spec's two-state distinction is + * deferred to v2 when admin controls are added. + */ static STATUSES = { ACTIVE: 'active', REQUIRES_REAUTH: 'requires_reauth', From ed28a0984ba13cdf674cfbd1959cfd0749186710 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Mon, 22 Jun 2026 22:09:16 +0530 Subject: [PATCH 03/32] test(data-models): fix electrodb timeout and add TaskManagementConnection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test fixes to unblock PR #1702 CI: 1. electrodb-service-construction.test.js: increase timeout from 2000ms (Mocha default) to 10000ms. Adding 2 entities (43 total, up from 41 on main) pushed full-registry Service construction past 2000ms on CI's slower runners. The schemas are valid — the test passes with a realistic timeout (locally: ~1700ms per run). 2. Add task-management-connection.model+collection tests: - isActive() returns true for 'active', false for other statuses - markRequiresReauth() updates status and calls save(); propagates errors - findActiveByOrganizationAndProvider() delegates to the GSI lookup with status='active'; throws ValidationError on bad organizationId or missing provider These tests bring task-management-connection coverage to 100% (model.js + collection.js + index.js), matching the package threshold. Co-authored-by: Cursor --- .../electrodb-service-construction.test.js | 6 +- .../task-management-connection.model.test.js | 180 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js diff --git a/packages/spacecat-shared-data-access/test/unit/models/base/electrodb-service-construction.test.js b/packages/spacecat-shared-data-access/test/unit/models/base/electrodb-service-construction.test.js index e79843244..cd09daa5f 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/base/electrodb-service-construction.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/base/electrodb-service-construction.test.js @@ -39,7 +39,11 @@ import EntityRegistry from '../../../../src/models/base/entity.registry.js'; * does. Any new entity added to `entity.registry.js` is automatically * covered. */ -describe('EntityRegistry — ElectroDB Service construction', () => { +describe('EntityRegistry — ElectroDB Service construction', function () { + // Service construction time scales with entity count — set a generous ceiling + // so CI machines with slower CPUs don't false-positive on a valid schema. + this.timeout(10000); + it('new electrodb.Service(EntityRegistry.getEntities()) succeeds', () => { const entities = EntityRegistry.getEntities(); diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js new file mode 100644 index 000000000..17fa7dabf --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -0,0 +1,180 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { stub } from 'sinon'; + +import TaskManagementConnection from '../../../../src/models/task-management-connection/task-management-connection.model.js'; +import TaskManagementConnectionCollection from '../../../../src/models/task-management-connection/task-management-connection.collection.js'; +import { createElectroMocks } from '../../util.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const VALID_ORG_ID = '22222222-2222-2222-2222-222222222222'; + +describe('TaskManagementConnectionModel', () => { + let instance; + let mockRecord; + + beforeEach(() => { + mockRecord = { + taskManagementConnectionId: '11111111-1111-1111-1111-111111111111', + organizationId: '22222222-2222-2222-2222-222222222222', + provider: TaskManagementConnection.PROVIDERS.JIRA_CLOUD, + status: TaskManagementConnection.STATUSES.ACTIVE, + metadata: { cloudId: '33333333-3333-3333-3333-333333333333', siteUrl: 'https://example.atlassian.net' }, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'system', + }; + + ({ model: instance } = createElectroMocks(TaskManagementConnection, mockRecord)); + }); + + describe('ENTITY_NAME', () => { + it('is TaskManagementConnection', () => { + expect(TaskManagementConnection.ENTITY_NAME).to.equal('TaskManagementConnection'); + }); + }); + + describe('PROVIDERS', () => { + it('defines JIRA_CLOUD provider', () => { + expect(TaskManagementConnection.PROVIDERS.JIRA_CLOUD).to.equal('jira_cloud'); + }); + }); + + describe('STATUSES', () => { + it('defines ACTIVE status', () => { + expect(TaskManagementConnection.STATUSES.ACTIVE).to.equal('active'); + }); + + it('defines REQUIRES_REAUTH status', () => { + expect(TaskManagementConnection.STATUSES.REQUIRES_REAUTH).to.equal('requires_reauth'); + }); + + it('defines DISCONNECTED status', () => { + expect(TaskManagementConnection.STATUSES.DISCONNECTED).to.equal('disconnected'); + }); + }); + + describe('isActive()', () => { + it('returns true when status is active', () => { + expect(instance.isActive()).to.be.true; + }); + + it('returns false when status is requires_reauth', () => { + instance.record.status = TaskManagementConnection.STATUSES.REQUIRES_REAUTH; + expect(instance.isActive()).to.be.false; + }); + + it('returns false when status is disconnected', () => { + instance.record.status = TaskManagementConnection.STATUSES.DISCONNECTED; + expect(instance.isActive()).to.be.false; + }); + }); + + describe('markRequiresReauth()', () => { + it('sets status to requires_reauth and saves', async () => { + const saveStub = stub(instance.patcher, 'save').resolves(); + + await expect(instance.markRequiresReauth()).to.be.fulfilled; + + expect(saveStub).to.have.been.calledOnce; + expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.REQUIRES_REAUTH); + }); + + it('propagates save errors', async () => { + stub(instance.patcher, 'save').rejects(new Error('DB error')); + + await expect(instance.markRequiresReauth()).to.be.rejected; + }); + }); +}); + +describe('TaskManagementConnectionCollection', () => { + let collection; + let mockElectroService; + let mockEntityRegistry; + let mockLogger; + let schema; + + const mockRecord = { + taskManagementConnectionId: '11111111-1111-1111-1111-111111111111', + organizationId: VALID_ORG_ID, + provider: TaskManagementConnection.PROVIDERS.JIRA_CLOUD, + status: TaskManagementConnection.STATUSES.ACTIVE, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + updatedBy: 'system', + }; + + beforeEach(() => { + ({ + mockElectroService, + mockEntityRegistry, + mockLogger, + schema, + } = createElectroMocks(TaskManagementConnection, mockRecord)); + mockElectroService.entities = {}; + collection = new TaskManagementConnectionCollection( + mockElectroService, + mockEntityRegistry, + schema, + mockLogger, + ); + }); + + describe('findActiveByOrganizationAndProvider()', () => { + it('delegates to findByOrganizationIdAndProviderAndStatus with active status', async () => { + collection.findByOrganizationIdAndProviderAndStatus = stub().resolves(mockRecord); + + const result = await collection.findActiveByOrganizationAndProvider( + VALID_ORG_ID, + TaskManagementConnection.PROVIDERS.JIRA_CLOUD, + ); + + expect(result).to.equal(mockRecord); + expect(collection.findByOrganizationIdAndProviderAndStatus).to.have.been.calledOnceWith( + VALID_ORG_ID, + TaskManagementConnection.PROVIDERS.JIRA_CLOUD, + TaskManagementConnection.STATUSES.ACTIVE, + ); + }); + + it('returns null when no active connection exists', async () => { + collection.findByOrganizationIdAndProviderAndStatus = stub().resolves(null); + + const result = await collection.findActiveByOrganizationAndProvider( + VALID_ORG_ID, + TaskManagementConnection.PROVIDERS.JIRA_CLOUD, + ); + + expect(result).to.be.null; + }); + + it('throws ValidationError when organizationId is not a valid UUID', async () => { + await expect( + collection.findActiveByOrganizationAndProvider('not-a-uuid', 'jira_cloud'), + ).to.be.rejectedWith('organizationId must be a valid UUID'); + }); + + it('throws ValidationError when provider is missing', async () => { + await expect( + collection.findActiveByOrganizationAndProvider(VALID_ORG_ID, ''), + ).to.be.rejectedWith('provider is required'); + }); + }); +}); From 9759dd197a2a49e6715a83152542af1d5bd7934d Mon Sep 17 00:00:00 2001 From: ppatwal Date: Tue, 23 Jun 2026 01:07:41 +0530 Subject: [PATCH 04/32] feat(data-models): add ticketProvider and createdBy to Ticket schema Both fields are required by the architecture spec (PR #150) and were previously omitted without justification: - ticketProvider (readOnly): denormalized from the connection so the audit record is self-contained even if the connection is later deleted - createdBy (readOnly): IMS user ID of the person who created the ticket, sourced from the JWT sub claim at request time Also updates ticket.model.js JSDoc to document the two new fields and the remaining intentional v1 deviations (no status_synced_at, no TicketSuggestion bridge model). TypeScript declarations updated with getTicketProvider() and getCreatedBy(). Co-authored-by: Cursor --- .../src/models/ticket/index.d.ts | 2 ++ .../src/models/ticket/ticket.model.js | 19 ++++++++++++++----- .../src/models/ticket/ticket.schema.js | 10 ++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts index 317ba36a3..91533b8aa 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -13,11 +13,13 @@ import type { BaseCollection, BaseModel, Opportunity, TaskManagementConnection } from '../index'; export interface Ticket extends BaseModel { + getCreatedBy(): string; getOpportunityId(): string | undefined; getOrganizationId(): string; getTaskManagementConnectionId(): string; getTicketId(): string; getTicketKey(): string; + getTicketProvider(): string; getTicketStatus(): string; getTicketUrl(): string; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js index 94234af06..727ad1117 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -15,15 +15,24 @@ import BaseModel from '../base/base.model.js'; /** * Ticket — one Jira issue created by ASO via a TaskManagementConnection. * - * Preserves the three provider-side identifiers needed for future operations: - * ticketId — Jira's internal numeric ID (data.id). Required for PATCH/update calls. - * ticketKey — Human-readable issue key, e.g. 'ASO-42'. Used in UI links. - * ticketUrl — Direct browser URL, e.g. 'https://acme.atlassian.net/browse/ASO-42'. + * Preserves the provider-side identifiers needed for future operations: + * ticketId — Jira's internal numeric ID (data.id). Required for PATCH/update calls. + * ticketKey — Human-readable issue key, e.g. 'ASO-42'. Used in UI links. + * ticketUrl — Direct browser URL, e.g. 'https://acme.atlassian.net/browse/ASO-42'. + * ticketProvider — Provider that created the ticket (e.g. 'jira_cloud'). Denormalized from + * the connection so the audit record is self-contained even if the connection + * is later deleted. + * createdBy — IMS user ID of the person who initiated ticket creation (JWT sub claim). * * ticketStatus mirrors the Jira column heading ('To Do', 'In Progress', 'Done'). - * It is updated asynchronously by a future status-sync job; write directly via + * It is updated asynchronously by a future status-sync job (v2); write directly via * setTicketStatus() + save() only from that job. * + * v1 scope — intentional deviations from the architecture spec: + * - No status_synced_at: Jira webhook status sync is a v2 feature. + * - No TicketSuggestion bridge model: v1 enforces 1:1 via opportunityId on the Ticket + * itself; the M:N ticket_suggestions table is deferred to v2 (grouped ticket creation). + * * @class Ticket * @extends BaseModel */ diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index 3735a5029..a10370482 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -43,6 +43,16 @@ const schema = new SchemaBuilder(Ticket, TicketCollection) type: 'string', required: true, default: Ticket.DEFAULT_STATUS, + }) + .addAttribute('ticketProvider', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('createdBy', { + type: 'string', + required: true, + readOnly: true, }); export default schema.build(); From fd9d00febcec201c9e119dbb519771c0efc40a7d Mon Sep 17 00:00:00 2001 From: ppatwal Date: Tue, 23 Jun 2026 01:47:18 +0530 Subject: [PATCH 05/32] feat(data-models): add TicketSuggestion bridge model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ticket_suggestions bridge table model per architecture spec (PR #150). Links a specific Suggestion to the Ticket created for it. Key design decisions: - UNIQUE (suggestion_id) enforced at the DB level (SQL migration #3) prevents the same suggestion from being ticketed twice in v1. In v2, relax to UNIQUE (suggestion_id, ticket_id) for grouped mode. - suggestionId is a logical TEXT reference — suggestions live in DynamoDB/ElectroDB, not PostgreSQL, so no Postgres FK is declared. - GSI on suggestionId auto-generates findBySuggestionId() for the "has this suggestion already been ticketed?" check. - opportunityId is denormalized on the bridge row to avoid a JOIN through the suggestion store for opportunity-scoped queries. Co-authored-by: Cursor --- .../src/models/base/entity.registry.js | 3 ++ .../src/models/index.js | 1 + .../src/models/ticket-suggestion/index.d.ts | 26 +++++++++++ .../src/models/ticket-suggestion/index.js | 19 ++++++++ .../ticket-suggestion.collection.js | 29 +++++++++++++ .../ticket-suggestion.model.js | 36 ++++++++++++++++ .../ticket-suggestion.schema.js | 43 +++++++++++++++++++ 7 files changed, 157 insertions(+) create mode 100644 packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index b8f68dd5b..78e5a6ab0 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -57,6 +57,7 @@ import AccessGrantLogCollection from '../access-grant-log/access-grant-log.colle import SiteImsOrgAccessCollection from '../site-ims-org-access/site-ims-org-access.collection.js'; import TaskManagementConnectionCollection from '../task-management-connection/task-management-connection.collection.js'; import TicketCollection from '../ticket/ticket.collection.js'; +import TicketSuggestionCollection from '../ticket-suggestion/ticket-suggestion.collection.js'; import ApiKeySchema from '../api-key/api-key.schema.js'; import AsyncJobSchema from '../async-job/async-job.schema.js'; @@ -101,6 +102,7 @@ import AccessGrantLogSchema from '../access-grant-log/access-grant-log.schema.js import SiteImsOrgAccessSchema from '../site-ims-org-access/site-ims-org-access.schema.js'; import TaskManagementConnectionSchema from '../task-management-connection/task-management-connection.schema.js'; import TicketSchema from '../ticket/ticket.schema.js'; +import TicketSuggestionSchema from '../ticket-suggestion/ticket-suggestion.schema.js'; /** * EntityRegistry - A registry class responsible for managing entities, their schema and collection. @@ -240,6 +242,7 @@ EntityRegistry.registerEntity(AccessGrantLogSchema, AccessGrantLogCollection); EntityRegistry.registerEntity(SiteImsOrgAccessSchema, SiteImsOrgAccessCollection); EntityRegistry.registerEntity(TaskManagementConnectionSchema, TaskManagementConnectionCollection); EntityRegistry.registerEntity(TicketSchema, TicketCollection); +EntityRegistry.registerEntity(TicketSuggestionSchema, TicketSuggestionCollection); EntityRegistry.defaultEntities = { ...EntityRegistry.entities }; export default EntityRegistry; diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index 0346235b4..bcd926cc8 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -55,3 +55,4 @@ export * from './sentiment-guideline/index.js'; export * from './sentiment-topic/index.js'; export * from './task-management-connection/index.js'; export * from './ticket/index.js'; +export * from './ticket-suggestion/index.js'; diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts new file mode 100644 index 000000000..902b56d61 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Ticket } from '../index'; + +export interface TicketSuggestion extends BaseModel { + getCreatedBy(): string; + getOpportunityId(): string | undefined; + getSuggestionId(): string; + getTicketId(): string; + getTicket(): Promise; +} + +export interface TicketSuggestionCollection extends BaseCollection { + allByTicketId(ticketId: string): Promise; + findBySuggestionId(suggestionId: string): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js new file mode 100644 index 000000000..278dc0b47 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import TicketSuggestion from './ticket-suggestion.model.js'; +import TicketSuggestionCollection from './ticket-suggestion.collection.js'; + +export { + TicketSuggestion, + TicketSuggestionCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js new file mode 100644 index 000000000..9b803f7e8 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * TicketSuggestionCollection — manages TicketSuggestion bridge records. + * + * Auto-generated query methods (via schema references and indexes): + * allByTicketId(ticketId) — all suggestions linked to a ticket + * findBySuggestionId(suggestionId) — look up whether a suggestion has been ticketed + * + * @class TicketSuggestionCollection + * @extends BaseCollection + */ +class TicketSuggestionCollection extends BaseCollection { + static COLLECTION_NAME = 'TicketSuggestionCollection'; +} + +export default TicketSuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js new file mode 100644 index 000000000..224445ac8 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * TicketSuggestion — bridge record linking a Suggestion to the Ticket created for it. + * + * Enforces 1:1 in v1: `UNIQUE (suggestion_id)` at the DB level prevents the same + * suggestion from being ticketed twice. In v2, the constraint relaxes to + * `UNIQUE (suggestion_id, ticket_id)` to support grouped ticket creation (M:N). + * + * `suggestionId` is a logical reference (stored as TEXT), not a Postgres FK. + * Suggestions live in DynamoDB/ElectroDB — there is no Postgres FK to enforce. + * Application-layer validation ensures the suggestion exists before creating this record. + * + * `opportunityId` is stored for direct opportunity-scoped queries without requiring + * a JOIN through the Ticket row. + * + * @class TicketSuggestion + * @extends BaseModel + */ +class TicketSuggestion extends BaseModel { + static ENTITY_NAME = 'TicketSuggestion'; +} + +export default TicketSuggestion; diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js new file mode 100644 index 000000000..bfa6dcef9 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import SchemaBuilder from '../base/schema.builder.js'; +import TicketSuggestion from './ticket-suggestion.model.js'; +import TicketSuggestionCollection from './ticket-suggestion.collection.js'; + +// GSI on suggestionId powers findBySuggestionId() — used to check if a +// suggestion has already been ticketed before attempting to create a duplicate. +const schema = new SchemaBuilder(TicketSuggestion, TicketSuggestionCollection) + .addReference('belongs_to', 'Ticket') + .addAttribute('suggestionId', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('opportunityId', { + type: 'string', + required: false, + readOnly: true, + }) + .addAttribute('createdBy', { + type: 'string', + required: true, + readOnly: true, + }) + .addIndex( + { composite: ['suggestionId'] }, + { composite: [] }, + ); + +export default schema.build(); From 956066e0c631a2df01e7de917c3c1c0b06d457a4 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Tue, 23 Jun 2026 14:37:38 +0530 Subject: [PATCH 06/32] fix(data-models): add disabled, error statuses + markDisabled/markError/markDisconnected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns TaskManagementConnection with the full spec (PR #150) status enum: active | disabled | requires_reauth | error | disconnected Previously only active, requires_reauth, disconnected were defined, missing spec's 'disabled' (admin-disabled) and 'error' (repeated API failures). 'disconnected' is a v1 soft-delete extension — the spec hard-deletes the row; v1 keeps it with status='disconnected' for audit history. New methods: markDisabled(), markError(), markDisconnected(). Tests updated to cover all five statuses and new mark* methods. Co-authored-by: Cursor --- .../task-management-connection.model.js | 54 +++++++++++++++---- .../task-management-connection.model.test.js | 51 ++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js index 3ff74e840..2e6bffe28 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -16,10 +16,12 @@ import BaseModel from '../base/base.model.js'; * TaskManagementConnection — one OAuth connection from an organization to a * task-management provider (e.g. Jira Cloud). * - * Status lifecycle: + * Status lifecycle (per architecture spec): * active → tokens are valid, tickets can be created - * requires_reauth → refresh token expired, user must reconnect - * disconnected → explicitly disconnected by the user + * disabled → admin-disabled; no tickets until re-enabled + * requires_reauth → refresh token expired/revoked, user must reconnect + * error → repeated API failures; connection degraded + * disconnected → explicitly deleted by the user (v1 soft-delete) * * Provider-specific config (cloudId, siteUrl, scopeKey) lives in `metadata` * as jsonb so new fields never require a schema change. @@ -36,16 +38,17 @@ class TaskManagementConnection extends BaseModel { }; /** - * Connection health statuses. + * Connection health statuses (per architecture spec PR #150). * - * v1 note: `DISCONNECTED` covers what the architecture spec calls both `disabled` - * (admin-disabled) and `error` (irrecoverable failure). v1 unifies them into a - * single terminal state for simplicity; the spec's two-state distinction is - * deferred to v2 when admin controls are added. + * DISCONNECTED is a v1 extension — it represents the "deleted" lifecycle + * event as a soft-delete so audit history is preserved. The spec hard-deletes + * the row; v1 keeps it with status='disconnected' until a GC job removes it. */ static STATUSES = { ACTIVE: 'active', + DISABLED: 'disabled', REQUIRES_REAUTH: 'requires_reauth', + ERROR: 'error', DISCONNECTED: 'disconnected', }; @@ -60,8 +63,8 @@ class TaskManagementConnection extends BaseModel { /** * Marks the connection as requiring re-authentication (e.g. after a failed - * token refresh). Persists the status immediately so other services see the - * degraded state without waiting for the next GC cycle. + * token refresh). Persists immediately so other services see the degraded + * state without waiting for the next GC cycle. * * @returns {Promise} */ @@ -69,6 +72,37 @@ class TaskManagementConnection extends BaseModel { this.setStatus(TaskManagementConnection.STATUSES.REQUIRES_REAUTH); return this.save(); } + + /** + * Marks the connection as disabled (e.g. admin-disabled). + * + * @returns {Promise} + */ + async markDisabled() { + this.setStatus(TaskManagementConnection.STATUSES.DISABLED); + return this.save(); + } + + /** + * Marks the connection as in an error state (repeated API failures). + * + * @returns {Promise} + */ + async markError() { + this.setStatus(TaskManagementConnection.STATUSES.ERROR); + return this.save(); + } + + /** + * Marks the connection as disconnected (user-initiated soft-delete). + * v1 preserves the row for audit; a GC job handles eventual hard deletion. + * + * @returns {Promise} + */ + async markDisconnected() { + this.setStatus(TaskManagementConnection.STATUSES.DISCONNECTED); + return this.save(); + } } export default TaskManagementConnection; diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js index 17fa7dabf..354ae2191 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -60,10 +60,18 @@ describe('TaskManagementConnectionModel', () => { expect(TaskManagementConnection.STATUSES.ACTIVE).to.equal('active'); }); + it('defines DISABLED status', () => { + expect(TaskManagementConnection.STATUSES.DISABLED).to.equal('disabled'); + }); + it('defines REQUIRES_REAUTH status', () => { expect(TaskManagementConnection.STATUSES.REQUIRES_REAUTH).to.equal('requires_reauth'); }); + it('defines ERROR status', () => { + expect(TaskManagementConnection.STATUSES.ERROR).to.equal('error'); + }); + it('defines DISCONNECTED status', () => { expect(TaskManagementConnection.STATUSES.DISCONNECTED).to.equal('disconnected'); }); @@ -74,11 +82,21 @@ describe('TaskManagementConnectionModel', () => { expect(instance.isActive()).to.be.true; }); + it('returns false when status is disabled', () => { + instance.record.status = TaskManagementConnection.STATUSES.DISABLED; + expect(instance.isActive()).to.be.false; + }); + it('returns false when status is requires_reauth', () => { instance.record.status = TaskManagementConnection.STATUSES.REQUIRES_REAUTH; expect(instance.isActive()).to.be.false; }); + it('returns false when status is error', () => { + instance.record.status = TaskManagementConnection.STATUSES.ERROR; + expect(instance.isActive()).to.be.false; + }); + it('returns false when status is disconnected', () => { instance.record.status = TaskManagementConnection.STATUSES.DISCONNECTED; expect(instance.isActive()).to.be.false; @@ -101,6 +119,39 @@ describe('TaskManagementConnectionModel', () => { await expect(instance.markRequiresReauth()).to.be.rejected; }); }); + + describe('markDisabled()', () => { + it('sets status to disabled and saves', async () => { + const saveStub = stub(instance.patcher, 'save').resolves(); + + await expect(instance.markDisabled()).to.be.fulfilled; + + expect(saveStub).to.have.been.calledOnce; + expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.DISABLED); + }); + }); + + describe('markError()', () => { + it('sets status to error and saves', async () => { + const saveStub = stub(instance.patcher, 'save').resolves(); + + await expect(instance.markError()).to.be.fulfilled; + + expect(saveStub).to.have.been.calledOnce; + expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.ERROR); + }); + }); + + describe('markDisconnected()', () => { + it('sets status to disconnected and saves', async () => { + const saveStub = stub(instance.patcher, 'save').resolves(); + + await expect(instance.markDisconnected()).to.be.fulfilled; + + expect(saveStub).to.have.been.calledOnce; + expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.DISCONNECTED); + }); + }); }); describe('TaskManagementConnectionCollection', () => { From c259842a44e4555ce77148a4478e521f632e699a Mon Sep 17 00:00:00 2001 From: ppatwal Date: Tue, 23 Jun 2026 14:40:41 +0530 Subject: [PATCH 07/32] feat(data-models): add metadata validation + export validateMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec §Metadata Validation Strategy: - metadata-validator.js: per-provider schemas for jira_cloud (cloudId UUID, siteName required, siteUrl optional https) and jira_corp (baseUrl https). Enforces additionalProperties: false. Throws ValidationError on any violation. - Unknown providers are rejected — no silent passthrough. - validateMetadata() exported from the task-management-connection index so auth-service (connection creation path) can call it before any DB write. - Unit tests: 10 cases covering required fields, UUID format, https scheme, extra properties, and unknown provider. Co-authored-by: Cursor --- .../task-management-connection/index.js | 2 + .../metadata-validator.js | 90 ++++++++++++++++++ .../metadata-validator.test.js | 91 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js index 18bf2b804..4404b0d56 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js @@ -12,8 +12,10 @@ import TaskManagementConnection from './task-management-connection.model.js'; import TaskManagementConnectionCollection from './task-management-connection.collection.js'; +import { validateMetadata } from './metadata-validator.js'; export { TaskManagementConnection, TaskManagementConnectionCollection, + validateMetadata, }; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js new file mode 100644 index 000000000..b6955a881 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ValidationError } from '../../errors/index.js'; + +// UUID regex used by the spec for cloudId format validation. +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** + * Per-provider metadata schemas (mirrors spec §Metadata Validation Strategy). + * + * Each schema defines: + * required — fields that MUST be present + * properties — per-field validators (functions that return an error string or null) + * allowed — exhaustive list of permitted keys (enforces additionalProperties: false) + * + * Design: plain JS instead of ajv so no new production dependency is needed. + * The logic is equivalent to the spec's JSON Schema: required fields, a UUID + * pattern constraint, and additionalProperties: false. + */ +const METADATA_SCHEMAS = { + jira_cloud: { + required: ['cloudId', 'siteName'], + allowed: new Set(['cloudId', 'siteName', 'siteUrl']), + properties: { + cloudId: (v) => (UUID_REGEX.test(v) ? null : 'cloudId must be a valid UUID'), + siteName: (v) => (typeof v === 'string' && v.length > 0 ? null : 'siteName must be a non-empty string'), + siteUrl: (v) => (v === undefined || (typeof v === 'string' && v.startsWith('https://')) ? null : 'siteUrl must start with https://'), + }, + }, + jira_corp: { + required: ['baseUrl'], + allowed: new Set(['baseUrl', 'projectCategory']), + properties: { + baseUrl: (v) => (typeof v === 'string' && v.startsWith('https://') ? null : 'baseUrl must be a valid https:// URI'), + projectCategory: (v) => (v === undefined || typeof v === 'string' ? null : 'projectCategory must be a string'), + }, + }, +}; + +/** + * Validates provider-specific connection metadata before a DB write. + * + * Called on connection INSERT and UPDATE (auth-service path and future edit API). + * Unknown providers are rejected — no silent passthrough. + * + * @param {string} provider - e.g. 'jira_cloud' + * @param {object} metadata - The JSONB metadata object to validate + * @throws {ValidationError} On missing fields, wrong types, unknown keys, or unknown provider + */ +export function validateMetadata(provider, metadata) { + const schema = METADATA_SCHEMAS[provider]; + if (!schema) { + // Providers without a schema (asana, workfront) are v2 placeholders — reject + // all writes until a schema is defined so incomplete data never reaches the DB. + throw new ValidationError(`No metadata schema for provider: ${provider}`); + } + + const { required, allowed, properties } = schema; + + for (const field of required) { + if (metadata[field] === undefined || metadata[field] === null) { + throw new ValidationError(`metadata.${field} is required`); + } + } + + for (const [field, validate] of Object.entries(properties)) { + if (metadata[field] !== undefined) { + const err = validate(metadata[field]); + if (err) { + throw new ValidationError(`Invalid metadata: ${err}`); + } + } + } + + // additionalProperties: false — reject any key not in the allowed set + const extraKeys = Object.keys(metadata).filter((k) => !allowed.has(k)); + if (extraKeys.length > 0) { + throw new ValidationError(`Unexpected metadata properties for ${provider}: ${extraKeys.join(', ')}`); + } +} diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js new file mode 100644 index 000000000..d071a107e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import { validateMetadata } from '../../../../src/models/task-management-connection/metadata-validator.js'; + +const VALID_CLOUD_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +describe('validateMetadata()', () => { + describe('jira_cloud', () => { + it('accepts valid jira_cloud metadata', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + siteName: 'My Jira', + siteUrl: 'https://my-org.atlassian.net', + })).not.to.throw(); + }); + + it('accepts minimal metadata (cloudId + siteName only)', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + siteName: 'My Jira', + })).not.to.throw(); + }); + + it('rejects missing cloudId', () => { + expect(() => validateMetadata('jira_cloud', { siteName: 'My Jira' })) + .to.throw('metadata.cloudId is required'); + }); + + it('rejects missing siteName', () => { + expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID })) + .to.throw('metadata.siteName is required'); + }); + + it('rejects non-UUID cloudId', () => { + expect(() => validateMetadata('jira_cloud', { cloudId: 'not-a-uuid', siteName: 'x' })) + .to.throw('cloudId must be a valid UUID'); + }); + + it('rejects siteUrl without https scheme', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + siteName: 'x', + siteUrl: 'http://insecure.atlassian.net', + })).to.throw('siteUrl must start with https://'); + }); + + it('rejects extra properties', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + siteName: 'x', + unexpected: 'field', + })).to.throw('Unexpected metadata properties'); + }); + }); + + describe('jira_corp', () => { + it('accepts valid jira_corp metadata', () => { + expect(() => validateMetadata('jira_corp', { + baseUrl: 'https://jira.corp.example.com', + })).not.to.throw(); + }); + + it('rejects missing baseUrl', () => { + expect(() => validateMetadata('jira_corp', {})) + .to.throw('metadata.baseUrl is required'); + }); + + it('rejects non-https baseUrl', () => { + expect(() => validateMetadata('jira_corp', { baseUrl: 'http://jira.corp.example.com' })) + .to.throw('baseUrl must be a valid https:// URI'); + }); + }); + + describe('unknown provider', () => { + it('throws for unknown provider', () => { + expect(() => validateMetadata('asana', { anything: 'goes' })) + .to.throw('No metadata schema for provider: asana'); + }); + }); +}); From 7066e87a5de5db1f5fa2a891e2b344d15476a4c2 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Wed, 24 Jun 2026 06:35:54 +0530 Subject: [PATCH 08/32] fix(data-models): align task-management models with PR #720 and solution doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix index.d.ts: add missing `export type * from './ticket-suggestion'` - Fix TaskManagementConnection index.d.ts: add markDisabled/markError/markDisconnected method signatures and new getConnectedBy/getDisplayName/getInstanceUrl getters - Fix metadata-validator: jira_cloud metadata is now { cloudId, scopes? } per PR #720 (mysticat-data-service) — siteName/siteUrl moved to dedicated display_name and instance_url columns, not stored in metadata JSONB - Fix metadata-validator: remove dead v === undefined branch in jira_corp projectCategory validator; add tests for all uncovered branches (100% coverage) - Fix task-management-connection.schema.js: add instanceUrl, displayName, connectedBy attributes matching NOT NULL columns in PR #720 Postgres schema - Fix ticket.model.js: remove stale "No TicketSuggestion bridge model" comment — TicketSuggestion IS included in this PR - Update model test fixture to use aligned metadata { cloudId, scopes } Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/index.d.ts | 1 + .../task-management-connection/index.d.ts | 9 +++ .../metadata-validator.js | 19 +++-- .../task-management-connection.schema.js | 26 +++++++ .../src/models/ticket/ticket.model.js | 7 +- .../metadata-validator.test.js | 74 ++++++++++++++----- .../task-management-connection.model.test.js | 5 +- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/index.d.ts b/packages/spacecat-shared-data-access/src/models/index.d.ts index b542459b7..6d491dc2f 100755 --- a/packages/spacecat-shared-data-access/src/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/index.d.ts @@ -42,6 +42,7 @@ export type * from './sentiment-guideline'; export type * from './sentiment-topic'; export type * from './task-management-connection'; export type * from './ticket'; +export type * from './ticket-suggestion'; export type * from './site'; export type * from './site-candidate'; export type * from './site-enrollment'; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index e3d4669bd..97e92a789 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -20,7 +20,16 @@ export interface TaskManagementConnection extends BaseModel { * so the UI can prompt the user to reconnect. */ markRequiresReauth(): Promise; + /** Persists status = 'disabled'. */ + markDisabled(): Promise; + /** Persists status = 'error' after repeated API failures. */ + markError(): Promise; + /** Persists status = 'disconnected' (soft-delete on user revoke). */ + markDisconnected(): Promise; + getConnectedBy(): string; + getDisplayName(): string; + getInstanceUrl(): string; getMetadata(): object; getOrganizationId(): string; getProvider(): string; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js index b6955a881..bdd71dc1b 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -29,12 +29,21 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 */ const METADATA_SCHEMAS = { jira_cloud: { - required: ['cloudId', 'siteName'], - allowed: new Set(['cloudId', 'siteName', 'siteUrl']), + // Aligns with mysticat-data-service PR #720: + // - cloudId (required) is Atlassian's stable workspace UUID used to build API URLs; + // enforced as UUID format by a DB CHECK constraint. + // - scopes (optional) is the array from the Atlassian accessible-resources response; + // stored so permission gaps can be detected without re-calling Atlassian (e.g. missing + // manage:jira-webhook when v2 webhooks land). + // - siteName and siteUrl are NOT stored in metadata — they live in the dedicated + // display_name and instance_url columns (see PR #720 mysticat-data-service). + required: ['cloudId'], + allowed: new Set(['cloudId', 'scopes']), properties: { cloudId: (v) => (UUID_REGEX.test(v) ? null : 'cloudId must be a valid UUID'), - siteName: (v) => (typeof v === 'string' && v.length > 0 ? null : 'siteName must be a non-empty string'), - siteUrl: (v) => (v === undefined || (typeof v === 'string' && v.startsWith('https://')) ? null : 'siteUrl must start with https://'), + scopes: (v) => (Array.isArray(v) && v.every((s) => typeof s === 'string') + ? null + : 'scopes must be an array of strings'), }, }, jira_corp: { @@ -42,7 +51,7 @@ const METADATA_SCHEMAS = { allowed: new Set(['baseUrl', 'projectCategory']), properties: { baseUrl: (v) => (typeof v === 'string' && v.startsWith('https://') ? null : 'baseUrl must be a valid https:// URI'), - projectCategory: (v) => (v === undefined || typeof v === 'string' ? null : 'projectCategory must be a string'), + projectCategory: (v) => (typeof v === 'string' ? null : 'projectCategory must be a string'), }, }, }; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 0e18e259d..c0c338170 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -32,6 +32,32 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect required: true, default: TaskManagementConnection.STATUSES.ACTIVE, }) + // display_name column (PR #720): human-readable site name from Atlassian accessible-resources. + // Set by auth-service at OAuth callback time; never user-provided. + .addAttribute('displayName', { + type: 'string', + required: true, + readOnly: true, + validate: (value) => typeof value === 'string' && value.length > 0 && value.length <= 255, + }) + // instance_url column (PR #720): Jira site URL (https://*.atlassian.net). + // Display-only — never used as a request target (SSRF protection: all outbound + // calls route through the fixed Atlassian gateway keyed on cloudId from metadata). + .addAttribute('instanceUrl', { + type: 'string', + required: true, + readOnly: true, + validate: (value) => typeof value === 'string' && value.startsWith('https://'), + }) + // connected_by column (PR #720): IMS user ID (JWT sub) of the person who completed OAuth. + .addAttribute('connectedBy', { + type: 'string', + required: true, + readOnly: true, + }) + // metadata JSONB (PR #720): provider-specific structured data. + // jira_cloud: { cloudId (required UUID), scopes (optional string array) }. + // siteName and siteUrl are NOT stored here — they live in displayName/instanceUrl above. .addAttribute('metadata', { type: 'any', required: true, diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js index 727ad1117..a086ce0aa 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -29,9 +29,10 @@ import BaseModel from '../base/base.model.js'; * setTicketStatus() + save() only from that job. * * v1 scope — intentional deviations from the architecture spec: - * - No status_synced_at: Jira webhook status sync is a v2 feature. - * - No TicketSuggestion bridge model: v1 enforces 1:1 via opportunityId on the Ticket - * itself; the M:N ticket_suggestions table is deferred to v2 (grouped ticket creation). + * - No status_synced_at: Jira webhook status sync is a v2 feature. UI links to ticketUrl + * for live status. + * - opportunityId is kept as an optional FK on Ticket in addition to the TicketSuggestion + * bridge so single-suggestion queries don't require a JOIN. * * @class Ticket * @extends BaseModel diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js index d071a107e..5de6dcefe 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -17,60 +17,86 @@ const VALID_CLOUD_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; describe('validateMetadata()', () => { describe('jira_cloud', () => { - it('accepts valid jira_cloud metadata', () => { + it('accepts minimal metadata (cloudId only)', () => { expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID, - siteName: 'My Jira', - siteUrl: 'https://my-org.atlassian.net', })).not.to.throw(); }); - it('accepts minimal metadata (cloudId + siteName only)', () => { + it('accepts metadata with optional scopes array', () => { expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID, - siteName: 'My Jira', + scopes: ['read:jira-work', 'write:jira-work'], })).not.to.throw(); }); - it('rejects missing cloudId', () => { - expect(() => validateMetadata('jira_cloud', { siteName: 'My Jira' })) - .to.throw('metadata.cloudId is required'); + it('accepts empty scopes array', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + scopes: [], + })).not.to.throw(); }); - it('rejects missing siteName', () => { - expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID })) - .to.throw('metadata.siteName is required'); + it('rejects missing cloudId', () => { + expect(() => validateMetadata('jira_cloud', {})) + .to.throw('metadata.cloudId is required'); }); it('rejects non-UUID cloudId', () => { - expect(() => validateMetadata('jira_cloud', { cloudId: 'not-a-uuid', siteName: 'x' })) + expect(() => validateMetadata('jira_cloud', { cloudId: 'not-a-uuid' })) .to.throw('cloudId must be a valid UUID'); }); - it('rejects siteUrl without https scheme', () => { + it('rejects non-array scopes', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + scopes: 'read:jira-work', + })).to.throw('scopes must be an array of strings'); + }); + + it('rejects scopes array with non-string elements', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + scopes: [42], + })).to.throw('scopes must be an array of strings'); + }); + + it('rejects siteName in metadata (must use displayName column)', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + siteName: 'My Jira', + })).to.throw('Unexpected metadata properties'); + }); + + it('rejects siteUrl in metadata (must use instanceUrl column)', () => { expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID, - siteName: 'x', - siteUrl: 'http://insecure.atlassian.net', - })).to.throw('siteUrl must start with https://'); + siteUrl: 'https://my-org.atlassian.net', + })).to.throw('Unexpected metadata properties'); }); it('rejects extra properties', () => { expect(() => validateMetadata('jira_cloud', { cloudId: VALID_CLOUD_ID, - siteName: 'x', unexpected: 'field', })).to.throw('Unexpected metadata properties'); }); }); describe('jira_corp', () => { - it('accepts valid jira_corp metadata', () => { + it('accepts valid jira_corp metadata with baseUrl only', () => { expect(() => validateMetadata('jira_corp', { baseUrl: 'https://jira.corp.example.com', })).not.to.throw(); }); + it('accepts valid jira_corp metadata with optional projectCategory', () => { + expect(() => validateMetadata('jira_corp', { + baseUrl: 'https://jira.corp.example.com', + projectCategory: 'Engineering', + })).not.to.throw(); + }); + it('rejects missing baseUrl', () => { expect(() => validateMetadata('jira_corp', {})) .to.throw('metadata.baseUrl is required'); @@ -80,6 +106,18 @@ describe('validateMetadata()', () => { expect(() => validateMetadata('jira_corp', { baseUrl: 'http://jira.corp.example.com' })) .to.throw('baseUrl must be a valid https:// URI'); }); + + it('rejects non-string baseUrl', () => { + expect(() => validateMetadata('jira_corp', { baseUrl: 42 })) + .to.throw('baseUrl must be a valid https:// URI'); + }); + + it('rejects non-string projectCategory', () => { + expect(() => validateMetadata('jira_corp', { + baseUrl: 'https://jira.corp.example.com', + projectCategory: 99, + })).to.throw('projectCategory must be a string'); + }); }); describe('unknown provider', () => { diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js index 354ae2191..88cfc7d14 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -34,7 +34,10 @@ describe('TaskManagementConnectionModel', () => { organizationId: '22222222-2222-2222-2222-222222222222', provider: TaskManagementConnection.PROVIDERS.JIRA_CLOUD, status: TaskManagementConnection.STATUSES.ACTIVE, - metadata: { cloudId: '33333333-3333-3333-3333-333333333333', siteUrl: 'https://example.atlassian.net' }, + displayName: 'My Jira Site', + instanceUrl: 'https://my-org.atlassian.net', + connectedBy: 'ims-user-id-123', + metadata: { cloudId: '33333333-3333-3333-3333-333333333333', scopes: ['read:jira-work', 'write:jira-work'] }, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z', updatedBy: 'system', From 1eadf949525929b3f5f245daf2e448bc4df71106 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Wed, 24 Jun 2026 06:39:36 +0530 Subject: [PATCH 09/32] fix(data-models): correct stale metadata comment in TaskManagementConnection metadata JSONB is { cloudId, scopes } per PR #720, not { cloudId, siteUrl, scopeKey }. Display fields live in instanceUrl/displayName columns. Co-Authored-By: Claude Sonnet 4.6 --- .../task-management-connection.model.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js index 2e6bffe28..2ffc651e8 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -23,8 +23,8 @@ import BaseModel from '../base/base.model.js'; * error → repeated API failures; connection degraded * disconnected → explicitly deleted by the user (v1 soft-delete) * - * Provider-specific config (cloudId, siteUrl, scopeKey) lives in `metadata` - * as jsonb so new fields never require a schema change. + * Provider-specific config lives in `metadata` as jsonb (jira_cloud: { cloudId, scopes }). + * Display fields (siteName, siteUrl) live in the dedicated displayName/instanceUrl columns. * * @class TaskManagementConnection * @extends BaseModel From 93ee3594f8e95ace60623fb6919addb82233249d Mon Sep 17 00:00:00 2001 From: ppatwal Date: Wed, 24 Jun 2026 16:56:35 +0530 Subject: [PATCH 10/32] feat(data-models): add externalInstanceId to TaskManagementConnection schema Adds the externalInstanceId attribute (required, readOnly) to align with the new external_instance_id column in mysticat-data-service PR #720. - Add externalInstanceId attribute to schema (string, required, readOnly) - Add externalInstanceId to mockRecord in model test - Add schema test file covering the new attribute Co-Authored-By: Claude Sonnet 4.6 --- .../task-management-connection.schema.js | 10 +++ .../task-management-connection.model.test.js | 1 + .../task-management-connection.schema.test.js | 69 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index c0c338170..847ab6a33 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -55,6 +55,16 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect required: true, readOnly: true, }) + // external_instance_id column: provider-stable identifier for the remote workspace. + // jira_cloud → Atlassian cloudId UUID; jira_corp → normalized baseUrl (v2). + // Used as the dedup key in UNIQUE(organization_id, provider, external_instance_id). + // Never changes after connection is created — readOnly. + .addAttribute('externalInstanceId', { + type: 'string', + required: true, + readOnly: true, + validate: (value) => typeof value === 'string' && value.length > 0, + }) // metadata JSONB (PR #720): provider-specific structured data. // jira_cloud: { cloudId (required UUID), scopes (optional string array) }. // siteName and siteUrl are NOT stored here — they live in displayName/instanceUrl above. diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js index 88cfc7d14..ab9eb0440 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -34,6 +34,7 @@ describe('TaskManagementConnectionModel', () => { organizationId: '22222222-2222-2222-2222-222222222222', provider: TaskManagementConnection.PROVIDERS.JIRA_CLOUD, status: TaskManagementConnection.STATUSES.ACTIVE, + externalInstanceId: '33333333-3333-3333-3333-333333333333', displayName: 'My Jira Site', instanceUrl: 'https://my-org.atlassian.net', connectedBy: 'ims-user-id-123', diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js new file mode 100644 index 000000000..55b08560b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import taskManagementConnectionSchema from '../../../../src/models/task-management-connection/task-management-connection.schema.js'; + +describe('TaskManagementConnection Schema', () => { + let attributes; + + before(() => { + attributes = taskManagementConnectionSchema.getAttributes(); + }); + + describe('externalInstanceId attribute', () => { + it('exists', () => { + expect(attributes.externalInstanceId).to.exist; + }); + + it('is required', () => { + expect(attributes.externalInstanceId.required).to.be.true; + }); + + it('is readOnly', () => { + expect(attributes.externalInstanceId.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.externalInstanceId.type).to.equal('string'); + }); + + it('validates non-empty string', () => { + expect(attributes.externalInstanceId.validate('some-id')).to.be.true; + }); + + it('rejects empty string', () => { + expect(attributes.externalInstanceId.validate('')).to.be.false; + }); + }); + + describe('connectedBy attribute', () => { + it('is required and readOnly', () => { + expect(attributes.connectedBy.required).to.be.true; + expect(attributes.connectedBy.readOnly).to.be.true; + }); + }); + + describe('displayName attribute', () => { + it('is required and readOnly', () => { + expect(attributes.displayName.required).to.be.true; + expect(attributes.displayName.readOnly).to.be.true; + }); + }); + + describe('instanceUrl attribute', () => { + it('is required and readOnly', () => { + expect(attributes.instanceUrl.required).to.be.true; + expect(attributes.instanceUrl.readOnly).to.be.true; + }); + }); +}); From ee4497330fc7198f1286af77561370312957ced2 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Wed, 24 Jun 2026 18:50:47 +0530 Subject: [PATCH 11/32] feat(data-models): add OAuthNonce model and fix schema correctness issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OAuthNonce model (schema, model, collection, index) with custom delete({ nonce }) method for atomic single-use replay prevention - Register OAuthNonce in EntityRegistry and models/index.js - Fix TicketSuggestion.opportunityId: required: false → required: true (matches DB NOT NULL constraint; avoids opaque 500 on create) - Fix Ticket.ticketStatus: required: true → required: false, default: null (DB column is nullable; status is set by async sync job, not on create) - Remove Ticket.DEFAULT_STATUS = 'To Do' (Jira-specific constant no longer referenced; provider defaults belong in the client layer) - Add TODO tracking lastUsedAt/errorMessage missing from TaskManagementConnection schema (follow-up) Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/base/entity.registry.js | 3 + .../src/models/index.js | 1 + .../src/models/oauth-nonce/index.js | 19 +++++ .../oauth-nonce/oauth-nonce.collection.js | 46 +++++++++++ .../models/oauth-nonce/oauth-nonce.model.js | 35 ++++++++ .../models/oauth-nonce/oauth-nonce.schema.js | 40 ++++++++++ .../task-management-connection.schema.js | 3 + .../ticket-suggestion.schema.js | 2 +- .../src/models/ticket/ticket.model.js | 3 - .../src/models/ticket/ticket.schema.js | 4 +- .../oauth-nonce.collection.test.js | 79 +++++++++++++++++++ .../oauth-nonce/oauth-nonce.schema.test.js | 62 +++++++++++++++ 12 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js create mode 100644 packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index 78e5a6ab0..cc7f2761f 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -55,6 +55,7 @@ import SentimentGuidelineCollection from '../sentiment-guideline/sentiment-guide import SentimentTopicCollection from '../sentiment-topic/sentiment-topic.collection.js'; import AccessGrantLogCollection from '../access-grant-log/access-grant-log.collection.js'; import SiteImsOrgAccessCollection from '../site-ims-org-access/site-ims-org-access.collection.js'; +import OAuthNonceCollection from '../oauth-nonce/oauth-nonce.collection.js'; import TaskManagementConnectionCollection from '../task-management-connection/task-management-connection.collection.js'; import TicketCollection from '../ticket/ticket.collection.js'; import TicketSuggestionCollection from '../ticket-suggestion/ticket-suggestion.collection.js'; @@ -100,6 +101,7 @@ import SentimentGuidelineSchema from '../sentiment-guideline/sentiment-guideline import SentimentTopicSchema from '../sentiment-topic/sentiment-topic.schema.js'; import AccessGrantLogSchema from '../access-grant-log/access-grant-log.schema.js'; import SiteImsOrgAccessSchema from '../site-ims-org-access/site-ims-org-access.schema.js'; +import OAuthNonceSchema from '../oauth-nonce/oauth-nonce.schema.js'; import TaskManagementConnectionSchema from '../task-management-connection/task-management-connection.schema.js'; import TicketSchema from '../ticket/ticket.schema.js'; import TicketSuggestionSchema from '../ticket-suggestion/ticket-suggestion.schema.js'; @@ -240,6 +242,7 @@ EntityRegistry.registerEntity(SentimentGuidelineSchema, SentimentGuidelineCollec EntityRegistry.registerEntity(SentimentTopicSchema, SentimentTopicCollection); EntityRegistry.registerEntity(AccessGrantLogSchema, AccessGrantLogCollection); EntityRegistry.registerEntity(SiteImsOrgAccessSchema, SiteImsOrgAccessCollection); +EntityRegistry.registerEntity(OAuthNonceSchema, OAuthNonceCollection); EntityRegistry.registerEntity(TaskManagementConnectionSchema, TaskManagementConnectionCollection); EntityRegistry.registerEntity(TicketSchema, TicketCollection); EntityRegistry.registerEntity(TicketSuggestionSchema, TicketSuggestionCollection); diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index bcd926cc8..b5a585770 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -53,6 +53,7 @@ export * from './page-citability/index.js'; export * from './plg-onboarding/index.js'; export * from './sentiment-guideline/index.js'; export * from './sentiment-topic/index.js'; +export * from './oauth-nonce/index.js'; export * from './task-management-connection/index.js'; export * from './ticket/index.js'; export * from './ticket-suggestion/index.js'; diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js new file mode 100644 index 000000000..f124432ce --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import OAuthNonce from './oauth-nonce.model.js'; +import OAuthNonceCollection from './oauth-nonce.collection.js'; + +export { + OAuthNonce, + OAuthNonceCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js new file mode 100644 index 000000000..d025150e1 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseCollection from '../base/base.collection.js'; + +/** + * OAuthNonceCollection — manages OAuthNonce records. + * + * @class OAuthNonceCollection + * @extends BaseCollection + */ +class OAuthNonceCollection extends BaseCollection { + static COLLECTION_NAME = 'OAuthNonceCollection'; + + /** + * Atomically deletes a nonce by its value. + * Returns the number of rows deleted (1 = consumed, 0 = not found or already consumed). + * Used by auth-service to enforce single-use replay prevention at OAuth callback time. + * + * @param {object} keys + * @param {string} keys.nonce - The nonce value to consume. + * @returns {Promise} 1 if the nonce was found and deleted, 0 otherwise. + */ + async delete({ nonce } = {}) { + const { data, error } = await this.postgrestService + .from(this.tableName) + .delete() + .eq('nonce', nonce) + .select(); + if (error) { + throw error; + } + return (data ?? []).length; + } +} + +export default OAuthNonceCollection; diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js new file mode 100644 index 000000000..2d8651fea --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * OAuthNonce — a single-use state token used for OAuth 2.0 CSRF/replay prevention. + * + * Lifecycle: + * 1. Created by auth-service at the start of an OAuth authorization flow. + * 2. Consumed atomically (via OAuthNonceCollection.delete) when the provider + * redirects back to the callback endpoint. + * 3. If the nonce cannot be consumed the callback is rejected (replay attack). + * + * Rows are short-lived (TTL ≈ 10 minutes). A background cleanup job can sweep + * expired rows, but replay protection does not depend on cleanup — the nonce is + * deleted on first use regardless of expiresAt. + * + * @class OAuthNonce + * @extends BaseModel + */ +class OAuthNonce extends BaseModel { + static ENTITY_NAME = 'OAuthNonce'; +} + +export default OAuthNonce; diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js new file mode 100644 index 000000000..688e48bb6 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import OAuthNonce from './oauth-nonce.model.js'; +import OAuthNonceCollection from './oauth-nonce.collection.js'; + +// nonce is the state parameter sent to the OAuth provider and consumed exactly once +// at callback time to prevent CSRF/replay attacks. The index on nonce powers the +// OAuthNonceCollection.delete({ nonce }) lookup used by auth-service at callback time. +const schema = new SchemaBuilder(OAuthNonce, OAuthNonceCollection) + .addAttribute('nonce', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('expiresAt', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + }) + .addIndex( + { composite: ['nonce'] }, + { composite: [] }, + ); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 847ab6a33..02265b9c1 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -65,6 +65,9 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect readOnly: true, validate: (value) => typeof value === 'string' && value.length > 0, }) + // TODO(follow-up): add lastUsedAt (timestamp, nullable) and errorMessage (string, nullable) + // columns that the auth-service already writes in markError(). Tracked in the data-models PR. + // metadata JSONB (PR #720): provider-specific structured data. // jira_cloud: { cloudId (required UUID), scopes (optional string array) }. // siteName and siteUrl are NOT stored here — they live in displayName/instanceUrl above. diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js index bfa6dcef9..2927e0940 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -27,7 +27,7 @@ const schema = new SchemaBuilder(TicketSuggestion, TicketSuggestionCollection) }) .addAttribute('opportunityId', { type: 'string', - required: false, + required: true, readOnly: true, }) .addAttribute('createdBy', { diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js index a086ce0aa..585f42fe2 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -39,9 +39,6 @@ import BaseModel from '../base/base.model.js'; */ class Ticket extends BaseModel { static ENTITY_NAME = 'Ticket'; - - /** Default status assigned to every newly created ticket. */ - static DEFAULT_STATUS = 'To Do'; } export default Ticket; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index a10370482..a75c20837 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -41,8 +41,8 @@ const schema = new SchemaBuilder(Ticket, TicketCollection) }) .addAttribute('ticketStatus', { type: 'string', - required: true, - default: Ticket.DEFAULT_STATUS, + required: false, + default: null, }) .addAttribute('ticketProvider', { type: 'string', diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js new file mode 100644 index 000000000..955a96b89 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; + +import { createElectroMocks } from '../../util.js'; +import OAuthNonce from '../../../../src/models/oauth-nonce/oauth-nonce.model.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const MOCK_RECORD = { + oAuthNonceId: '11111111-1111-1111-1111-111111111111', + nonce: 'abc123', + expiresAt: '2025-01-01T00:00:10.000Z', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + updatedBy: 'system', +}; + +describe('OAuthNonceCollection', () => { + let instance; + + beforeEach(() => { + ({ collection: instance } = createElectroMocks(OAuthNonce, MOCK_RECORD)); + }); + + describe('delete()', () => { + function setupDeleteChain(result) { + const selectStub = sinon.stub().resolves(result); + const eqStub = sinon.stub().returns({ select: selectStub }); + const deleteStub = sinon.stub().returns({ eq: eqStub }); + instance.postgrestService.from = sinon.stub().returns({ delete: deleteStub }); + return { deleteStub, eqStub, selectStub }; + } + + it('returns 1 when nonce is found and deleted', async () => { + setupDeleteChain({ data: [MOCK_RECORD], error: null }); + + const result = await instance.delete({ nonce: 'abc123' }); + + expect(result).to.equal(1); + }); + + it('returns 0 when nonce is not found', async () => { + setupDeleteChain({ data: [], error: null }); + + const result = await instance.delete({ nonce: 'unknown' }); + + expect(result).to.equal(0); + }); + + it('returns 0 when data is null', async () => { + setupDeleteChain({ data: null, error: null }); + + const result = await instance.delete({ nonce: 'abc123' }); + + expect(result).to.equal(0); + }); + + it('throws when PostgREST returns an error', async () => { + setupDeleteChain({ data: null, error: new Error('DB error') }); + + await expect(instance.delete({ nonce: 'abc123' })).to.be.rejectedWith('DB error'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js new file mode 100644 index 000000000..550518688 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import oauthNonceSchema from '../../../../src/models/oauth-nonce/oauth-nonce.schema.js'; + +describe('OAuthNonce Schema', () => { + let attributes; + + before(() => { + attributes = oauthNonceSchema.getAttributes(); + }); + + describe('nonce attribute', () => { + it('exists', () => { + expect(attributes.nonce).to.exist; + }); + + it('is required', () => { + expect(attributes.nonce.required).to.be.true; + }); + + it('is readOnly', () => { + expect(attributes.nonce.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.nonce.type).to.equal('string'); + }); + }); + + describe('expiresAt attribute', () => { + it('exists', () => { + expect(attributes.expiresAt).to.exist; + }); + + it('is required', () => { + expect(attributes.expiresAt.required).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.expiresAt.type).to.equal('string'); + }); + + it('validates ISO date strings', () => { + expect(attributes.expiresAt.validate('2025-01-01T00:00:00.000Z')).to.be.true; + }); + + it('rejects non-ISO strings', () => { + expect(attributes.expiresAt.validate('not-a-date')).to.be.false; + }); + }); +}); From 8a8f0f2fd20b0a3a84422d648c1e036867b2091d Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 02:05:20 +0530 Subject: [PATCH 12/32] fix(data-models): address PR #1702 review blockers (SITES-44690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ticket schema: add has_many TicketSuggestions with removeDependents to prevent orphan bridge rows on ticket deletion - Organization schema: add has_many TaskManagementConnections for cascade and traversal support - TaskManagementConnection schema: fix instanceUrl validation from startsWith to isValidUrl (proper URL parsing) - TaskManagementConnection schema: remove metadata default {} — empty object bypassed cloudId required check at creation time - TaskManagementConnection model: add markActive() for auth-service re-auth path to restore requires_reauth connections Co-Authored-By: Claude Sonnet 4.6 --- .../models/organization/organization.schema.js | 1 + .../task-management-connection.model.js | 11 +++++++++++ .../task-management-connection.schema.js | 8 ++++++-- .../src/models/ticket/ticket.schema.js | 1 + .../task-management-connection.model.test.js | 18 ++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/organization/organization.schema.js b/packages/spacecat-shared-data-access/src/models/organization/organization.schema.js index b698f7fab..be6e155dc 100644 --- a/packages/spacecat-shared-data-access/src/models/organization/organization.schema.js +++ b/packages/spacecat-shared-data-access/src/models/organization/organization.schema.js @@ -25,6 +25,7 @@ const schema = new SchemaBuilder(Organization, OrganizationCollection) .addReference('has_many', 'Projects') .addReference('has_many', 'Entitlements') .addReference('has_many', 'TrialUsers') + .addReference('has_many', 'TaskManagementConnections') .addAttribute('config', { type: 'any', required: true, diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js index 2ffc651e8..ab9a5dcaa 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -61,6 +61,17 @@ class TaskManagementConnection extends BaseModel { return this.getStatus() === TaskManagementConnection.STATUSES.ACTIVE; } + /** + * Marks the connection as active. Called by auth-service after a successful + * re-authorization to restore a connection from requires_reauth state. + * + * @returns {Promise} + */ + async markActive() { + this.setStatus(TaskManagementConnection.STATUSES.ACTIVE); + return this.save(); + } + /** * Marks the connection as requiring re-authentication (e.g. after a failed * token refresh). Persists immediately so other services see the degraded diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 02265b9c1..60a6360fa 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -12,6 +12,8 @@ /* c8 ignore start */ +import { isValidUrl } from '@adobe/spacecat-shared-utils'; + import SchemaBuilder from '../base/schema.builder.js'; import TaskManagementConnection from './task-management-connection.model.js'; import TaskManagementConnectionCollection from './task-management-connection.collection.js'; @@ -47,7 +49,7 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect type: 'string', required: true, readOnly: true, - validate: (value) => typeof value === 'string' && value.startsWith('https://'), + validate: (value) => isValidUrl(value), }) // connected_by column (PR #720): IMS user ID (JWT sub) of the person who completed OAuth. .addAttribute('connectedBy', { @@ -71,10 +73,12 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect // metadata JSONB (PR #720): provider-specific structured data. // jira_cloud: { cloudId (required UUID), scopes (optional string array) }. // siteName and siteUrl are NOT stored here — they live in displayName/instanceUrl above. + // No default — callers must supply valid metadata (e.g. { cloudId: '...' } for + // jira_cloud). An empty-object default would silently bypass validateMetadata's + // required-field check at the schema level. .addAttribute('metadata', { type: 'any', required: true, - default: {}, }); export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index a75c20837..6127294e2 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -23,6 +23,7 @@ const schema = new SchemaBuilder(Ticket, TicketCollection) .addReference('belongs_to', 'TaskManagementConnection') // Optional FK — a ticket may not be linked to an opportunity in future flows. .addReference('belongs_to', 'Opportunity', ['ticketKey'], { required: false }) + .addReference('has_many', 'TicketSuggestions', ['createdAt'], { removeDependents: true }) .addAttribute('ticketId', { type: 'string', required: true, diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js index ab9eb0440..b8c21e378 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -156,6 +156,24 @@ describe('TaskManagementConnectionModel', () => { expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.DISCONNECTED); }); }); + + describe('markActive()', () => { + it('sets status to active and saves', async () => { + instance.record.status = TaskManagementConnection.STATUSES.REQUIRES_REAUTH; + const saveStub = stub(instance.patcher, 'save').resolves(); + + await expect(instance.markActive()).to.be.fulfilled; + + expect(saveStub).to.have.been.calledOnce; + expect(instance.record.status).to.equal(TaskManagementConnection.STATUSES.ACTIVE); + }); + + it('propagates save errors', async () => { + stub(instance.patcher, 'save').rejects(new Error('DB error')); + + await expect(instance.markActive()).to.be.rejected; + }); + }); }); describe('TaskManagementConnectionCollection', () => { From 15613d56e8044a0fe4a265fe57a3e8b279ac3c91 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 02:18:44 +0530 Subject: [PATCH 13/32] fix(data-models): remove unreachable jira_corp metadata schema (SITES-44690) jira_corp is not in PROVIDERS enum and has no v1 implementation path. Remove dead schema and its tests; add v1 comment pointing to where v2 providers should be added. Co-Authored-By: Claude Sonnet 4.6 --- .../metadata-validator.js | 10 +---- .../metadata-validator.test.js | 37 ------------------- 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js index bdd71dc1b..c6650d3eb 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -27,6 +27,8 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 * The logic is equivalent to the spec's JSON Schema: required fields, a UUID * pattern constraint, and additionalProperties: false. */ +// v1: only jira_cloud is supported. Add jira_corp, asana, workfront schemas here +// when the corresponding provider value is added to TaskManagementConnection.PROVIDERS. const METADATA_SCHEMAS = { jira_cloud: { // Aligns with mysticat-data-service PR #720: @@ -46,14 +48,6 @@ const METADATA_SCHEMAS = { : 'scopes must be an array of strings'), }, }, - jira_corp: { - required: ['baseUrl'], - allowed: new Set(['baseUrl', 'projectCategory']), - properties: { - baseUrl: (v) => (typeof v === 'string' && v.startsWith('https://') ? null : 'baseUrl must be a valid https:// URI'), - projectCategory: (v) => (typeof v === 'string' ? null : 'projectCategory must be a string'), - }, - }, }; /** diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js index 5de6dcefe..e14ca71ff 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -83,43 +83,6 @@ describe('validateMetadata()', () => { }); }); - describe('jira_corp', () => { - it('accepts valid jira_corp metadata with baseUrl only', () => { - expect(() => validateMetadata('jira_corp', { - baseUrl: 'https://jira.corp.example.com', - })).not.to.throw(); - }); - - it('accepts valid jira_corp metadata with optional projectCategory', () => { - expect(() => validateMetadata('jira_corp', { - baseUrl: 'https://jira.corp.example.com', - projectCategory: 'Engineering', - })).not.to.throw(); - }); - - it('rejects missing baseUrl', () => { - expect(() => validateMetadata('jira_corp', {})) - .to.throw('metadata.baseUrl is required'); - }); - - it('rejects non-https baseUrl', () => { - expect(() => validateMetadata('jira_corp', { baseUrl: 'http://jira.corp.example.com' })) - .to.throw('baseUrl must be a valid https:// URI'); - }); - - it('rejects non-string baseUrl', () => { - expect(() => validateMetadata('jira_corp', { baseUrl: 42 })) - .to.throw('baseUrl must be a valid https:// URI'); - }); - - it('rejects non-string projectCategory', () => { - expect(() => validateMetadata('jira_corp', { - baseUrl: 'https://jira.corp.example.com', - projectCategory: 99, - })).to.throw('projectCategory must be a string'); - }); - }); - describe('unknown provider', () => { it('throws for unknown provider', () => { expect(() => validateMetadata('asana', { anything: 'goes' })) From c06aac491df3ed0825244061e35e435f4859ce0b Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 13:30:53 +0530 Subject: [PATCH 14/32] fix(data-models): add lastUsedAt and errorMessage attributes to TaskManagementConnection schema Both columns already exist in the DB migration (mysticat-data-service PR #720) but were missing from the ORM schema. Adds the attributes so the model API is complete and consistent with the database. Co-authored-by: Cursor --- .../task-management-connection/index.d.ts | 4 +++ .../task-management-connection.schema.js | 11 ++++++-- .../task-management-connection.schema.test.js | 27 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index 97e92a789..5b2395da8 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -29,13 +29,17 @@ export interface TaskManagementConnection extends BaseModel { getConnectedBy(): string; getDisplayName(): string; + getErrorMessage(): string | null; getInstanceUrl(): string; + getLastUsedAt(): string | null; getMetadata(): object; getOrganizationId(): string; getProvider(): string; getStatus(): string; getTickets(): Promise; + setErrorMessage(message: string | null): TaskManagementConnection; + setLastUsedAt(timestamp: string): TaskManagementConnection; setMetadata(metadata: object): TaskManagementConnection; setStatus(status: string): TaskManagementConnection; } diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 60a6360fa..50c9989b7 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -67,8 +67,15 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect readOnly: true, validate: (value) => typeof value === 'string' && value.length > 0, }) - // TODO(follow-up): add lastUsedAt (timestamp, nullable) and errorMessage (string, nullable) - // columns that the auth-service already writes in markError(). Tracked in the data-models PR. + .addAttribute('lastUsedAt', { + type: 'string', + required: false, + validate: (value) => !value || !Number.isNaN(Date.parse(value)), + }) + .addAttribute('errorMessage', { + type: 'string', + required: false, + }) // metadata JSONB (PR #720): provider-specific structured data. // jira_cloud: { cloudId (required UUID), scopes (optional string array) }. diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js index 55b08560b..301b69b7a 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js @@ -66,4 +66,31 @@ describe('TaskManagementConnection Schema', () => { expect(attributes.instanceUrl.readOnly).to.be.true; }); }); + + describe('lastUsedAt attribute', () => { + it('exists and is optional', () => { + expect(attributes.lastUsedAt).to.exist; + expect(attributes.lastUsedAt.required).to.be.false; + }); + + it('validates ISO date strings', () => { + expect(attributes.lastUsedAt.validate('2026-06-15T10:05:00Z')).to.be.true; + }); + + it('rejects non-ISO strings', () => { + expect(attributes.lastUsedAt.validate('not-a-date')).to.be.false; + }); + + it('accepts null/undefined', () => { + expect(attributes.lastUsedAt.validate(null)).to.be.true; + expect(attributes.lastUsedAt.validate(undefined)).to.be.true; + }); + }); + + describe('errorMessage attribute', () => { + it('exists and is optional', () => { + expect(attributes.errorMessage).to.exist; + expect(attributes.errorMessage.required).to.be.false; + }); + }); }); From 63b0b73af47b85506b541cafa7a49ecadce7d5eb Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 17:18:06 +0530 Subject: [PATCH 15/32] fix(data-models): add missing TypeScript declarations for task-management models (SITES-44690) - Add OAuthNonce and OAuthNonceCollection TypeScript declarations with atomic delete method signature - Add getTaskManagementConnections() to Organization interface - Add getExternalInstanceId() and markActive() to TaskManagementConnection - Export oauth-nonce types from models barrel Co-authored-by: Cursor --- .../src/models/index.d.ts | 1 + .../src/models/oauth-nonce/index.d.ts | 26 +++++++++++++++++++ .../src/models/organization/index.d.ts | 4 ++- .../task-management-connection/index.d.ts | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts diff --git a/packages/spacecat-shared-data-access/src/models/index.d.ts b/packages/spacecat-shared-data-access/src/models/index.d.ts index 6d491dc2f..820b27175 100755 --- a/packages/spacecat-shared-data-access/src/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/index.d.ts @@ -30,6 +30,7 @@ export type * from './import-url'; export type * from './key-event'; export type * from './latest-audit'; export type * from './opportunity'; +export type * from './oauth-nonce'; export type * from './organization'; export type * from './page-citability'; export type * from './page-intent'; diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts new file mode 100644 index 000000000..f15b87d8d --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel } from '../index'; + +export interface OAuthNonce extends BaseModel { + getNonce(): string; + getExpiresAt(): string; +} + +export interface OAuthNonceCollection extends BaseCollection { + /** + * Atomically deletes a nonce by its value. + * Returns the number of rows deleted (1 = consumed, 0 = not found or already consumed). + */ + delete(keys: { nonce: string }): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/organization/index.d.ts b/packages/spacecat-shared-data-access/src/models/organization/index.d.ts index f8a52ed2a..8aca552aa 100644 --- a/packages/spacecat-shared-data-access/src/models/organization/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/organization/index.d.ts @@ -11,7 +11,8 @@ */ import type { - BaseCollection, BaseModel, Site, Project, Entitlement, OrganizationIdentityProvider, TrialUser, + BaseCollection, BaseModel, Site, Project, Entitlement, OrganizationIdentityProvider, + TaskManagementConnection, TrialUser, } from '../index'; export interface Organization extends BaseModel { @@ -24,6 +25,7 @@ export interface Organization extends BaseModel { getProjects(): Promise; getEntitlements(): Promise; getOrganizationIdentityProviders(): Promise; + getTaskManagementConnections(): Promise; getTrialUsers(): Promise; setConfig(config: object): Organization; setFulfillableItems(fulfillableItems: object): Organization; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index 5b2395da8..52e1effbc 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -24,10 +24,13 @@ export interface TaskManagementConnection extends BaseModel { markDisabled(): Promise; /** Persists status = 'error' after repeated API failures. */ markError(): Promise; + /** Persists status = 'active' after a successful re-authorization. */ + markActive(): Promise; /** Persists status = 'disconnected' (soft-delete on user revoke). */ markDisconnected(): Promise; getConnectedBy(): string; + getExternalInstanceId(): string; getDisplayName(): string; getErrorMessage(): string | null; getInstanceUrl(): string; From c6d15cf5539a4501a9a1b3745833c14a6c7bf2be Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 18:10:51 +0530 Subject: [PATCH 16/32] fix(data-access): rename ticketId to externalTicketId, add schema tests, remove unused imports - Rename ticketId attribute to externalTicketId to resolve ORM PK collision (entityNameToIdName('Ticket') = 'ticketId' clashed with the Jira internal ID attribute) - Remove unused Opportunity and TaskManagementConnection imports from Ticket TS declarations - Add unit tests for Ticket and TicketSuggestion schemas Co-authored-by: Cursor --- .../src/models/ticket/index.d.ts | 4 +- .../src/models/ticket/ticket.model.js | 2 +- .../src/models/ticket/ticket.schema.js | 2 +- .../ticket-suggestion.schema.test.js | 73 +++++++++++++++ .../unit/models/ticket/ticket.schema.test.js | 90 +++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts index 91533b8aa..38600d105 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -10,14 +10,14 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, Opportunity, TaskManagementConnection } from '../index'; +import type { BaseCollection, BaseModel } from '../index'; export interface Ticket extends BaseModel { getCreatedBy(): string; getOpportunityId(): string | undefined; getOrganizationId(): string; getTaskManagementConnectionId(): string; - getTicketId(): string; + getExternalTicketId(): string; getTicketKey(): string; getTicketProvider(): string; getTicketStatus(): string; diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js index 585f42fe2..780238447 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -16,7 +16,7 @@ import BaseModel from '../base/base.model.js'; * Ticket — one Jira issue created by ASO via a TaskManagementConnection. * * Preserves the provider-side identifiers needed for future operations: - * ticketId — Jira's internal numeric ID (data.id). Required for PATCH/update calls. + * externalTicketId — Jira's internal numeric ID (data.id). Required for PATCH/update calls. * ticketKey — Human-readable issue key, e.g. 'ASO-42'. Used in UI links. * ticketUrl — Direct browser URL, e.g. 'https://acme.atlassian.net/browse/ASO-42'. * ticketProvider — Provider that created the ticket (e.g. 'jira_cloud'). Denormalized from diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index 6127294e2..d8e048e01 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -24,7 +24,7 @@ const schema = new SchemaBuilder(Ticket, TicketCollection) // Optional FK — a ticket may not be linked to an opportunity in future flows. .addReference('belongs_to', 'Opportunity', ['ticketKey'], { required: false }) .addReference('has_many', 'TicketSuggestions', ['createdAt'], { removeDependents: true }) - .addAttribute('ticketId', { + .addAttribute('externalTicketId', { type: 'string', required: true, readOnly: true, diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js new file mode 100644 index 000000000..ab629dc9a --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import ticketSuggestionSchema from '../../../../src/models/ticket-suggestion/ticket-suggestion.schema.js'; + +describe('TicketSuggestion Schema', () => { + let attributes; + + before(() => { + attributes = ticketSuggestionSchema.getAttributes(); + }); + + describe('suggestionId attribute', () => { + it('exists', () => { + expect(attributes.suggestionId).to.exist; + }); + + it('is required', () => { + expect(attributes.suggestionId.required).to.be.true; + }); + + it('is readOnly', () => { + expect(attributes.suggestionId.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.suggestionId.type).to.equal('string'); + }); + }); + + describe('opportunityId attribute', () => { + it('is required and readOnly', () => { + expect(attributes.opportunityId.required).to.be.true; + expect(attributes.opportunityId.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.opportunityId.type).to.equal('string'); + }); + }); + + describe('createdBy attribute', () => { + it('is required and readOnly', () => { + expect(attributes.createdBy.required).to.be.true; + expect(attributes.createdBy.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.createdBy.type).to.equal('string'); + }); + }); + + describe('index', () => { + it('has an index on suggestionId', () => { + const indexes = ticketSuggestionSchema.getIndexes(); + expect(indexes).to.be.an('array').that.is.not.empty; + const suggestionIdIndex = indexes.find( + (idx) => idx.sk?.composite?.includes('suggestionId'), + ); + expect(suggestionIdIndex).to.exist; + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js new file mode 100644 index 000000000..967aafb03 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import ticketSchema from '../../../../src/models/ticket/ticket.schema.js'; + +describe('Ticket Schema', () => { + let attributes; + + before(() => { + attributes = ticketSchema.getAttributes(); + }); + + describe('externalTicketId attribute', () => { + it('exists', () => { + expect(attributes.externalTicketId).to.exist; + }); + + it('is required', () => { + expect(attributes.externalTicketId.required).to.be.true; + }); + + it('is readOnly', () => { + expect(attributes.externalTicketId.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.externalTicketId.type).to.equal('string'); + }); + }); + + describe('ticketKey attribute', () => { + it('is required and readOnly', () => { + expect(attributes.ticketKey.required).to.be.true; + expect(attributes.ticketKey.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.ticketKey.type).to.equal('string'); + }); + }); + + describe('ticketUrl attribute', () => { + it('is required and readOnly', () => { + expect(attributes.ticketUrl.required).to.be.true; + expect(attributes.ticketUrl.readOnly).to.be.true; + }); + + it('validates URLs', () => { + expect(attributes.ticketUrl.validate('https://acme.atlassian.net/browse/ASO-42')).to.be.true; + }); + + it('rejects invalid URLs', () => { + expect(attributes.ticketUrl.validate('not-a-url')).to.be.false; + }); + }); + + describe('ticketStatus attribute', () => { + it('is optional', () => { + expect(attributes.ticketStatus.required).to.be.false; + }); + + it('defaults to null', () => { + expect(attributes.ticketStatus.default).to.be.null; + }); + }); + + describe('ticketProvider attribute', () => { + it('is required and readOnly', () => { + expect(attributes.ticketProvider.required).to.be.true; + expect(attributes.ticketProvider.readOnly).to.be.true; + }); + }); + + describe('createdBy attribute', () => { + it('is required and readOnly', () => { + expect(attributes.createdBy.required).to.be.true; + expect(attributes.createdBy.readOnly).to.be.true; + }); + }); +}); From 3a5cbc2002aff34a3a88676f0716afa05415cf38 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 25 Jun 2026 21:10:24 +0530 Subject: [PATCH 17/32] fix(data-access): bump IT data-service image to v5.48.1 The v5.24.1 image was cleaned from ECR, breaking integration test setup. Bump to v5.48.1 (latest release) to restore CI. Co-authored-by: Cursor --- .../test/it/postgrest/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml index f8d0cb552..9f82b3c45 100644 --- a/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml +++ b/packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml @@ -26,11 +26,11 @@ services: # aws ecr describe-images \ # --profile --region us-east-1 \ # --repository-name mysticat-data-service \ - # --image-ids imageTag=v5.24.1 \ + # --image-ids imageTag=v5.48.1 \ # --query 'imageDetails[0].imageDigest' --output text # Then export MYSTICAT_DATA_SERVICE_DIGEST=sha256: before npm run test:it, # or set it in the .env file consumed by docker compose. Leave unset for local dev. - image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.24.1}${MYSTICAT_DATA_SERVICE_DIGEST:+@${MYSTICAT_DATA_SERVICE_DIGEST}} + image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.48.1}${MYSTICAT_DATA_SERVICE_DIGEST:+@${MYSTICAT_DATA_SERVICE_DIGEST}} depends_on: db: condition: service_healthy From a331cc4a1904d1fb33311258514fc50d1622b358 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Fri, 26 Jun 2026 13:55:31 +0530 Subject: [PATCH 18/32] fix(data-access): guard validateMetadata and OAuthNonce.delete against invalid input (SITES-44690) - validateMetadata now throws ValidationError on non-object input instead of letting TypeError propagate from property access - OAuthNonceCollection.delete rejects undefined/null nonce with a clear error instead of sending it to PostgREST Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/models/oauth-nonce/oauth-nonce.collection.js | 3 +++ .../models/task-management-connection/metadata-validator.js | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js index d025150e1..ca9e04cba 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js @@ -31,6 +31,9 @@ class OAuthNonceCollection extends BaseCollection { * @returns {Promise} 1 if the nonce was found and deleted, 0 otherwise. */ async delete({ nonce } = {}) { + if (!nonce || typeof nonce !== 'string') { + throw new Error('nonce is required and must be a non-empty string'); + } const { data, error } = await this.postgrestService .from(this.tableName) .delete() diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js index c6650d3eb..82e16e11d 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -68,6 +68,10 @@ export function validateMetadata(provider, metadata) { throw new ValidationError(`No metadata schema for provider: ${provider}`); } + if (metadata === null || metadata === undefined || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new ValidationError('metadata must be a non-null object'); + } + const { required, allowed, properties } = schema; for (const field of required) { From 7be1031047a8e22c6d88c768cdfbb7c55599e2d5 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Fri, 26 Jun 2026 16:40:29 +0530 Subject: [PATCH 19/32] feat(data-access): add connectedAt attribute to TaskManagementConnection Tracks when OAuth was last successfully completed. Set on initial connect, updated on re-auth by auth-service. Differs from createdAt after reconnect. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/models/task-management-connection/index.d.ts | 2 ++ .../task-management-connection.schema.js | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index 52e1effbc..16c269e72 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -29,6 +29,7 @@ export interface TaskManagementConnection extends BaseModel { /** Persists status = 'disconnected' (soft-delete on user revoke). */ markDisconnected(): Promise; + getConnectedAt(): string | null; getConnectedBy(): string; getExternalInstanceId(): string; getDisplayName(): string; @@ -41,6 +42,7 @@ export interface TaskManagementConnection extends BaseModel { getStatus(): string; getTickets(): Promise; + setConnectedAt(timestamp: string): TaskManagementConnection; setErrorMessage(message: string | null): TaskManagementConnection; setLastUsedAt(timestamp: string): TaskManagementConnection; setMetadata(metadata: object): TaskManagementConnection; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 50c9989b7..e1b99c7ce 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -57,6 +57,13 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect required: true, readOnly: true, }) + // connected_at column: when OAuth was last successfully completed. + // Set on initial connect, updated on re-auth. Differs from createdAt after reconnect. + .addAttribute('connectedAt', { + type: 'string', + required: false, + validate: (value) => !value || !Number.isNaN(Date.parse(value)), + }) // external_instance_id column: provider-stable identifier for the remote workspace. // jira_cloud → Atlassian cloudId UUID; jira_corp → normalized baseUrl (v2). // Used as the dedup key in UNIQUE(organization_id, provider, external_instance_id). From e2c95cb49d61327a245e83fbe0408922c7ffbdd9 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Fri, 26 Jun 2026 22:55:22 +0530 Subject: [PATCH 20/32] fix(data-access): use isIsoDate, wire metadata validation, fix copyright year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Date.parse with isIsoDate for connectedAt/lastUsedAt validation (strict ISO 8601 only — rejects loose formats like "Jan 1, 2024") - Wire validateMetadata into metadata attribute set hook for defence-in-depth alongside DB CHECK constraint - Update copyright year from 2024 to 2026 in all new files - Add schema tests for connectedAt, metadata set hook validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/models/oauth-nonce/index.d.ts | 2 +- .../src/models/oauth-nonce/index.js | 2 +- .../oauth-nonce/oauth-nonce.collection.js | 2 +- .../models/oauth-nonce/oauth-nonce.model.js | 2 +- .../models/oauth-nonce/oauth-nonce.schema.js | 2 +- .../task-management-connection/index.d.ts | 2 +- .../task-management-connection/index.js | 2 +- .../metadata-validator.js | 2 +- .../task-management-connection.collection.js | 2 +- .../task-management-connection.model.js | 2 +- .../task-management-connection.schema.js | 18 ++++-- .../src/models/ticket-suggestion/index.d.ts | 2 +- .../src/models/ticket-suggestion/index.js | 2 +- .../ticket-suggestion.collection.js | 2 +- .../ticket-suggestion.model.js | 2 +- .../ticket-suggestion.schema.js | 2 +- .../src/models/ticket/index.d.ts | 2 +- .../src/models/ticket/index.js | 2 +- .../src/models/ticket/ticket.collection.js | 2 +- .../src/models/ticket/ticket.model.js | 2 +- .../src/models/ticket/ticket.schema.js | 2 +- .../oauth-nonce.collection.test.js | 2 +- .../oauth-nonce/oauth-nonce.schema.test.js | 2 +- .../metadata-validator.test.js | 2 +- .../task-management-connection.model.test.js | 2 +- .../task-management-connection.schema.test.js | 64 ++++++++++++++++++- .../ticket-suggestion.schema.test.js | 2 +- .../unit/models/ticket/ticket.schema.test.js | 2 +- 28 files changed, 102 insertions(+), 32 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts index f15b87d8d..4ceb5f546 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js index f124432ce..fe668df16 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js index ca9e04cba..6946c5d05 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js index 2d8651fea..a4f184115 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js index 688e48bb6..f7af08570 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index 16c269e72..5112d934b 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js index 4404b0d56..ff5404bdb 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js index 82e16e11d..d318c0eba 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js index 15cedb233..5f96f9d83 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js index ab9a5dcaa..488d44b8e 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index e1b99c7ce..d63e33487 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -12,11 +12,12 @@ /* c8 ignore start */ -import { isValidUrl } from '@adobe/spacecat-shared-utils'; +import { isIsoDate, isValidUrl } from '@adobe/spacecat-shared-utils'; import SchemaBuilder from '../base/schema.builder.js'; import TaskManagementConnection from './task-management-connection.model.js'; import TaskManagementConnectionCollection from './task-management-connection.collection.js'; +import { validateMetadata } from './metadata-validator.js'; // Sort key [provider, status] on the Organization GSI lets the collection method // findActiveByOrganizationAndProvider() resolve to a single DB call: @@ -62,7 +63,7 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect .addAttribute('connectedAt', { type: 'string', required: false, - validate: (value) => !value || !Number.isNaN(Date.parse(value)), + validate: (value) => !value || isIsoDate(value), }) // external_instance_id column: provider-stable identifier for the remote workspace. // jira_cloud → Atlassian cloudId UUID; jira_corp → normalized baseUrl (v2). @@ -77,7 +78,7 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect .addAttribute('lastUsedAt', { type: 'string', required: false, - validate: (value) => !value || !Number.isNaN(Date.parse(value)), + validate: (value) => !value || isIsoDate(value), }) .addAttribute('errorMessage', { type: 'string', @@ -93,6 +94,15 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect .addAttribute('metadata', { type: 'any', required: true, + set: (value, allAttrs) => { + // Validate metadata on every write — defence-in-depth alongside DB CHECK constraint. + // The provider attribute is readOnly, so it's always present in allAttrs after creation. + const provider = allAttrs?.provider; + if (provider) { + validateMetadata(provider, value); + } + return value; + }, }); export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts index 902b56d61..b74e8c0c2 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js index 278dc0b47..59475f0ee 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js index 9b803f7e8..f90cd3c88 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js index 224445ac8..0b2f3e0db 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js index 2927e0940..ea97ed7e5 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts index 38600d105..095092e37 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.js b/packages/spacecat-shared-data-access/src/models/ticket/index.js index b97b72261..7929ec926 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/index.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js index fe01c7a62..dc3c745f2 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js index 780238447..5087cde3f 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index d8e048e01..6174d399a 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js index 955a96b89..75028036a 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js index 550518688..acd2d2d0e 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js index e14ca71ff..8f1bde6b3 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js index b8c21e378..caa74e012 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js index 301b69b7a..c14d39ee5 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -67,6 +67,31 @@ describe('TaskManagementConnection Schema', () => { }); }); + describe('connectedAt attribute', () => { + it('exists and is optional', () => { + expect(attributes.connectedAt).to.exist; + expect(attributes.connectedAt.required).to.be.false; + }); + + it('validates ISO date strings', () => { + expect(attributes.connectedAt.validate('2026-06-15T10:05:00.000Z')).to.be.true; + }); + + it('rejects non-ISO strings', () => { + expect(attributes.connectedAt.validate('not-a-date')).to.be.false; + }); + + it('rejects loose date formats accepted by Date.parse', () => { + expect(attributes.connectedAt.validate('Jan 1, 2024')).to.be.false; + expect(attributes.connectedAt.validate('Tuesday')).to.be.false; + }); + + it('accepts null/undefined', () => { + expect(attributes.connectedAt.validate(null)).to.be.true; + expect(attributes.connectedAt.validate(undefined)).to.be.true; + }); + }); + describe('lastUsedAt attribute', () => { it('exists and is optional', () => { expect(attributes.lastUsedAt).to.exist; @@ -74,19 +99,54 @@ describe('TaskManagementConnection Schema', () => { }); it('validates ISO date strings', () => { - expect(attributes.lastUsedAt.validate('2026-06-15T10:05:00Z')).to.be.true; + expect(attributes.lastUsedAt.validate('2026-06-15T10:05:00.000Z')).to.be.true; }); it('rejects non-ISO strings', () => { expect(attributes.lastUsedAt.validate('not-a-date')).to.be.false; }); + it('rejects loose date formats accepted by Date.parse', () => { + expect(attributes.lastUsedAt.validate('Jan 1, 2024')).to.be.false; + }); + it('accepts null/undefined', () => { expect(attributes.lastUsedAt.validate(null)).to.be.true; expect(attributes.lastUsedAt.validate(undefined)).to.be.true; }); }); + describe('metadata attribute', () => { + it('exists and is required', () => { + expect(attributes.metadata).to.exist; + expect(attributes.metadata.required).to.be.true; + }); + + it('set hook validates jira_cloud metadata and passes valid data through', () => { + const valid = { cloudId: '11111111-2222-3333-4444-555555555555' }; + const result = attributes.metadata.set(valid, { provider: 'jira_cloud' }); + expect(result).to.deep.equal(valid); + }); + + it('set hook rejects invalid jira_cloud metadata', () => { + const invalid = { cloudId: 'not-a-uuid' }; + expect(() => attributes.metadata.set(invalid, { provider: 'jira_cloud' })) + .to.throw('Invalid metadata'); + }); + + it('set hook rejects extra properties in metadata', () => { + const extra = { cloudId: '11111111-2222-3333-4444-555555555555', siteName: 'forbidden' }; + expect(() => attributes.metadata.set(extra, { provider: 'jira_cloud' })) + .to.throw('Unexpected metadata properties'); + }); + + it('set hook passes through when provider is not yet set', () => { + const data = { anything: true }; + const result = attributes.metadata.set(data, {}); + expect(result).to.deep.equal(data); + }); + }); + describe('errorMessage attribute', () => { it('exists and is optional', () => { expect(attributes.errorMessage).to.exist; diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js index ab629dc9a..0228f3361 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js index 967aafb03..59c9dee49 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 From 332d9c530621ace1dcafac39e5db2cee4e21bf71 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 00:52:28 +0530 Subject: [PATCH 21/32] fix(data-access): correct TicketSuggestion index test to use Object.values and check pk getIndexes() returns an object map, not an array; suggestionId is in pk, not sk. Co-Authored-By: Claude Sonnet 4.6 --- .../models/ticket-suggestion/ticket-suggestion.schema.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js index 0228f3361..b90e4bd39 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js @@ -62,10 +62,10 @@ describe('TicketSuggestion Schema', () => { describe('index', () => { it('has an index on suggestionId', () => { - const indexes = ticketSuggestionSchema.getIndexes(); + const indexes = Object.values(ticketSuggestionSchema.getIndexes()); expect(indexes).to.be.an('array').that.is.not.empty; const suggestionIdIndex = indexes.find( - (idx) => idx.sk?.composite?.includes('suggestionId'), + (idx) => idx.pk?.composite?.includes('suggestionId'), ); expect(suggestionIdIndex).to.exist; }); From eca5cb3a74292f30b5a9347d4415025bbb16711e Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 01:18:24 +0530 Subject: [PATCH 22/32] test(data-access): cover null/array metadata branches in metadata-validator Lines 72-73 were uncovered; add null and array cases to hit the guard. Co-Authored-By: Claude Sonnet 4.6 --- .../metadata-validator.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js index 8f1bde6b3..1ff2c154e 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -89,4 +89,16 @@ describe('validateMetadata()', () => { .to.throw('No metadata schema for provider: asana'); }); }); + + describe('invalid metadata shape', () => { + it('rejects null metadata', () => { + expect(() => validateMetadata('jira_cloud', null)) + .to.throw('metadata must be a non-null object'); + }); + + it('rejects array metadata', () => { + expect(() => validateMetadata('jira_cloud', [{ cloudId: VALID_CLOUD_ID }])) + .to.throw('metadata must be a non-null object'); + }); + }); }); From ad35864df2560ce4d62951c63c54e7b97e265688 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 01:41:55 +0530 Subject: [PATCH 23/32] test(data-access): cover missing-nonce guard in OAuthNonceCollection.delete Lines 35-36 were uncovered; add test for empty nonce input. Co-Authored-By: Claude Sonnet 4.6 --- .../unit/models/oauth-nonce/oauth-nonce.collection.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js index 75028036a..7f065c365 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js @@ -75,5 +75,9 @@ describe('OAuthNonceCollection', () => { await expect(instance.delete({ nonce: 'abc123' })).to.be.rejectedWith('DB error'); }); + + it('throws when nonce is missing', async () => { + await expect(instance.delete({})).to.be.rejectedWith('nonce is required and must be a non-empty string'); + }); }); }); From 623841de7718787104185402672f9fae0414e1d4 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 02:03:06 +0530 Subject: [PATCH 24/32] fix(data-access): remove readOnly from displayName and instanceUrl on TaskManagementConnection Both fields are updated by auth-service on re-auth (user may reconnect to a different Jira site or Atlassian may rename the site). readOnly: true prevented setDisplayName() and setInstanceUrl() from being generated, causing a runtime TypeError on the re-auth path in spacecat-auth-service PR #595. Co-Authored-By: Claude Sonnet 4.6 --- .../task-management-connection.schema.js | 6 +++--- .../task-management-connection.schema.test.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index d63e33487..e0a1cbb05 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -36,20 +36,20 @@ const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnect default: TaskManagementConnection.STATUSES.ACTIVE, }) // display_name column (PR #720): human-readable site name from Atlassian accessible-resources. - // Set by auth-service at OAuth callback time; never user-provided. + // Set by auth-service at OAuth callback time; updated on re-auth (user may reconnect to a + // different Jira site or Atlassian may rename the site). Not readOnly — setDisplayName() needed. .addAttribute('displayName', { type: 'string', required: true, - readOnly: true, validate: (value) => typeof value === 'string' && value.length > 0 && value.length <= 255, }) // instance_url column (PR #720): Jira site URL (https://*.atlassian.net). // Display-only — never used as a request target (SSRF protection: all outbound // calls route through the fixed Atlassian gateway keyed on cloudId from metadata). + // Updated on re-auth — not readOnly so setInstanceUrl() works. .addAttribute('instanceUrl', { type: 'string', required: true, - readOnly: true, validate: (value) => isValidUrl(value), }) // connected_by column (PR #720): IMS user ID (JWT sub) of the person who completed OAuth. diff --git a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js index c14d39ee5..18316179e 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js @@ -54,16 +54,16 @@ describe('TaskManagementConnection Schema', () => { }); describe('displayName attribute', () => { - it('is required and readOnly', () => { + it('is required and mutable (updated on re-auth)', () => { expect(attributes.displayName.required).to.be.true; - expect(attributes.displayName.readOnly).to.be.true; + expect(attributes.displayName.readOnly).to.not.be.true; }); }); describe('instanceUrl attribute', () => { - it('is required and readOnly', () => { + it('is required and mutable (updated on re-auth)', () => { expect(attributes.instanceUrl.required).to.be.true; - expect(attributes.instanceUrl.readOnly).to.be.true; + expect(attributes.instanceUrl.readOnly).to.not.be.true; }); }); From 84c7399e5ebfe0dde85e4165a8c94b97826316d1 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 02:26:37 +0530 Subject: [PATCH 25/32] fix(data-access): map Ticket FK to connection_id column and add TypeScript types for new entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB tickets table uses connection_id (not task_management_connection_id). entityNameToIdName('TaskManagementConnection') generates taskManagementConnectionId which auto-maps to task_management_connection_id — wrong column, runtime failure. Override with postgrestField: 'connection_id' to match the actual DB migration (#720). Also adds OAuthNonce, TaskManagementConnection, Ticket, TicketSuggestion to the DataAccess TypeScript interface in index.d.ts so TS consumers get proper types. Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/ticket/ticket.schema.js | 12 +++++++++++- .../src/service/index.d.ts | 8 ++++++++ .../test/unit/models/ticket/ticket.schema.test.js | 11 +++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index 6174d399a..bc14c5fa5 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -12,7 +12,7 @@ /* c8 ignore start */ -import { isValidUrl } from '@adobe/spacecat-shared-utils'; +import { isValidUUID, isValidUrl } from '@adobe/spacecat-shared-utils'; import SchemaBuilder from '../base/schema.builder.js'; import Ticket from './ticket.model.js'; @@ -21,6 +21,16 @@ import TicketCollection from './ticket.collection.js'; const schema = new SchemaBuilder(Ticket, TicketCollection) .addReference('belongs_to', 'Organization') .addReference('belongs_to', 'TaskManagementConnection') + // The DB column for the TaskManagementConnection FK is `connection_id` (not + // `task_management_connection_id`). Override the auto-generated attribute to + // point to the correct column so PostgREST queries and INSERTs use the right name. + .addAttribute('taskManagementConnectionId', { + type: 'string', + required: true, + readOnly: true, + postgrestField: 'connection_id', + validate: (value) => isValidUUID(value), + }) // Optional FK — a ticket may not be linked to an opportunity in future flows. .addReference('belongs_to', 'Opportunity', ['ticketKey'], { required: false }) .addReference('has_many', 'TicketSuggestions', ['createdAt'], { removeDependents: true }) diff --git a/packages/spacecat-shared-data-access/src/service/index.d.ts b/packages/spacecat-shared-data-access/src/service/index.d.ts index 79ff36955..1ae261f87 100644 --- a/packages/spacecat-shared-data-access/src/service/index.d.ts +++ b/packages/spacecat-shared-data-access/src/service/index.d.ts @@ -44,6 +44,10 @@ import type { SiteEnrollmentCollection } from '../models/site-enrollment'; import type { SiteTopFormCollection } from '../models/site-top-form'; import type { SiteTopPageCollection } from '../models/site-top-page'; import type { SuggestionCollection } from '../models/suggestion'; +import type { OAuthNonceCollection } from '../models/oauth-nonce'; +import type { TaskManagementConnectionCollection } from '../models/task-management-connection'; +import type { TicketCollection } from '../models/ticket'; +import type { TicketSuggestionCollection } from '../models/ticket-suggestion'; import type { TrialUserCollection } from '../models/trial-user'; import type { TrialUserActivityCollection } from '../models/trial-user-activity'; @@ -94,6 +98,10 @@ export interface DataAccess { SiteTopForm: SiteTopFormCollection; SiteTopPage: SiteTopPageCollection; Suggestion: SuggestionCollection; + OAuthNonce: OAuthNonceCollection; + TaskManagementConnection: TaskManagementConnectionCollection; + Ticket: TicketCollection; + TicketSuggestion: TicketSuggestionCollection; TrialUser: TrialUserCollection; TrialUserActivity: TrialUserActivityCollection; } diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js index 59c9dee49..68cf32ffb 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js @@ -20,6 +20,17 @@ describe('Ticket Schema', () => { attributes = ticketSchema.getAttributes(); }); + describe('taskManagementConnectionId attribute', () => { + it('is required and readOnly', () => { + expect(attributes.taskManagementConnectionId.required).to.be.true; + expect(attributes.taskManagementConnectionId.readOnly).to.be.true; + }); + + it('maps to connection_id DB column', () => { + expect(attributes.taskManagementConnectionId.postgrestField).to.equal('connection_id'); + }); + }); + describe('externalTicketId attribute', () => { it('exists', () => { expect(attributes.externalTicketId).to.exist; From 176dd8c5712d315dfbfaab2f706cfa2120fc8fcd Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 02:32:31 +0530 Subject: [PATCH 26/32] fix(data-access): suppress updatedAt/updatedBy for entities without those DB columns The SchemaBuilder auto-adds updatedAt and updatedBy attributes with defaults. These get included in every INSERT payload via toDbRecord/toDbMap. None of the 4 new tables have an updated_by column; ticket_suggestions and oauth_nonces also lack updated_at. Without suppression, every INSERT fails with "column updated_by does not exist" from PostgREST. Fix: override those attributes with postgrestIgnore: true in each schema so createFieldMaps excludes them from toDbMap and they are never sent to PostgREST. Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/oauth-nonce/oauth-nonce.schema.js | 6 ++++++ .../task-management-connection.schema.js | 3 +++ .../models/ticket-suggestion/ticket-suggestion.schema.js | 6 ++++++ .../src/models/ticket/ticket.schema.js | 3 +++ 4 files changed, 18 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js index f7af08570..a8a690a3a 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js @@ -22,6 +22,12 @@ import OAuthNonceCollection from './oauth-nonce.collection.js'; // at callback time to prevent CSRF/replay attacks. The index on nonce powers the // OAuthNonceCollection.delete({ nonce }) lookup used by auth-service at callback time. const schema = new SchemaBuilder(OAuthNonce, OAuthNonceCollection) + // oauth_nonces is append-only — no updated_at or updated_by columns in the DB. + // Suppress the SchemaBuilder auto-added attributes so they are not included in INSERTs. + .addAttribute('updatedAt', { + type: 'string', required: false, readOnly: true, postgrestIgnore: true, + }) + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) .addAttribute('nonce', { type: 'string', required: true, diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index e0a1cbb05..35b4c6be8 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -23,6 +23,9 @@ import { validateMetadata } from './metadata-validator.js'; // findActiveByOrganizationAndProvider() resolve to a single DB call: // allByOrganizationIdAndProviderAndStatus(orgId, 'jira_cloud', 'active') const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnectionCollection) + // task_management_connections table has updated_at but no updated_by column. Suppress + // updatedBy so it is not included in INSERTs or UPDATEs. + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) .addReference('belongs_to', 'Organization', ['provider', 'status']) .addReference('has_many', 'Tickets', ['updatedAt'], { removeDependents: true }) .addAttribute('provider', { diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js index ea97ed7e5..2c5eb7668 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -19,6 +19,12 @@ import TicketSuggestionCollection from './ticket-suggestion.collection.js'; // GSI on suggestionId powers findBySuggestionId() — used to check if a // suggestion has already been ticketed before attempting to create a duplicate. const schema = new SchemaBuilder(TicketSuggestion, TicketSuggestionCollection) + // ticket_suggestions is append-only — no updated_at or updated_by columns in the DB. + // Suppress the SchemaBuilder auto-added attributes so they are not included in INSERTs. + .addAttribute('updatedAt', { + type: 'string', required: false, readOnly: true, postgrestIgnore: true, + }) + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) .addReference('belongs_to', 'Ticket') .addAttribute('suggestionId', { type: 'string', diff --git a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js index bc14c5fa5..0c7cd7260 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -19,6 +19,9 @@ import Ticket from './ticket.model.js'; import TicketCollection from './ticket.collection.js'; const schema = new SchemaBuilder(Ticket, TicketCollection) + // tickets table has updated_at but no updated_by column. Suppress updatedBy so + // it is not included in INSERTs. + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) .addReference('belongs_to', 'Organization') .addReference('belongs_to', 'TaskManagementConnection') // The DB column for the TaskManagementConnection FK is `connection_id` (not From 86f44bb35cc302da86ffbfae4ae58880137d96e9 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 02:46:40 +0530 Subject: [PATCH 27/32] fix(data-access): mark OAuthNonce and TicketSuggestion as allowUpdates(false) (SITES-44690) Both tables have no UPDATE grant in the DB. Without allowUpdates(false), setters are generated and a mutate+save() call fails with an opaque PostgREST 403 instead of a clean ValidationError at the model layer. Aligns with the pattern used by Audit and AccessGrantLog. Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/oauth-nonce/oauth-nonce.schema.js | 4 ++++ .../models/ticket-suggestion/ticket-suggestion.schema.js | 4 ++++ .../test/unit/models/oauth-nonce/oauth-nonce.schema.test.js | 6 ++++++ .../ticket-suggestion/ticket-suggestion.schema.test.js | 6 ++++++ 4 files changed, 20 insertions(+) diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js index a8a690a3a..cb7b50940 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.schema.js @@ -22,6 +22,10 @@ import OAuthNonceCollection from './oauth-nonce.collection.js'; // at callback time to prevent CSRF/replay attacks. The index on nonce powers the // OAuthNonceCollection.delete({ nonce }) lookup used by auth-service at callback time. const schema = new SchemaBuilder(OAuthNonce, OAuthNonceCollection) + // oauth_nonces is append-only — the DB grants no UPDATE privilege (postgrest_anon and + // postgrest_writer both lack UPDATE). Disabling updates at the model layer surfaces a + // clean ValidationError instead of an opaque PostgREST 403 at the DB level. + .allowUpdates(false) // oauth_nonces is append-only — no updated_at or updated_by columns in the DB. // Suppress the SchemaBuilder auto-added attributes so they are not included in INSERTs. .addAttribute('updatedAt', { diff --git a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js index 2c5eb7668..07827ac94 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -19,6 +19,10 @@ import TicketSuggestionCollection from './ticket-suggestion.collection.js'; // GSI on suggestionId powers findBySuggestionId() — used to check if a // suggestion has already been ticketed before attempting to create a duplicate. const schema = new SchemaBuilder(TicketSuggestion, TicketSuggestionCollection) + // ticket_suggestions is append-only — the DB grants no UPDATE privilege (bridge rows are + // immutable once created; delete and recreate if wrong). Disabling updates at the model + // layer surfaces a clean ValidationError instead of an opaque PostgREST 403. + .allowUpdates(false) // ticket_suggestions is append-only — no updated_at or updated_by columns in the DB. // Suppress the SchemaBuilder auto-added attributes so they are not included in INSERTs. .addAttribute('updatedAt', { diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js index acd2d2d0e..0cb91a6c7 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js @@ -20,6 +20,12 @@ describe('OAuthNonce Schema', () => { attributes = oauthNonceSchema.getAttributes(); }); + describe('schema options', () => { + it('does not allow updates (append-only)', () => { + expect(oauthNonceSchema.allowsUpdates()).to.be.false; + }); + }); + describe('nonce attribute', () => { it('exists', () => { expect(attributes.nonce).to.exist; diff --git a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js index b90e4bd39..07b3a2b7b 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js @@ -20,6 +20,12 @@ describe('TicketSuggestion Schema', () => { attributes = ticketSuggestionSchema.getAttributes(); }); + describe('schema options', () => { + it('does not allow updates (append-only)', () => { + expect(ticketSuggestionSchema.allowsUpdates()).to.be.false; + }); + }); + describe('suggestionId attribute', () => { it('exists', () => { expect(attributes.suggestionId).to.exist; From 7e6fa58d3a99707c6d964bd49fc1c4a9e91a3688 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 02:53:01 +0530 Subject: [PATCH 28/32] fix(data-access): fix TypeScript declarations and schema comment for new entities (SITES-44690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add setDisplayName/setInstanceUrl to TaskManagementConnection.d.ts (both attrs are mutable since PR 623841de removed readOnly, but setters were missing from the TS interface, causing type errors for TypeScript consumers) - Add getOrganization() to TaskManagementConnection.d.ts (belongs_to reference getter was generated at runtime but not declared) - Add getOrganization(), getTaskManagementConnection(), getOpportunity(), getTicketSuggestions() to Ticket.d.ts (reference getters missing; pattern verified against Opportunity.d.ts which includes getSite()/getAudit()) - Fix schema comment: allByOrganizationIdAndProviderAndStatus → findByOrganizationIdAndProviderAndStatus (collection calls findBy, not allBy) Co-Authored-By: Claude Sonnet 4.6 --- .../src/models/task-management-connection/index.d.ts | 9 +++++++-- .../task-management-connection.schema.js | 2 +- .../src/models/ticket/index.d.ts | 8 +++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts index 5112d934b..549244f74 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -10,7 +10,9 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel, Ticket } from '../index'; +import type { + BaseCollection, BaseModel, Organization, Ticket, +} from '../index'; export interface TaskManagementConnection extends BaseModel { /** Returns true when the connection is healthy and ready to create tickets. */ @@ -31,19 +33,22 @@ export interface TaskManagementConnection extends BaseModel { getConnectedAt(): string | null; getConnectedBy(): string; - getExternalInstanceId(): string; getDisplayName(): string; getErrorMessage(): string | null; + getExternalInstanceId(): string; getInstanceUrl(): string; getLastUsedAt(): string | null; getMetadata(): object; + getOrganization(): Promise; getOrganizationId(): string; getProvider(): string; getStatus(): string; getTickets(): Promise; setConnectedAt(timestamp: string): TaskManagementConnection; + setDisplayName(name: string): TaskManagementConnection; setErrorMessage(message: string | null): TaskManagementConnection; + setInstanceUrl(url: string): TaskManagementConnection; setLastUsedAt(timestamp: string): TaskManagementConnection; setMetadata(metadata: object): TaskManagementConnection; setStatus(status: string): TaskManagementConnection; diff --git a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js index 35b4c6be8..f88f40728 100644 --- a/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -21,7 +21,7 @@ import { validateMetadata } from './metadata-validator.js'; // Sort key [provider, status] on the Organization GSI lets the collection method // findActiveByOrganizationAndProvider() resolve to a single DB call: -// allByOrganizationIdAndProviderAndStatus(orgId, 'jira_cloud', 'active') +// findByOrganizationIdAndProviderAndStatus(orgId, 'jira_cloud', 'active') const schema = new SchemaBuilder(TaskManagementConnection, TaskManagementConnectionCollection) // task_management_connections table has updated_at but no updated_by column. Suppress // updatedBy so it is not included in INSERTs or UPDATEs. diff --git a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts index 095092e37..d1c564b92 100644 --- a/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -10,17 +10,23 @@ * governing permissions and limitations under the License. */ -import type { BaseCollection, BaseModel } from '../index'; +import type { + BaseCollection, BaseModel, Organization, Opportunity, TaskManagementConnection, TicketSuggestion, +} from '../index'; export interface Ticket extends BaseModel { getCreatedBy(): string; + getOpportunity(): Promise; getOpportunityId(): string | undefined; + getOrganization(): Promise; getOrganizationId(): string; + getTaskManagementConnection(): Promise; getTaskManagementConnectionId(): string; getExternalTicketId(): string; getTicketKey(): string; getTicketProvider(): string; getTicketStatus(): string; + getTicketSuggestions(): Promise; getTicketUrl(): string; setTicketStatus(status: string): Ticket; From 15d0e43576d85d57410ed640ea84a5ed0fd789e7 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 03:19:47 +0530 Subject: [PATCH 29/32] fix(data-access): enforce expiry check in OAuthNonce.delete() (SITES-44690) The delete query now filters by `expires_at > NOW()` so expired nonces return 0 (rejected) even if the row has not been swept by the cleanup job. This matches the intended behaviour described in the DB migration: DELETE FROM oauth_nonces WHERE nonce = $1 AND expires_at > NOW() Without this check, an attacker who obtained an expired state token could replay it during the window between expiry and the next cleanup sweep. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../oauth-nonce/oauth-nonce.collection.js | 14 +++++++++++-- .../oauth-nonce.collection.test.js | 21 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js index 6946c5d05..ca30c6d67 100644 --- a/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js @@ -22,9 +22,18 @@ class OAuthNonceCollection extends BaseCollection { static COLLECTION_NAME = 'OAuthNonceCollection'; /** - * Atomically deletes a nonce by its value. - * Returns the number of rows deleted (1 = consumed, 0 = not found or already consumed). + * Atomically consumes a nonce: deletes it only if it exists AND has not expired. + * + * Mirrors the intended DB operation from the migration: + * DELETE FROM oauth_nonces WHERE nonce = $1 AND expires_at > NOW() RETURNING id + * + * Returns the number of rows deleted: + * 1 = consumed (nonce was valid and not expired) + * 0 = not found, already consumed, OR expired + * * Used by auth-service to enforce single-use replay prevention at OAuth callback time. + * An expired nonce must return 0 (rejected) even if the row still exists in the DB — + * the background cleanup job may not have swept it yet. * * @param {object} keys * @param {string} keys.nonce - The nonce value to consume. @@ -38,6 +47,7 @@ class OAuthNonceCollection extends BaseCollection { .from(this.tableName) .delete() .eq('nonce', nonce) + .gt('expires_at', new Date().toISOString()) .select(); if (error) { throw error; diff --git a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js index 7f065c365..af10272a6 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js @@ -40,10 +40,13 @@ describe('OAuthNonceCollection', () => { describe('delete()', () => { function setupDeleteChain(result) { const selectStub = sinon.stub().resolves(result); - const eqStub = sinon.stub().returns({ select: selectStub }); + const gtStub = sinon.stub().returns({ select: selectStub }); + const eqStub = sinon.stub().returns({ gt: gtStub }); const deleteStub = sinon.stub().returns({ eq: eqStub }); instance.postgrestService.from = sinon.stub().returns({ delete: deleteStub }); - return { deleteStub, eqStub, selectStub }; + return { + deleteStub, eqStub, gtStub, selectStub, + }; } it('returns 1 when nonce is found and deleted', async () => { @@ -70,6 +73,20 @@ describe('OAuthNonceCollection', () => { expect(result).to.equal(0); }); + it('filters by expires_at > now to reject expired nonces', async () => { + const { eqStub, gtStub } = setupDeleteChain({ data: [], error: null }); + + await instance.delete({ nonce: 'expired-nonce' }); + + expect(eqStub).to.have.been.calledOnceWith('nonce', 'expired-nonce'); + expect(gtStub).to.have.been.calledOnce; + expect(gtStub.firstCall.args[0]).to.equal('expires_at'); + // Second arg is an ISO date string — verify it's a recent timestamp + const filterTimestamp = new Date(gtStub.firstCall.args[1]); + expect(filterTimestamp).to.be.an.instanceOf(Date); + expect(Date.now() - filterTimestamp.getTime()).to.be.lessThan(5000); + }); + it('throws when PostgREST returns an error', async () => { setupDeleteChain({ data: null, error: new Error('DB error') }); From 17021483dd52a9ad2a9527ea878214f06a58c41f Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 13:17:51 +0530 Subject: [PATCH 30/32] feat(data-access): add IdempotencyKey data model (SITES-44690) Adds IdempotencyKey entity (schema, model, collection) for the idempotency_keys table from mysticat-data-service PR #720. Replaces raw PostgREST access in the API service with a proper data model that provides camelCase field mapping, validation, and enum-typed status values. Key design decisions: - belongs_to Organization (FK with cascade) - key + endpoint are readOnly (set at creation, never changed) - status enum: processing | completed | failed (default: processing) - updatedBy suppressed via postgrestIgnore (DB has no updated_by column) - Collection methods: findActiveKey(key, orgId) filters by expires_at > NOW(), deleteExpired() sweeps rows where expires_at < NOW() Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/models/base/entity.registry.js | 3 + .../idempotency-key.collection.js | 89 +++++++++ .../idempotency-key/idempotency-key.model.js | 41 +++++ .../idempotency-key/idempotency-key.schema.js | 50 +++++ .../src/models/idempotency-key/index.d.ts | 41 +++++ .../src/models/idempotency-key/index.js | 19 ++ .../src/models/index.d.ts | 1 + .../src/models/index.js | 1 + .../src/service/index.d.ts | 2 + .../idempotency-key.collection.test.js | 173 ++++++++++++++++++ .../idempotency-key.schema.test.js | 91 +++++++++ 11 files changed, 511 insertions(+) create mode 100644 packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js create mode 100644 packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.model.js create mode 100644 packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js create mode 100644 packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts create mode 100644 packages/spacecat-shared-data-access/src/models/idempotency-key/index.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js create mode 100644 packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js diff --git a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js index cc7f2761f..e11da058d 100755 --- a/packages/spacecat-shared-data-access/src/models/base/entity.registry.js +++ b/packages/spacecat-shared-data-access/src/models/base/entity.registry.js @@ -55,6 +55,7 @@ import SentimentGuidelineCollection from '../sentiment-guideline/sentiment-guide import SentimentTopicCollection from '../sentiment-topic/sentiment-topic.collection.js'; import AccessGrantLogCollection from '../access-grant-log/access-grant-log.collection.js'; import SiteImsOrgAccessCollection from '../site-ims-org-access/site-ims-org-access.collection.js'; +import IdempotencyKeyCollection from '../idempotency-key/idempotency-key.collection.js'; import OAuthNonceCollection from '../oauth-nonce/oauth-nonce.collection.js'; import TaskManagementConnectionCollection from '../task-management-connection/task-management-connection.collection.js'; import TicketCollection from '../ticket/ticket.collection.js'; @@ -101,6 +102,7 @@ import SentimentGuidelineSchema from '../sentiment-guideline/sentiment-guideline import SentimentTopicSchema from '../sentiment-topic/sentiment-topic.schema.js'; import AccessGrantLogSchema from '../access-grant-log/access-grant-log.schema.js'; import SiteImsOrgAccessSchema from '../site-ims-org-access/site-ims-org-access.schema.js'; +import IdempotencyKeySchema from '../idempotency-key/idempotency-key.schema.js'; import OAuthNonceSchema from '../oauth-nonce/oauth-nonce.schema.js'; import TaskManagementConnectionSchema from '../task-management-connection/task-management-connection.schema.js'; import TicketSchema from '../ticket/ticket.schema.js'; @@ -242,6 +244,7 @@ EntityRegistry.registerEntity(SentimentGuidelineSchema, SentimentGuidelineCollec EntityRegistry.registerEntity(SentimentTopicSchema, SentimentTopicCollection); EntityRegistry.registerEntity(AccessGrantLogSchema, AccessGrantLogCollection); EntityRegistry.registerEntity(SiteImsOrgAccessSchema, SiteImsOrgAccessCollection); +EntityRegistry.registerEntity(IdempotencyKeySchema, IdempotencyKeyCollection); EntityRegistry.registerEntity(OAuthNonceSchema, OAuthNonceCollection); EntityRegistry.registerEntity(TaskManagementConnectionSchema, TaskManagementConnectionCollection); EntityRegistry.registerEntity(TicketSchema, TicketCollection); diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js new file mode 100644 index 000000000..4d6b3ec3f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils'; + +import { ValidationError } from '../../errors/index.js'; +import BaseCollection from '../base/base.collection.js'; + +/** + * IdempotencyKeyCollection — manages IdempotencyKey entities. + * + * Auto-generated index query methods (via schema references): + * allByOrganizationId(organizationId) — all keys for an organization + * + * @class IdempotencyKeyCollection + * @extends BaseCollection + */ +class IdempotencyKeyCollection extends BaseCollection { + static COLLECTION_NAME = 'IdempotencyKeyCollection'; + + /** + * Finds a non-expired idempotency key by its value and organization. + * + * Returns null when: + * - No key exists with that value for the organization + * - The key exists but has expired (expires_at < NOW()) + * + * @param {string} key - The idempotency key value. + * @param {string} organizationId - The organization UUID. + * @returns {Promise} + */ + async findActiveKey(key, organizationId) { + if (!hasText(key)) { + throw new ValidationError('key is required', this); + } + if (!isValidUUID(organizationId)) { + throw new ValidationError('organizationId must be a valid UUID', this); + } + + const { data, error } = await this.postgrestService + .from(this.tableName) + .select() + .eq('key', key) + .eq('organization_id', organizationId) + .gt('expires_at', new Date().toISOString()) + .limit(1); + + if (error) { + throw error; + } + + if (!data || data.length === 0) { + return null; + } + + return this.createInstanceFromRow(data[0]); + } + + /** + * Deletes all expired idempotency keys. + * Called by the cleanup scheduler (every 5 minutes). + * + * @returns {Promise} Number of expired keys deleted. + */ + async deleteExpired() { + const { data, error } = await this.postgrestService + .from(this.tableName) + .delete() + .lt('expires_at', new Date().toISOString()) + .select('id'); + + if (error) { + throw error; + } + + return (data ?? []).length; + } +} + +export default IdempotencyKeyCollection; diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.model.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.model.js new file mode 100644 index 000000000..2f5f64d00 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.model.js @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import BaseModel from '../base/base.model.js'; + +/** + * IdempotencyKey — deduplicates concurrent or retried requests using the + * Stripe idempotency pattern. + * + * Status lifecycle: + * processing → completed (cached success response) + * processing → failed (cached error response, client must generate new key to retry) + * + * Keys are scoped to (key, organizationId, endpoint) — cross-org collision is impossible + * and the same key can be used independently across different operations. + * + * Keys expire after 24 hours (or shorter for ephemeral locks like token refresh dedup). + * + * @class IdempotencyKey + * @extends BaseModel + */ +class IdempotencyKey extends BaseModel { + static ENTITY_NAME = 'IdempotencyKey'; + + static STATUSES = { + PROCESSING: 'processing', + COMPLETED: 'completed', + FAILED: 'failed', + }; +} + +export default IdempotencyKey; diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js new file mode 100644 index 000000000..14eb7ee02 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* c8 ignore start */ + +import { isIsoDate } from '@adobe/spacecat-shared-utils'; + +import SchemaBuilder from '../base/schema.builder.js'; +import IdempotencyKey from './idempotency-key.model.js'; +import IdempotencyKeyCollection from './idempotency-key.collection.js'; + +// idempotency_keys table has updated_at but no updated_by column. +const schema = new SchemaBuilder(IdempotencyKey, IdempotencyKeyCollection) + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) + .addReference('belongs_to', 'Organization') + .addAttribute('key', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('endpoint', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('status', { + type: Object.values(IdempotencyKey.STATUSES), + required: true, + default: IdempotencyKey.STATUSES.PROCESSING, + }) + .addAttribute('response', { + type: 'any', + required: false, + }) + .addAttribute('expiresAt', { + type: 'string', + required: true, + validate: (value) => isIsoDate(value), + }); + +export default schema.build(); diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts new file mode 100644 index 000000000..85282f43a --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { BaseCollection, BaseModel, Organization } from '../index'; + +export interface IdempotencyKey extends BaseModel { + getEndpoint(): string; + getExpiresAt(): string; + getKey(): string; + getOrganization(): Promise; + getOrganizationId(): string; + getResponse(): object | null; + getStatus(): string; + + setResponse(response: object | null): IdempotencyKey; + setStatus(status: string): IdempotencyKey; +} + +export interface IdempotencyKeyCollection extends BaseCollection { + allByOrganizationId(organizationId: string): Promise; + + /** + * Finds a non-expired idempotency key by its value and organization. + * Returns null when no active key exists. + */ + findActiveKey(key: string, organizationId: string): Promise; + + /** + * Deletes all expired idempotency keys. Returns the number deleted. + */ + deleteExpired(): Promise; +} diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/index.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.js new file mode 100644 index 000000000..340fe5151 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.js @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import IdempotencyKey from './idempotency-key.model.js'; +import IdempotencyKeyCollection from './idempotency-key.collection.js'; + +export { + IdempotencyKey, + IdempotencyKeyCollection, +}; diff --git a/packages/spacecat-shared-data-access/src/models/index.d.ts b/packages/spacecat-shared-data-access/src/models/index.d.ts index 820b27175..0c4e1278b 100755 --- a/packages/spacecat-shared-data-access/src/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/index.d.ts @@ -30,6 +30,7 @@ export type * from './import-url'; export type * from './key-event'; export type * from './latest-audit'; export type * from './opportunity'; +export type * from './idempotency-key'; export type * from './oauth-nonce'; export type * from './organization'; export type * from './page-citability'; diff --git a/packages/spacecat-shared-data-access/src/models/index.js b/packages/spacecat-shared-data-access/src/models/index.js index 936279ceb..7a5764a26 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -54,6 +54,7 @@ export * from './page-citability/index.js'; export * from './plg-onboarding/index.js'; export * from './sentiment-guideline/index.js'; export * from './sentiment-topic/index.js'; +export * from './idempotency-key/index.js'; export * from './oauth-nonce/index.js'; export * from './task-management-connection/index.js'; export * from './ticket/index.js'; diff --git a/packages/spacecat-shared-data-access/src/service/index.d.ts b/packages/spacecat-shared-data-access/src/service/index.d.ts index 1ae261f87..a20702ea1 100644 --- a/packages/spacecat-shared-data-access/src/service/index.d.ts +++ b/packages/spacecat-shared-data-access/src/service/index.d.ts @@ -44,6 +44,7 @@ import type { SiteEnrollmentCollection } from '../models/site-enrollment'; import type { SiteTopFormCollection } from '../models/site-top-form'; import type { SiteTopPageCollection } from '../models/site-top-page'; import type { SuggestionCollection } from '../models/suggestion'; +import type { IdempotencyKeyCollection } from '../models/idempotency-key'; import type { OAuthNonceCollection } from '../models/oauth-nonce'; import type { TaskManagementConnectionCollection } from '../models/task-management-connection'; import type { TicketCollection } from '../models/ticket'; @@ -98,6 +99,7 @@ export interface DataAccess { SiteTopForm: SiteTopFormCollection; SiteTopPage: SiteTopPageCollection; Suggestion: SuggestionCollection; + IdempotencyKey: IdempotencyKeyCollection; OAuthNonce: OAuthNonceCollection; TaskManagementConnection: TaskManagementConnectionCollection; Ticket: TicketCollection; diff --git a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js new file mode 100644 index 000000000..efdab9562 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js @@ -0,0 +1,173 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use as chaiUse } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; + +import { createElectroMocks } from '../../util.js'; +import IdempotencyKey from '../../../../src/models/idempotency-key/idempotency-key.model.js'; + +chaiUse(chaiAsPromised); +chaiUse(sinonChai); + +const VALID_ORG_ID = '22222222-2222-2222-2222-222222222222'; + +const MOCK_RECORD = { + idempotencyKeyId: '11111111-1111-1111-1111-111111111111', + key: 'test-idempotency-key', + organizationId: VALID_ORG_ID, + endpoint: 'POST /task-management/jira_cloud/tickets', + status: 'processing', + response: null, + expiresAt: '2026-01-02T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + updatedBy: 'system', +}; + +describe('IdempotencyKeyCollection', () => { + let instance; + + beforeEach(() => { + ({ collection: instance } = createElectroMocks(IdempotencyKey, MOCK_RECORD)); + }); + + describe('findActiveKey()', () => { + function setupSelectChain(result) { + const limitStub = sinon.stub().resolves(result); + const gtStub = sinon.stub().returns({ limit: limitStub }); + const eqOrgStub = sinon.stub().returns({ gt: gtStub }); + const eqKeyStub = sinon.stub().returns({ eq: eqOrgStub }); + const selectStub = sinon.stub().returns({ eq: eqKeyStub }); + instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); + return { + selectStub, eqKeyStub, eqOrgStub, gtStub, limitStub, + }; + } + + it('returns instance when active key found', async () => { + const dbRow = { + id: MOCK_RECORD.idempotencyKeyId, + key: MOCK_RECORD.key, + organization_id: MOCK_RECORD.organizationId, + endpoint: MOCK_RECORD.endpoint, + status: MOCK_RECORD.status, + response: null, + expires_at: MOCK_RECORD.expiresAt, + created_at: MOCK_RECORD.createdAt, + updated_at: MOCK_RECORD.updatedAt, + }; + setupSelectChain({ data: [dbRow], error: null }); + + const result = await instance.findActiveKey('test-key', VALID_ORG_ID); + + expect(result).to.not.be.null; + }); + + it('returns null when no key found', async () => { + setupSelectChain({ data: [], error: null }); + + const result = await instance.findActiveKey('unknown-key', VALID_ORG_ID); + + expect(result).to.be.null; + }); + + it('returns null when data is null', async () => { + setupSelectChain({ data: null, error: null }); + + const result = await instance.findActiveKey('test-key', VALID_ORG_ID); + + expect(result).to.be.null; + }); + + it('filters by expires_at > now', async () => { + const { gtStub } = setupSelectChain({ data: [], error: null }); + + await instance.findActiveKey('test-key', VALID_ORG_ID); + + expect(gtStub).to.have.been.calledOnce; + expect(gtStub.firstCall.args[0]).to.equal('expires_at'); + const filterTimestamp = new Date(gtStub.firstCall.args[1]); + expect(Date.now() - filterTimestamp.getTime()).to.be.lessThan(5000); + }); + + it('throws when PostgREST returns an error', async () => { + setupSelectChain({ data: null, error: new Error('DB error') }); + + await expect(instance.findActiveKey('test-key', VALID_ORG_ID)) + .to.be.rejectedWith('DB error'); + }); + + it('throws ValidationError when key is missing', async () => { + await expect(instance.findActiveKey('', VALID_ORG_ID)) + .to.be.rejectedWith('key is required'); + }); + + it('throws ValidationError when organizationId is not a valid UUID', async () => { + await expect(instance.findActiveKey('test-key', 'not-a-uuid')) + .to.be.rejectedWith('organizationId must be a valid UUID'); + }); + }); + + describe('deleteExpired()', () => { + function setupDeleteChain(result) { + const selectStub = sinon.stub().resolves(result); + const ltStub = sinon.stub().returns({ select: selectStub }); + const deleteStub = sinon.stub().returns({ lt: ltStub }); + instance.postgrestService.from = sinon.stub().returns({ delete: deleteStub }); + return { deleteStub, ltStub, selectStub }; + } + + it('returns number of deleted rows', async () => { + setupDeleteChain({ data: [{ id: '1' }, { id: '2' }], error: null }); + + const result = await instance.deleteExpired(); + + expect(result).to.equal(2); + }); + + it('returns 0 when no expired rows', async () => { + setupDeleteChain({ data: [], error: null }); + + const result = await instance.deleteExpired(); + + expect(result).to.equal(0); + }); + + it('returns 0 when data is null', async () => { + setupDeleteChain({ data: null, error: null }); + + const result = await instance.deleteExpired(); + + expect(result).to.equal(0); + }); + + it('filters by expires_at < now', async () => { + const { ltStub } = setupDeleteChain({ data: [], error: null }); + + await instance.deleteExpired(); + + expect(ltStub).to.have.been.calledOnce; + expect(ltStub.firstCall.args[0]).to.equal('expires_at'); + const filterTimestamp = new Date(ltStub.firstCall.args[1]); + expect(Date.now() - filterTimestamp.getTime()).to.be.lessThan(5000); + }); + + it('throws when PostgREST returns an error', async () => { + setupDeleteChain({ data: null, error: new Error('DB error') }); + + await expect(instance.deleteExpired()).to.be.rejectedWith('DB error'); + }); + }); +}); diff --git a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js new file mode 100644 index 000000000..4c21317d0 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import idempotencyKeySchema from '../../../../src/models/idempotency-key/idempotency-key.schema.js'; +import IdempotencyKey from '../../../../src/models/idempotency-key/idempotency-key.model.js'; + +describe('IdempotencyKey Schema', () => { + let attributes; + + before(() => { + attributes = idempotencyKeySchema.getAttributes(); + }); + + describe('key attribute', () => { + it('is required and readOnly', () => { + expect(attributes.key.required).to.be.true; + expect(attributes.key.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.key.type).to.equal('string'); + }); + }); + + describe('endpoint attribute', () => { + it('is required and readOnly', () => { + expect(attributes.endpoint.required).to.be.true; + expect(attributes.endpoint.readOnly).to.be.true; + }); + + it('is of type string', () => { + expect(attributes.endpoint.type).to.equal('string'); + }); + }); + + describe('status attribute', () => { + it('is required', () => { + expect(attributes.status.required).to.be.true; + }); + + it('defaults to processing', () => { + expect(attributes.status.default).to.equal(IdempotencyKey.STATUSES.PROCESSING); + }); + + it('accepts valid status values', () => { + expect(attributes.status.type).to.include('processing'); + expect(attributes.status.type).to.include('completed'); + expect(attributes.status.type).to.include('failed'); + }); + }); + + describe('response attribute', () => { + it('is optional', () => { + expect(attributes.response.required).to.be.false; + }); + + it('is of type any', () => { + expect(attributes.response.type).to.equal('any'); + }); + }); + + describe('expiresAt attribute', () => { + it('is required', () => { + expect(attributes.expiresAt.required).to.be.true; + }); + + it('validates ISO date strings', () => { + expect(attributes.expiresAt.validate('2026-01-01T00:00:00.000Z')).to.be.true; + }); + + it('rejects non-ISO strings', () => { + expect(attributes.expiresAt.validate('not-a-date')).to.be.false; + }); + }); + + describe('updatedBy attribute', () => { + it('is suppressed via postgrestIgnore', () => { + expect(attributes.updatedBy.postgrestIgnore).to.be.true; + }); + }); +}); From 2a5e0dc27b52e5d06ee28cb36baa940ed39104de Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 13:35:12 +0530 Subject: [PATCH 31/32] fix(data-access): wrap IdempotencyKey PostgREST errors in DataAccessError (SITES-44690) PostgREST returns plain error objects ({ code, message }), not Error instances. The IT all-collections-methods-coverage test expects thrown errors to be instanceof Error. Wrap with DataAccessError (consistent with how BaseCollection handles PostgREST errors internally). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../idempotency-key/idempotency-key.collection.js | 6 +++--- .../idempotency-key.collection.test.js | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js index 4d6b3ec3f..c23c9bc09 100644 --- a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.collection.js @@ -12,7 +12,7 @@ import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils'; -import { ValidationError } from '../../errors/index.js'; +import { DataAccessError, ValidationError } from '../../errors/index.js'; import BaseCollection from '../base/base.collection.js'; /** @@ -55,7 +55,7 @@ class IdempotencyKeyCollection extends BaseCollection { .limit(1); if (error) { - throw error; + throw new DataAccessError('Failed to find active idempotency key', { entityName: 'IdempotencyKey' }, error); } if (!data || data.length === 0) { @@ -79,7 +79,7 @@ class IdempotencyKeyCollection extends BaseCollection { .select('id'); if (error) { - throw error; + throw new DataAccessError('Failed to delete expired idempotency keys', { entityName: 'IdempotencyKey' }, error); } return (data ?? []).length; diff --git a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js index efdab9562..cfb401675 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js @@ -102,11 +102,11 @@ describe('IdempotencyKeyCollection', () => { expect(Date.now() - filterTimestamp.getTime()).to.be.lessThan(5000); }); - it('throws when PostgREST returns an error', async () => { - setupSelectChain({ data: null, error: new Error('DB error') }); + it('throws DataAccessError when PostgREST returns an error', async () => { + setupSelectChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); await expect(instance.findActiveKey('test-key', VALID_ORG_ID)) - .to.be.rejectedWith('DB error'); + .to.be.rejectedWith('Failed to find active idempotency key'); }); it('throws ValidationError when key is missing', async () => { @@ -164,10 +164,11 @@ describe('IdempotencyKeyCollection', () => { expect(Date.now() - filterTimestamp.getTime()).to.be.lessThan(5000); }); - it('throws when PostgREST returns an error', async () => { - setupDeleteChain({ data: null, error: new Error('DB error') }); + it('throws DataAccessError when PostgREST returns an error', async () => { + setupDeleteChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); - await expect(instance.deleteExpired()).to.be.rejectedWith('DB error'); + await expect(instance.deleteExpired()) + .to.be.rejectedWith('Failed to delete expired idempotency keys'); }); }); }); From e54e75f9e72abc7c71b703f5812a80e3723a8de2 Mon Sep 17 00:00:00 2001 From: ppatwal Date: Thu, 2 Jul 2026 14:04:35 +0530 Subject: [PATCH 32/32] fix(data-access): mark IdempotencyKey.expiresAt as readOnly and tighten response type (SITES-44690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expiresAt: add readOnly: true — expiry must not be extended post-creation, that would undermine the idempotency contract - response: change TS type from object | null to Record | null for strict TypeScript consumers Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/models/idempotency-key/idempotency-key.schema.js | 1 + .../src/models/idempotency-key/index.d.ts | 4 ++-- .../models/idempotency-key/idempotency-key.schema.test.js | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js index 14eb7ee02..b072d3db6 100644 --- a/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js @@ -44,6 +44,7 @@ const schema = new SchemaBuilder(IdempotencyKey, IdempotencyKeyCollection) .addAttribute('expiresAt', { type: 'string', required: true, + readOnly: true, validate: (value) => isIsoDate(value), }); diff --git a/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts index 85282f43a..d7ba68863 100644 --- a/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/index.d.ts @@ -18,10 +18,10 @@ export interface IdempotencyKey extends BaseModel { getKey(): string; getOrganization(): Promise; getOrganizationId(): string; - getResponse(): object | null; + getResponse(): Record | null; getStatus(): string; - setResponse(response: object | null): IdempotencyKey; + setResponse(response: Record | null): IdempotencyKey; setStatus(status: string): IdempotencyKey; } diff --git a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js index 4c21317d0..ae6db224d 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js @@ -74,6 +74,10 @@ describe('IdempotencyKey Schema', () => { expect(attributes.expiresAt.required).to.be.true; }); + it('is readOnly (expiry must not be extended post-creation)', () => { + expect(attributes.expiresAt.readOnly).to.be.true; + }); + it('validates ISO date strings', () => { expect(attributes.expiresAt.validate('2026-01-01T00:00:00.000Z')).to.be.true; });