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..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,11 @@ 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'; +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'; @@ -97,6 +102,11 @@ 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'; +import TicketSuggestionSchema from '../ticket-suggestion/ticket-suggestion.schema.js'; /** * EntityRegistry - A registry class responsible for managing entities, their schema and collection. @@ -234,6 +244,11 @@ 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); +EntityRegistry.registerEntity(TicketSuggestionSchema, TicketSuggestionCollection); EntityRegistry.defaultEntities = { ...EntityRegistry.entities }; export default EntityRegistry; 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..c23c9bc09 --- /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 { DataAccessError, 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 new DataAccessError('Failed to find active idempotency key', { entityName: 'IdempotencyKey' }, 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 new DataAccessError('Failed to delete expired idempotency keys', { entityName: 'IdempotencyKey' }, 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..b072d3db6 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/idempotency-key/idempotency-key.schema.js @@ -0,0 +1,51 @@ +/* + * 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, + readOnly: 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..d7ba68863 --- /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(): Record | null; + getStatus(): string; + + setResponse(response: Record | 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 f50fd367e..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,8 @@ 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'; export type * from './page-intent'; @@ -40,6 +42,9 @@ 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 './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/index.js b/packages/spacecat-shared-data-access/src/models/index.js index 6cca5072d..7a5764a26 100755 --- a/packages/spacecat-shared-data-access/src/models/index.js +++ b/packages/spacecat-shared-data-access/src/models/index.js @@ -54,3 +54,8 @@ 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'; +export * from './ticket-suggestion/index.js'; 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..4ceb5f546 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.d.ts @@ -0,0 +1,26 @@ +/* + * 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 } 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/oauth-nonce/index.js b/packages/spacecat-shared-data-access/src/models/oauth-nonce/index.js new file mode 100644 index 000000000..fe668df16 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/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 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..ca30c6d67 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.collection.js @@ -0,0 +1,59 @@ +/* + * 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 BaseCollection from '../base/base.collection.js'; + +/** + * OAuthNonceCollection — manages OAuthNonce records. + * + * @class OAuthNonceCollection + * @extends BaseCollection + */ +class OAuthNonceCollection extends BaseCollection { + static COLLECTION_NAME = 'OAuthNonceCollection'; + + /** + * 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. + * @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() + .eq('nonce', nonce) + .gt('expires_at', new Date().toISOString()) + .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..a4f184115 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.model.js @@ -0,0 +1,35 @@ +/* + * 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'; + +/** + * 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..cb7b50940 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/oauth-nonce/oauth-nonce.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 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) + // 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', { + type: 'string', required: false, readOnly: true, postgrestIgnore: true, + }) + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) + .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/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/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/index.d.ts b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts new file mode 100644 index 000000000..549244f74 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.d.ts @@ -0,0 +1,82 @@ +/* + * 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, 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; + /** Persists status = 'disabled'. */ + 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; + + getConnectedAt(): string | null; + getConnectedBy(): 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; +} + +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..ff5404bdb --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/index.js @@ -0,0 +1,21 @@ +/* + * 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 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..d318c0eba --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/metadata-validator.js @@ -0,0 +1,97 @@ +/* + * 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 { 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. + */ +// 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: + // - 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'), + scopes: (v) => (Array.isArray(v) && v.every((s) => typeof s === 'string') + ? null + : 'scopes must be an array of strings'), + }, + }, +}; + +/** + * 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}`); + } + + 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) { + 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/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..5f96f9d83 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.collection.js @@ -0,0 +1,61 @@ +/* + * 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 { 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..488d44b8e --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.model.js @@ -0,0 +1,119 @@ +/* + * 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'; + +/** + * TaskManagementConnection — one OAuth connection from an organization to a + * task-management provider (e.g. Jira Cloud). + * + * Status lifecycle (per architecture spec): + * active → tokens are valid, tickets can be created + * 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 lives in `metadata` as jsonb (jira_cloud: { cloudId, scopes }). + * Display fields (siteName, siteUrl) live in the dedicated displayName/instanceUrl columns. + * + * @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 (per architecture spec PR #150). + * + * 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', + }; + + /** + * 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 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 + * state without waiting for the next GC cycle. + * + * @returns {Promise} + */ + async markRequiresReauth() { + 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/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..f88f40728 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/task-management-connection/task-management-connection.schema.js @@ -0,0 +1,111 @@ +/* + * 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, 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: +// 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. + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) + .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, + }) + // display_name column (PR #720): human-readable site name from Atlassian accessible-resources. + // 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, + 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, + validate: (value) => isValidUrl(value), + }) + // connected_by column (PR #720): IMS user ID (JWT sub) of the person who completed OAuth. + .addAttribute('connectedBy', { + type: 'string', + 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 || isIsoDate(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). + // Never changes after connection is created — readOnly. + .addAttribute('externalInstanceId', { + type: 'string', + required: true, + readOnly: true, + validate: (value) => typeof value === 'string' && value.length > 0, + }) + .addAttribute('lastUsedAt', { + type: 'string', + required: false, + validate: (value) => !value || isIsoDate(value), + }) + .addAttribute('errorMessage', { + type: 'string', + required: false, + }) + + // 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, + 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 new file mode 100644 index 000000000..b74e8c0c2 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/index.d.ts @@ -0,0 +1,26 @@ +/* + * 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, 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..59475f0ee --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/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 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..f90cd3c88 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.collection.js @@ -0,0 +1,29 @@ +/* + * 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 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..0b2f3e0db --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.model.js @@ -0,0 +1,36 @@ +/* + * 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'; + +/** + * 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..07827ac94 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket-suggestion/ticket-suggestion.schema.js @@ -0,0 +1,53 @@ +/* + * 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 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) + // 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', { + type: 'string', required: false, readOnly: true, postgrestIgnore: true, + }) + .addAttribute('updatedBy', { type: 'string', required: false, postgrestIgnore: true }) + .addReference('belongs_to', 'Ticket') + .addAttribute('suggestionId', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('opportunityId', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('createdBy', { + type: 'string', + required: true, + readOnly: true, + }) + .addIndex( + { composite: ['suggestionId'] }, + { composite: [] }, + ); + +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..d1c564b92 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/index.d.ts @@ -0,0 +1,40 @@ +/* + * 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, 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; +} + +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..7929ec926 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/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 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..dc3c745f2 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.collection.js @@ -0,0 +1,30 @@ +/* + * 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 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..5087cde3f --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.model.js @@ -0,0 +1,44 @@ +/* + * 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'; + +/** + * Ticket — one Jira issue created by ASO via a TaskManagementConnection. + * + * Preserves the provider-side identifiers needed for future operations: + * 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 + * 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 (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. 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 + */ +class Ticket extends BaseModel { + static ENTITY_NAME = 'Ticket'; +} + +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..0c7cd7260 --- /dev/null +++ b/packages/spacecat-shared-data-access/src/models/ticket/ticket.schema.js @@ -0,0 +1,72 @@ +/* + * 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 { isValidUUID, 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) + // 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 + // `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 }) + .addAttribute('externalTicketId', { + 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: false, + default: null, + }) + .addAttribute('ticketProvider', { + type: 'string', + required: true, + readOnly: true, + }) + .addAttribute('createdBy', { + type: 'string', + required: true, + readOnly: true, + }); + +export default schema.build(); 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..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,11 @@ 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'; +import type { TicketSuggestionCollection } from '../models/ticket-suggestion'; import type { TrialUserCollection } from '../models/trial-user'; import type { TrialUserActivityCollection } from '../models/trial-user-activity'; @@ -94,6 +99,11 @@ export interface DataAccess { SiteTopForm: SiteTopFormCollection; SiteTopPage: SiteTopPageCollection; Suggestion: SuggestionCollection; + IdempotencyKey: IdempotencyKeyCollection; + OAuthNonce: OAuthNonceCollection; + TaskManagementConnection: TaskManagementConnectionCollection; + Ticket: TicketCollection; + TicketSuggestion: TicketSuggestionCollection; TrialUser: TrialUserCollection; TrialUserActivity: TrialUserActivityCollection; } 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/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..cfb401675 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.collection.test.js @@ -0,0 +1,174 @@ +/* + * 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 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('Failed to find active idempotency key'); + }); + + 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 DataAccessError when PostgREST returns an error', async () => { + setupDeleteChain({ data: null, error: { code: 'PGRST205', message: 'DB error' } }); + + await expect(instance.deleteExpired()) + .to.be.rejectedWith('Failed to delete expired idempotency keys'); + }); + }); +}); 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..ae6db224d --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/idempotency-key/idempotency-key.schema.test.js @@ -0,0 +1,95 @@ +/* + * 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('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; + }); + + 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; + }); + }); +}); 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..af10272a6 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.collection.test.js @@ -0,0 +1,100 @@ +/* + * 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 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 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, gtStub, 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('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') }); + + 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'); + }); + }); +}); 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..0cb91a6c7 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/oauth-nonce/oauth-nonce.schema.test.js @@ -0,0 +1,68 @@ +/* + * 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 oauthNonceSchema from '../../../../src/models/oauth-nonce/oauth-nonce.schema.js'; + +describe('OAuthNonce Schema', () => { + let attributes; + + before(() => { + 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; + }); + + 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; + }); + }); +}); 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..1ff2c154e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/metadata-validator.test.js @@ -0,0 +1,104 @@ +/* + * 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 { 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 minimal metadata (cloudId only)', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + })).not.to.throw(); + }); + + it('accepts metadata with optional scopes array', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + scopes: ['read:jira-work', 'write:jira-work'], + })).not.to.throw(); + }); + + it('accepts empty scopes array', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + scopes: [], + })).not.to.throw(); + }); + + 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' })) + .to.throw('cloudId must be a valid UUID'); + }); + + 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, + siteUrl: 'https://my-org.atlassian.net', + })).to.throw('Unexpected metadata properties'); + }); + + it('rejects extra properties', () => { + expect(() => validateMetadata('jira_cloud', { + cloudId: VALID_CLOUD_ID, + unexpected: 'field', + })).to.throw('Unexpected metadata properties'); + }); + }); + + describe('unknown provider', () => { + it('throws for unknown provider', () => { + expect(() => validateMetadata('asana', { anything: 'goes' })) + .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'); + }); + }); +}); 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..caa74e012 --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.model.test.js @@ -0,0 +1,253 @@ +/* + * 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 { 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, + externalInstanceId: '33333333-3333-3333-3333-333333333333', + 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', + }; + + ({ 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 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'); + }); + }); + + describe('isActive()', () => { + it('returns true when status is active', () => { + 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; + }); + }); + + 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('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('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', () => { + 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'); + }); + }); +}); 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..18316179e --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/task-management-connection/task-management-connection.schema.test.js @@ -0,0 +1,156 @@ +/* + * 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 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 mutable (updated on re-auth)', () => { + expect(attributes.displayName.required).to.be.true; + expect(attributes.displayName.readOnly).to.not.be.true; + }); + }); + + describe('instanceUrl attribute', () => { + it('is required and mutable (updated on re-auth)', () => { + expect(attributes.instanceUrl.required).to.be.true; + expect(attributes.instanceUrl.readOnly).to.not.be.true; + }); + }); + + 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; + expect(attributes.lastUsedAt.required).to.be.false; + }); + + it('validates ISO date strings', () => { + 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; + expect(attributes.errorMessage.required).to.be.false; + }); + }); +}); 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..07b3a2b7b --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket-suggestion/ticket-suggestion.schema.test.js @@ -0,0 +1,79 @@ +/* + * 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 ticketSuggestionSchema from '../../../../src/models/ticket-suggestion/ticket-suggestion.schema.js'; + +describe('TicketSuggestion Schema', () => { + let attributes; + + before(() => { + 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; + }); + + 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 = Object.values(ticketSuggestionSchema.getIndexes()); + expect(indexes).to.be.an('array').that.is.not.empty; + const suggestionIdIndex = indexes.find( + (idx) => idx.pk?.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..68cf32ffb --- /dev/null +++ b/packages/spacecat-shared-data-access/test/unit/models/ticket/ticket.schema.test.js @@ -0,0 +1,101 @@ +/* + * 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 ticketSchema from '../../../../src/models/ticket/ticket.schema.js'; + +describe('Ticket Schema', () => { + let attributes; + + before(() => { + 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; + }); + + 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; + }); + }); +});