diff --git a/apps/api/package.json b/apps/api/package.json index 9285844e..85fa1ab9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "test:m5-03": "node ../../tools/m5-03/run-focused-api-test.mjs", "test:m5-04": "node ../../tools/m5-04/run-focused-api-test.mjs", "test:m5-05": "node ../../tools/m5-05/run-focused-api-test.mjs", + "test:m5-06": "node ../../tools/m5-06/run-focused-api-test.mjs", "test:m4-01": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/m4-01-meta-app-config.spec.js", "test:m3-09": "node ../../tools/m3-09/run-focused-test.mjs", "test:m3-08": "node ../../tools/m3-08/run-focused-test.mjs", diff --git a/apps/api/src/database/migrations/migrations.ts b/apps/api/src/database/migrations/migrations.ts index fc7d6c18..0dc54cff 100644 --- a/apps/api/src/database/migrations/migrations.ts +++ b/apps/api/src/database/migrations/migrations.ts @@ -22,6 +22,7 @@ import { CONNECTION_LIFECYCLE_MIGRATION, CONNECTION_LIFECYCLE_TABLES } from './c import { INTERNAL_ASSET_OPERATIONS_MIGRATION, INTERNAL_ASSET_OPERATION_TABLES } from './internal-asset-operations.js'; import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from './onboarding-coexistence-lock.js'; import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from './automation-config.js'; +import { NOTIFICATIONS_MIGRATION, NOTIFICATION_TABLES } from './notifications.js'; function ddl( id: string, @@ -295,6 +296,7 @@ const migrationsWithoutChecksums = [ INTERNAL_ASSET_OPERATIONS_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_MIGRATION, AUTOMATION_CONFIG_MIGRATION, + NOTIFICATIONS_MIGRATION, ] as const; export const MIGRATIONS: readonly VersionedMigration[] = migrationsWithoutChecksums.map((migration) => ({ @@ -329,5 +331,6 @@ export const DOMAIN_TABLES = [ ...INTERNAL_ASSET_OPERATION_TABLES, ...ONBOARDING_COEXISTENCE_LOCK_TABLES, ...AUTOMATION_CONFIG_TABLES, + ...NOTIFICATION_TABLES, ] as const; diff --git a/apps/api/src/database/migrations/notifications.ts b/apps/api/src/database/migrations/notifications.ts new file mode 100644 index 00000000..3e874dd8 --- /dev/null +++ b/apps/api/src/database/migrations/notifications.ts @@ -0,0 +1,139 @@ +import { migrationChecksum } from './migration-fingerprint.js'; +import { assertExpectedColumns, tableExists } from './schema-inspection.js'; +import type { DdlStep, MigrationStepContext, VersionedMigration } from './migration.types.js'; + +interface IndexDefinition { + readonly name: string; + readonly columns: readonly string[]; + readonly unique: boolean; +} + +async function verifyTable(queryRunner: import('typeorm').QueryRunner, table: string, + columns: readonly string[], indexes: readonly IndexDefinition[]): Promise { + if (!(await tableExists(queryRunner, table))) return false; + await assertExpectedColumns(queryRunner, table, columns); + const rows = await queryRunner.query( + 'SELECT index_name AS name, column_name, non_unique FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? ORDER BY index_name, seq_in_index', + [table], + ) as Array<{ name: string; column_name: string; non_unique: number | string }>; + const actual = new Map(); + for (const row of rows) { + const index = actual.get(row.name) ?? { columns: [], unique: Number(row.non_unique) === 0 }; + index.columns.push(row.column_name); + actual.set(row.name, index); + } + for (const expected of indexes) { + const index = actual.get(expected.name); + if (!index || index.unique !== expected.unique || index.columns.join('\0') !== expected.columns.join('\0')) { + throw new Error(`Notification migration found an incompatible index on ${table}.${expected.name}.`); + } + } + return true; +} + +function tableStep(id: string, table: string, columns: readonly string[], indexes: readonly IndexDefinition[], sql: string): DdlStep { + return { + id, + phase: 'expand', + sql, + definitionVersion: 'v1', + definition: JSON.stringify({ table, columns, indexes, verify: verifyTable.toString() }), + retryWhenAbsent: true, + verify: ({ queryRunner }) => verifyTable(queryRunner, table, columns, indexes), + }; +} + +const notifications = tableStep( + 'create-tenant-notifications', + 'ca_meta_notifications', + ['id', 'tenant_id', 'asset_id', 'kind', 'message_ref', 'provider_target_ref', 'source_event_hash', 'status', 'safe_summary', 'created_at', 'updated_at'], + [ + { name: 'PRIMARY', columns: ['id'], unique: true }, + { name: 'uq_ca_meta_notification_tenant_id', columns: ['tenant_id', 'id'], unique: true }, + { name: 'uq_ca_meta_notification_source', columns: ['tenant_id', 'source_event_hash'], unique: true }, + { name: 'ix_ca_meta_notification_tenant_list', columns: ['tenant_id', 'status', 'created_at', 'id'], unique: false }, + { name: 'ix_ca_meta_notification_tenant_asset', columns: ['tenant_id', 'asset_id', 'status', 'created_at', 'id'], unique: false }, + ], + `CREATE TABLE IF NOT EXISTS ca_meta_notifications ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + kind VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + message_ref CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + provider_target_ref VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + source_event_hash BINARY(32) NOT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'UNREAD', + safe_summary VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_ca_meta_notification_tenant_id (tenant_id, id), + UNIQUE KEY uq_ca_meta_notification_source (tenant_id, source_event_hash), + KEY ix_ca_meta_notification_tenant_list (tenant_id, status, created_at, id), + KEY ix_ca_meta_notification_tenant_asset (tenant_id, asset_id, status, created_at, id), + CONSTRAINT fk_ca_meta_notification_tenant FOREIGN KEY (tenant_id) REFERENCES ca_meta_tenants (id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT fk_ca_meta_notification_asset FOREIGN KEY (tenant_id, asset_id) REFERENCES ca_meta_assets (tenant_id, id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT ck_ca_meta_notification_kind CHECK (kind IN ('MESSAGE_RECEIVED', 'COMMENT_RECEIVED', 'ASSET_STATUS_CHANGED', 'ACTION_REQUIRED')), + CONSTRAINT ck_ca_meta_notification_status CHECK (status IN ('UNREAD', 'READ', 'DISMISSED')) + ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`, +); + +const actions = tableStep( + 'create-tenant-notification-action-receipts', + 'ca_meta_notification_action_receipts', + ['id', 'tenant_id', 'notification_id', 'action', 'idempotency_key_hash', 'request_hash', 'operation_id', 'status', 'safe_error_code', 'actor_ref_hash', 'created_at', 'updated_at'], + [ + { name: 'PRIMARY', columns: ['id'], unique: true }, + { name: 'uq_ca_meta_notification_action_idempotency', columns: ['tenant_id', 'idempotency_key_hash'], unique: true }, + { name: 'uq_ca_meta_notification_action_operation', columns: ['tenant_id', 'operation_id'], unique: true }, + { name: 'ix_ca_meta_notification_action_notification', columns: ['tenant_id', 'notification_id', 'created_at'], unique: false }, + ], + `CREATE TABLE IF NOT EXISTS ca_meta_notification_action_receipts ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + notification_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + action VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + idempotency_key_hash BINARY(32) NOT NULL, + request_hash BINARY(32) NOT NULL, + operation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + safe_error_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, + actor_ref_hash BINARY(32) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_ca_meta_notification_action_idempotency (tenant_id, idempotency_key_hash), + UNIQUE KEY uq_ca_meta_notification_action_operation (tenant_id, operation_id), + KEY ix_ca_meta_notification_action_notification (tenant_id, notification_id, created_at), + CONSTRAINT fk_ca_meta_notification_action_notification FOREIGN KEY (tenant_id, notification_id) + REFERENCES ca_meta_notifications (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT fk_ca_meta_notification_action_job FOREIGN KEY (tenant_id, operation_id) + REFERENCES ca_meta_jobs (tenant_id, id) ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT ck_ca_meta_notification_action CHECK (action IN ('MARK_READ', 'DISMISS', 'SEND_TEMPLATE', 'REPLY_COMMENT')), + CONSTRAINT ck_ca_meta_notification_action_status CHECK (status IN ('SUCCEEDED', 'PENDING', 'RUNNING', 'FAILED', 'UNKNOWN')) + ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin`, +); + +const definition = { + id: '202611120024-tenant-notifications', + schemaVersion: 24, + minimumSupportedApiVersion: 1, + steps: [notifications, actions], +} as const; + +export const NOTIFICATIONS_MIGRATION: VersionedMigration = { + ...definition, + checksum: migrationChecksum(definition), +}; + +export const NOTIFICATION_TABLES = [ + 'ca_meta_notifications', + 'ca_meta_notification_action_receipts', +] as const; + +export async function verifyNotificationTables(context: MigrationStepContext): Promise { + return await tableExists(context.queryRunner, 'ca_meta_notifications') && + await tableExists(context.queryRunner, 'ca_meta_notification_action_receipts'); +} diff --git a/apps/api/src/identity/identity.types.ts b/apps/api/src/identity/identity.types.ts index c778d145..621e60db 100644 --- a/apps/api/src/identity/identity.types.ts +++ b/apps/api/src/identity/identity.types.ts @@ -41,6 +41,7 @@ export type ServiceScope = | 'TENANT_RESOURCE_READ' | 'META_CONNECTION_EXECUTE' | 'CREDENTIAL_USE' + | 'NOTIFICATION_PROJECTION_WRITE' | 'WEBHOOK_ROUTE' | 'MAGIC_LINK_DELIVERY'; diff --git a/apps/api/src/identity/mysql-identity.repository.ts b/apps/api/src/identity/mysql-identity.repository.ts index 66bd7ab9..62d4ddbe 100644 --- a/apps/api/src/identity/mysql-identity.repository.ts +++ b/apps/api/src/identity/mysql-identity.repository.ts @@ -19,6 +19,7 @@ const SERVICE_SCOPES = new Set([ 'TENANT_RESOURCE_READ', 'META_CONNECTION_EXECUTE', 'CREDENTIAL_USE', + 'NOTIFICATION_PROJECTION_WRITE', 'WEBHOOK_ROUTE', 'MAGIC_LINK_DELIVERY', ]); diff --git a/apps/api/src/jobs/event-validation.ts b/apps/api/src/jobs/event-validation.ts index f50f2599..85669bd3 100644 --- a/apps/api/src/jobs/event-validation.ts +++ b/apps/api/src/jobs/event-validation.ts @@ -37,7 +37,7 @@ export interface OutboxEventEnvelope { readonly data: Readonly>; } -export type JobType = 'INBOX_PROCESS' | 'OUTBOX_DELIVER' | 'ONBOARDING_STEP' | 'PROJECTION_RECONCILE' | 'HISTORY_SYNC' | 'CREDENTIAL_INSPECTION' | 'ASSET_REFRESH' | 'TEMPLATE_CREATE'; +export type JobType = 'INBOX_PROCESS' | 'OUTBOX_DELIVER' | 'ONBOARDING_STEP' | 'PROJECTION_RECONCILE' | 'HISTORY_SYNC' | 'CREDENTIAL_INSPECTION' | 'ASSET_REFRESH' | 'TEMPLATE_CREATE' | 'NOTIFICATION_ACTION'; export type JobPayload = | { readonly kind: 'INBOX_PROCESS'; readonly inboxEventRef: string } @@ -60,6 +60,13 @@ export type JobPayload = | { readonly kind: 'TEMPLATE_CREATE'; readonly assetId: string; readonly appConfigurationId: string; readonly environment: 'development' | 'test' | 'staging' | 'production'; readonly request: TemplateCreateRequest; + } + | { + readonly kind: 'NOTIFICATION_ACTION'; readonly notificationId: string; readonly assetId: string; + readonly appConfigurationId: string; readonly environment: 'development' | 'test' | 'staging' | 'production'; + readonly request: + | { readonly action: 'SEND_TEMPLATE'; readonly templateName: string; readonly language: string; readonly parameters: readonly string[] } + | { readonly action: 'REPLY_COMMENT'; readonly replyText: string }; }; export type JobLane = 'WEBHOOK_URGENT' | 'ONBOARDING' | 'HISTORICAL_PROJECTION'; @@ -112,6 +119,11 @@ export const JOB_POLICIES: Readonly> = Object.freeze( lane: 'ONBOARDING', effectMode: 'AMBIGUOUS', maxAttempts: 1, maxElapsedMs: 15 * 60_000, maxHandlerMs: 60_000, retryBaseMs: 1_000, retryMaxMs: 1_000, }, + NOTIFICATION_ACTION: { + // A single outbound attempt is permitted; ambiguity becomes UNKNOWN and is never retried. + lane: 'ONBOARDING', effectMode: 'AMBIGUOUS', maxAttempts: 1, + maxElapsedMs: 15 * 60_000, maxHandlerMs: 60_000, retryBaseMs: 1_000, retryMaxMs: 1_000, + }, }); export const MAX_WAITING_JOBS_PER_TENANT = 1_000; @@ -200,6 +212,23 @@ const jobPayloadSchemas: Readonly> = { }, }, }, + NOTIFICATION_ACTION: { + type: 'object', additionalProperties: false, + required: ['kind', 'notificationId', 'assetId', 'appConfigurationId', 'environment', 'request'], + properties: { + kind: { const: 'NOTIFICATION_ACTION' }, notificationId: { type: 'string', format: 'uuid' }, + assetId: { type: 'string', format: 'uuid' }, appConfigurationId: { type: 'string', format: 'uuid' }, + environment: { enum: ['development', 'test', 'staging', 'production'] }, + request: { oneOf: [ + { type: 'object', additionalProperties: false, required: ['action', 'templateName', 'language', 'parameters'], + properties: { action: { const: 'SEND_TEMPLATE' }, templateName: { type: 'string', minLength: 1, maxLength: 128 }, + language: { type: 'string', minLength: 2, maxLength: 16 }, + parameters: { type: 'array', maxItems: 100, items: { type: 'string', minLength: 1, maxLength: 1024 } } } }, + { type: 'object', additionalProperties: false, required: ['action', 'replyText'], + properties: { action: { const: 'REPLY_COMMENT' }, replyText: { type: 'string', minLength: 1, maxLength: 4096 } } }, + ] }, + }, + }, }; const jobValidators = new Map>( diff --git a/apps/api/src/jobs/job-execution-authorization.ts b/apps/api/src/jobs/job-execution-authorization.ts index 243e9b68..3da1bf0a 100644 --- a/apps/api/src/jobs/job-execution-authorization.ts +++ b/apps/api/src/jobs/job-execution-authorization.ts @@ -60,6 +60,7 @@ const REQUIRED_JOB_SCOPES: Readonly>> = Ob CREDENTIAL_INSPECTION: 'CREDENTIAL_USE', ASSET_REFRESH: 'META_CONNECTION_EXECUTE', TEMPLATE_CREATE: 'CREDENTIAL_USE', + NOTIFICATION_ACTION: 'CREDENTIAL_USE', } as const satisfies Readonly>>); function resourceReference(type: JobType, payload: JobPayload): string | undefined { @@ -73,6 +74,7 @@ function resourceReference(type: JobType, payload: JobPayload): string | undefin case 'CREDENTIAL_INSPECTION': return payload.credentialId; case 'ASSET_REFRESH': return payload.assetId; case 'TEMPLATE_CREATE': return payload.assetId; + case 'NOTIFICATION_ACTION': return payload.notificationId; } } @@ -291,6 +293,26 @@ export class MysqlJobExecutionAuthorizationPort implements JobExecutionAuthoriza // The operation grant name is a candidate only; real Meta eligibility remains a release gate. return rows.length === 1; } + case 'NOTIFICATION_ACTION': { + const isCommentReply = payload.request.action === 'REPLY_COMMENT'; + const compatibleAssetKinds = isCommentReply + ? ['FACEBOOK_PAGE', 'INSTAGRAM_BUSINESS_ACCOUNT', 'INSTAGRAM_CREATOR_ACCOUNT'] + : ['WHATSAPP_PHONE_NUMBER']; + const kindPredicate = isCommentReply ? "n.kind = 'COMMENT_RECEIVED'" : '1 = 1'; + const rows = await database.query( + `SELECT n.id + FROM ca_meta_notifications n + JOIN ca_meta_assets a ON a.tenant_id = n.tenant_id AND a.id = n.asset_id + WHERE n.tenant_id = ? AND n.id = ? AND n.asset_id = ? + AND a.app_configuration_id = ? AND a.environment = ? AND a.provider = 'META' + AND n.status = 'UNREAD' AND a.asset_kind IN (${compatibleAssetKinds.map(() => '?').join(',')}) + AND ${kindPredicate} + LIMIT 1`, + [tenantId, payload.notificationId, payload.assetId, payload.appConfigurationId, payload.environment, + ...compatibleAssetKinds], + ) as Array<{ id: string }>; + return rows.length === 1; + } } } } diff --git a/apps/api/src/jobs/job-store.ts b/apps/api/src/jobs/job-store.ts index 8bbcbe9a..6133f6f9 100644 --- a/apps/api/src/jobs/job-store.ts +++ b/apps/api/src/jobs/job-store.ts @@ -179,6 +179,7 @@ function resourceScope(input: EnqueueJobInput, payload: JobPayload): string { case 'CREDENTIAL_INSPECTION': return payload.credentialId; case 'ASSET_REFRESH': return payload.assetId; case 'TEMPLATE_CREATE': return payload.assetId; + case 'NOTIFICATION_ACTION': return payload.notificationId; } } diff --git a/apps/api/src/meta-assets/meta-assets.module.ts b/apps/api/src/meta-assets/meta-assets.module.ts index babd1f6a..477e9694 100644 --- a/apps/api/src/meta-assets/meta-assets.module.ts +++ b/apps/api/src/meta-assets/meta-assets.module.ts @@ -45,6 +45,8 @@ import { MysqlWhatsAppTemplatesAccountDirectory, WhatsAppTemplatesService, } from '../whatsapp-templates/whatsapp-templates.service.js'; import type { WhatsAppTemplatesProvider } from '../whatsapp-templates/whatsapp-templates.service.js'; +import { NotificationsController } from '../notifications/notifications.controller.js'; +import { NotificationsService } from '../notifications/notifications.service.js'; @Module({}) export class MetaAssetsModule { @@ -52,7 +54,7 @@ export class MetaAssetsModule { return { module: MetaAssetsModule, imports: [identityModule, JobsModule.register(environment), PrivacyModule.register(environment)], - controllers: [AssetCatalogController, WhatsAppTemplatesController, AutomationConfigController], + controllers: [AssetCatalogController, WhatsAppTemplatesController, AutomationConfigController, NotificationsController], providers: [ { provide: PortalSessionGuard, @@ -113,6 +115,13 @@ export class MetaAssetsModule { new AutomationConfigService(repository, assets, contexts, authorization, environment.nodeEnv), inject: [AUTOMATION_CONFIG_REPOSITORY, META_ASSETS_REPOSITORY, TenantContextService, TenantAuthorizationService], }, + { + provide: NotificationsService, + useFactory: (database: DurableWorkDatabase, contexts: TenantContextService, + authorization: TenantAuthorizationService, serviceAuthorization: ServiceTenantAuthorizationService, jobs: DurableJobStore) => + new NotificationsService(database, contexts, authorization, serviceAuthorization, jobs, false), + inject: [DURABLE_WORK_DATABASE, TenantContextService, TenantAuthorizationService, ServiceTenantAuthorizationService, DURABLE_JOB_STORE], + }, { provide: CREDENTIAL_KEY_PROVIDER, useFactory: () => new ConfiguredCredentialKeyProvider(environment.credentialKeyRing) }, { provide: CREDENTIAL_VAULT_AUTHORIZATION, useClass: FailClosedCredentialVaultAuthorization }, { diff --git a/apps/api/src/notifications/notification-action.worker.ts b/apps/api/src/notifications/notification-action.worker.ts new file mode 100644 index 00000000..6dec7bee --- /dev/null +++ b/apps/api/src/notifications/notification-action.worker.ts @@ -0,0 +1,260 @@ +import type { EntityManager } from 'typeorm'; +import type { CredentialResolverService } from '../credentials/credential-resolver.service.js'; +import type { CredentialResolverOperation, MetaAuthVariant, MetaFlowVariant } from '../credentials/credential-resolver.types.js'; +import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js'; +import type { MetaAppConfigRegistry } from '../meta-config/meta-app-config.registry.js'; +import type { MetaGraphClient } from '../meta-graph/meta-graph.client.js'; +import type { MetaGraphCallResult, MetaGraphExecutionContext, MetaGraphInput, MetaGraphJson } from '../meta-graph/meta-graph.types.js'; +import { isVerifiedJobExecutionContext, type VerifiedJobExecutionContext } from '../jobs/job-execution-authorization.js'; +import type { ClaimedJob, JobFinalization, JobOutcome } from '../jobs/job-store.js'; +import type { JobHandler } from '../worker/durable-worker.js'; +import type { JobPayload } from '../jobs/event-validation.js'; +import { isVerifiedServiceTenantScope } from '../identity/identity.service.js'; +import type { VerifiedServiceTenantScope } from '../identity/identity.types.js'; +import type { DurableWorkDatabase } from '../jobs/durable-work-database.js'; + +type NotificationActionPayload = Extract; +type NotificationActionRequest = NotificationActionPayload['request']; + +export interface NotificationActionTarget { + readonly tenantId: string; + readonly notificationId: string; + readonly assetId: string; + readonly appConfigurationId: string; + readonly environment: MetaEnvironment; + readonly assetKind: string; + readonly providerAssetId: string; + readonly providerTargetRef: string; + readonly flowVariant: MetaFlowVariant; + readonly authVariant: MetaAuthVariant; +} + +export interface NotificationActionDirectory { + resolve(input: { + readonly serviceScope: VerifiedServiceTenantScope; + readonly payload: NotificationActionPayload; + }): Promise; +} + +interface NotificationActionTargetRow { + tenant_id: string; + notification_id: string; + asset_id: string; + app_configuration_id: string; + environment: string; + asset_kind: string; + provider_asset_id: string; + provider_target_ref: string | null; + flow_variant: string; + auth_variant: string; +} + +/** Resolves only a notification-owned target and active verified asset authorization in the job tenant. */ +export class MysqlNotificationActionDirectory implements NotificationActionDirectory { + constructor(private readonly database: DurableWorkDatabase) {} + + async resolve(input: { readonly serviceScope: VerifiedServiceTenantScope; readonly payload: NotificationActionPayload }): Promise { + if (!isVerifiedServiceTenantScope(input.serviceScope) || input.serviceScope.tenantId.length === 0) return undefined; + const dataSource = await this.database.getDataSource(); + const rows = await dataSource.query( + `SELECT n.tenant_id, n.id AS notification_id, a.id AS asset_id, a.app_configuration_id, + a.environment, a.asset_kind, a.external_id AS provider_asset_id, n.provider_target_ref, + auth.flow_variant, auth.auth_variant + FROM ca_meta_notifications n + JOIN ca_meta_assets a ON a.tenant_id = n.tenant_id AND a.id = n.asset_id + JOIN ca_meta_asset_authorizations aa ON aa.tenant_id = a.tenant_id AND aa.asset_id = a.id + AND aa.app_configuration_id = a.app_configuration_id AND aa.provider = a.provider AND aa.environment = a.environment + AND aa.state = 'ACTIVE' AND aa.verification_state = 'VERIFIED' + JOIN ca_meta_authorizations auth ON auth.tenant_id = aa.tenant_id AND auth.id = aa.authorization_id + AND auth.app_configuration_id = aa.app_configuration_id AND auth.provider = aa.provider + AND auth.environment = aa.environment AND auth.authorization_state IN ('ACTIVE', 'PARTIAL') + WHERE n.tenant_id = ? AND n.id = ? AND n.asset_id = ? AND n.status = 'UNREAD' + AND a.app_configuration_id = ? AND a.environment = ? + AND n.provider_target_ref IS NOT NULL + AND ((n.kind = 'MESSAGE_RECEIVED' AND a.asset_kind = 'WHATSAPP_PHONE_NUMBER') + OR (n.kind = 'COMMENT_RECEIVED' AND a.asset_kind IN ('FACEBOOK_PAGE', 'INSTAGRAM_BUSINESS_ACCOUNT', 'INSTAGRAM_CREATOR_ACCOUNT'))) + LIMIT 2`, + [input.serviceScope.tenantId, input.payload.notificationId, input.payload.assetId, + input.payload.appConfigurationId, input.payload.environment], + ) as NotificationActionTargetRow[]; + if (rows.length !== 1) return undefined; + const row = rows[0]; + if (!row || row.tenant_id !== input.serviceScope.tenantId || row.notification_id !== input.payload.notificationId || + row.asset_id !== input.payload.assetId || row.app_configuration_id !== input.payload.appConfigurationId || + row.environment !== input.payload.environment || !validProviderRef(row.provider_asset_id) || + !row.provider_target_ref || !validProviderRef(row.provider_target_ref) || + !validFlow(row.flow_variant) || !validAuth(row.auth_variant)) return undefined; + return Object.freeze({ tenantId: row.tenant_id, notificationId: row.notification_id, assetId: row.asset_id, + appConfigurationId: row.app_configuration_id, environment: row.environment as MetaEnvironment, + assetKind: row.asset_kind, providerAssetId: row.provider_asset_id, providerTargetRef: row.provider_target_ref, + flowVariant: row.flow_variant, authVariant: row.auth_variant }); + } +} + +export interface NotificationActionProviderInput { + readonly context: MetaGraphExecutionContext; + readonly credentialHandle: Awaited>['handle']; + readonly operation: 'SEND_MESSAGE' | 'MANAGE_COMMENTS'; + readonly targetRef: string; + readonly request: NotificationActionRequest; + readonly correlationId: string; +} + +export interface NotificationActionProvider { + execute(input: NotificationActionProviderInput, signal: AbortSignal): Promise; +} + +export type NotificationActionResult = { readonly kind: 'SUCCEEDED' } | { readonly kind: 'FAILED_NO_EFFECT' } | { readonly kind: 'UNKNOWN' }; + +export interface NotificationActionAdapter { + execute(input: { + readonly tenantId: string; + readonly notificationId: string; + readonly assetId: string; + readonly appConfigurationId: string; + readonly environment: MetaEnvironment; + readonly request: NotificationActionRequest; + readonly serviceScope: VerifiedServiceTenantScope; + readonly correlationId: string; + }, signal: AbortSignal): Promise; +} + +/** Resolves an asset-bound credential before handing a one-use handle to the provider adapter. */ +export class MetaGraphNotificationActionAdapter implements NotificationActionAdapter { + constructor( + private readonly directory: NotificationActionDirectory, + private readonly configurations: MetaAppConfigRegistry, + private readonly credentials: Pick, + private readonly provider: NotificationActionProvider, + ) {} + + async execute(input: Parameters[0], signal: AbortSignal): Promise { + if (!isVerifiedServiceTenantScope(input.serviceScope) || input.serviceScope.tenantId !== input.tenantId || + !validEnvironment(input.environment) || signal.aborted) return { kind: 'FAILED_NO_EFFECT' }; + let target: NotificationActionTarget | undefined; + try { target = await this.directory.resolve({ serviceScope: input.serviceScope, payload: { + kind: 'NOTIFICATION_ACTION', notificationId: input.notificationId, assetId: input.assetId, + appConfigurationId: input.appConfigurationId, environment: input.environment, request: input.request, + } }); } catch { return { kind: 'FAILED_NO_EFFECT' }; } + if (!target || target.tenantId !== input.tenantId || target.notificationId !== input.notificationId || + target.assetId !== input.assetId || target.appConfigurationId !== input.appConfigurationId || + target.environment !== input.environment || !target.providerTargetRef || !target.providerAssetId || + !validFlowForAction(target, input.request)) return { kind: 'FAILED_NO_EFFECT' }; + + const operation: CredentialResolverOperation = input.request.action === 'SEND_TEMPLATE' ? 'SEND_MESSAGE' : 'MANAGE_COMMENTS'; + const profile = this.configurations.resolve(target.flowVariant, target.authVariant, target.environment); + if (!profile || profile.versions.graphApi.status !== 'VERIFIED') return { kind: 'FAILED_NO_EFFECT' }; + let credential: Awaited>; + try { + credential = await this.credentials.resolve({ tenantId: input.tenantId, provider: 'META', + appConfigurationId: input.appConfigurationId, environment: input.environment, assetId: input.assetId, operation }, + { serviceScope: input.serviceScope, correlationId: input.correlationId }); + } catch { return { kind: 'FAILED_NO_EFFECT' }; } + if (credential.status !== 'READY' || credential.capability !== operation || signal.aborted) return { kind: 'FAILED_NO_EFFECT' }; + + const context: MetaGraphExecutionContext = Object.freeze({ + tenantId: input.tenantId, provider: 'META', appConfigurationId: input.appConfigurationId, + environment: input.environment, assetId: input.assetId, operation, + flowVariant: target.flowVariant, authVariant: target.authVariant, + configurationRef: profile.configurationRef, providerObjectId: target.providerAssetId, + }); + try { + const result = await this.provider.execute({ context, credentialHandle: credential.handle, operation, + targetRef: target.providerTargetRef, request: input.request, correlationId: input.correlationId }, signal); + if (!result || result.outcome === 'UNKNOWN' || result.ambiguous) return { kind: 'UNKNOWN' }; + if (result.outcome === 'FAILED' || result.error) return { kind: 'FAILED_NO_EFFECT' }; + if (result.outcome !== 'SUCCEEDED') return { kind: 'UNKNOWN' }; + return { kind: 'SUCCEEDED' }; + } catch { + // The provider call may already have taken effect; its outcome is unknown and is never retried. + return { kind: 'UNKNOWN' }; + } + } +} + +/** Uses the already capability-checked Graph client; runtime registration remains explicitly absent. */ +export class MetaGraphNotificationProvider implements NotificationActionProvider { + constructor(private readonly graph: Pick) {} + + async execute(input: NotificationActionProviderInput): Promise { + const request = input.request; + const graphInput: MetaGraphInput = request.action === 'SEND_TEMPLATE' + ? Object.freeze({ + endpoint: 'SEND_MESSAGE', context: input.context, credentialHandle: input.credentialHandle, + credentialCapability: 'SEND_MESSAGE', operation: 'SEND_MESSAGE', correlationId: input.correlationId, + resourceId: input.context.providerObjectId, + body: Object.freeze({ messaging_product: 'whatsapp', to: input.targetRef, type: 'template', + template: Object.freeze({ name: request.templateName, language: Object.freeze({ code: request.language }), + ...(request.parameters.length > 0 ? { components: Object.freeze([Object.freeze({ type: 'body', + parameters: Object.freeze(request.parameters.map((text) => Object.freeze({ type: 'text', text }))) })]) } : {}) }) }), + }) + : Object.freeze({ + endpoint: 'COMMENT_REPLY', context: input.context, credentialHandle: input.credentialHandle, + credentialCapability: 'MANAGE_COMMENTS', operation: 'MANAGE_COMMENTS', correlationId: input.correlationId, + resourceId: input.targetRef, body: Object.freeze({ message: request.replyText }), + }); + return await this.graph.execute(graphInput); + } +} + +export function createNotificationActionJobHandler(adapter: NotificationActionAdapter): JobHandler { + return async (job: ClaimedJob, signal: AbortSignal, authorization: VerifiedJobExecutionContext): Promise => { + if (!isVerifiedJobExecutionContext(authorization) || job.type !== 'NOTIFICATION_ACTION' || + job.payload.kind !== 'NOTIFICATION_ACTION' || authorization.tenantId !== job.tenantId || + authorization.resourceRef !== job.payload.notificationId || authorization.serviceScope.tenantId !== job.tenantId) { + return { kind: 'PERMANENT_FAILURE', safeErrorCode: 'INVALID_REFERENCE' }; + } + let result: NotificationActionResult | null | undefined; + try { + result = await adapter.execute({ tenantId: job.tenantId, notificationId: job.payload.notificationId, + assetId: job.payload.assetId, appConfigurationId: job.payload.appConfigurationId, + environment: job.payload.environment, request: job.payload.request, + serviceScope: authorization.serviceScope, correlationId: job.correlationId }, signal); + } catch { result = undefined; } + if (result?.kind === 'SUCCEEDED') return { kind: 'SUCCEEDED' }; + if (result?.kind === 'FAILED_NO_EFFECT') return { kind: 'PERMANENT_FAILURE', safeErrorCode: 'HANDLER_FAILED' }; + return { kind: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN' }; + }; +} + +export function notificationActionFinalizer(manager: EntityManager, job: ClaimedJob, finalization: JobFinalization): Promise { + if (job.type !== 'NOTIFICATION_ACTION' || job.payload.kind !== 'NOTIFICATION_ACTION') return Promise.resolve(); + const payload = job.payload; + const receiptStatus = finalization.status === 'SUCCEEDED' ? 'SUCCEEDED' + : finalization.status === 'UNKNOWN' ? 'UNKNOWN' + : finalization.status === 'RETRY_PENDING' ? 'PENDING' : 'FAILED'; + return manager.query( + `UPDATE ca_meta_notification_action_receipts SET status = ?, safe_error_code = ?, updated_at = UTC_TIMESTAMP(6) + WHERE tenant_id = ? AND notification_id = ? AND operation_id = ?`, + [receiptStatus, finalization.safeErrorCode, job.tenantId, payload.notificationId, job.id], + ).then(async () => { + if (finalization.status === 'SUCCEEDED') { + await manager.query( + `UPDATE ca_meta_notifications SET status = 'READ', updated_at = UTC_TIMESTAMP(6) + WHERE tenant_id = ? AND id = ? AND status = 'UNREAD'`, [job.tenantId, payload.notificationId], + ); + } + }); +} + +function validEnvironment(value: string): value is MetaEnvironment { + return value === 'development' || value === 'test' || value === 'staging' || value === 'production'; +} + +function validProviderRef(value: string): boolean { return /^[A-Za-z0-9._:-]{1,191}$/.test(value); } +function validFlow(value: string): value is MetaFlowVariant { + return value === 'FACEBOOK' || value === 'INSTAGRAM' || value === 'WHATSAPP_EXCLUSIVE' || value === 'WHATSAPP_COEXISTENCE'; +} +function validAuth(value: string): value is MetaAuthVariant { + return value === 'FACEBOOK_LOGIN_FOR_BUSINESS' || value === 'INSTAGRAM_VIA_FACEBOOK_LOGIN' || value === 'INSTAGRAM_LOGIN' || + value === 'WHATSAPP_EMBEDDED_SIGNUP' || value === 'WHATSAPP_BUSINESS_APP_ONBOARDING'; +} + +function validFlowForAction(target: NotificationActionTarget, request: NotificationActionRequest): boolean { + if (request.action === 'SEND_TEMPLATE') return target.assetKind === 'WHATSAPP_PHONE_NUMBER' && + target.flowVariant === 'WHATSAPP_EXCLUSIVE' && target.authVariant === 'WHATSAPP_EMBEDDED_SIGNUP'; + return target.assetKind === 'FACEBOOK_PAGE' && target.flowVariant === 'FACEBOOK' && + target.authVariant === 'FACEBOOK_LOGIN_FOR_BUSINESS' || + ['INSTAGRAM_BUSINESS_ACCOUNT', 'INSTAGRAM_CREATOR_ACCOUNT'].includes(target.assetKind) && + target.flowVariant === 'INSTAGRAM' && ['INSTAGRAM_LOGIN', 'INSTAGRAM_VIA_FACEBOOK_LOGIN'].includes(target.authVariant); +} diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 00000000..14b734e1 --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -0,0 +1,166 @@ +import { + BadRequestException, + ConflictException, + Controller, + Get, + Headers, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + Query, + Req, + Res, + ServiceUnavailableException, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import type { components } from '@ca-meta/contracts'; +import { PublicRoute } from '../common/security/public-route.decorator.js'; +import { PortalSessionGuard, type PortalRequest } from '../identity/portal-session.guard.js'; +import { PORTAL_CONTEXT_COOKIE, readPortalCookie } from '../identity/portal-session.service.js'; +import { + NotificationExternalActionUnavailableError, + NotificationActionUnresolvedError, + NotificationIdempotencyConflictError, + NotificationInputError, + NotificationNotFoundError, + NotificationsService, +} from './notifications.service.js'; + +function tenantContextRef(request: PortalRequest, headerContext?: string): string { + const ref = headerContext ?? readPortalCookie(request.headers.cookie, PORTAL_CONTEXT_COOKIE); + if (!ref || !/^[A-Za-z0-9._~-]{16,512}$/.test(ref)) throw new BadRequestException(); + return ref; +} + +function requestId(request: PortalRequest): string { return request.requestId ?? 'unknown'; } + +function parseLimit(value: unknown): number { + if (value === undefined) return 50; + if (typeof value !== 'string' || !/^\d{1,3}$/.test(value)) throw new BadRequestException(); + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new BadRequestException(); + return limit; +} + +type NotificationChannel = 'FACEBOOK' | 'INSTAGRAM' | 'WHATSAPP'; + +function parseChannel(value: unknown): NotificationChannel | undefined { + if (value === undefined) return undefined; + if (value === 'FACEBOOK' || value === 'INSTAGRAM' || value === 'WHATSAPP') return value; + throw new BadRequestException(); +} + +function parseNumber(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length > 33 || !/^\+?[0-9]{1,32}$/.test(value)) throw new BadRequestException(); + return value; +} + +function object(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new BadRequestException(); + return value as Record; +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)) && keys.every((key) => Object.hasOwn(value, key)); +} + +function safeText(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value); +} + +function parseAction(value: unknown): components['schemas']['NotificationActionRequest'] { + const body = object(value); + if (body['action'] === 'MARK_READ' || body['action'] === 'DISMISS') { + if (!exactKeys(body, ['action'])) throw new BadRequestException(); + return { action: body['action'] }; + } + if (body['action'] === 'SEND_TEMPLATE') { + if (!exactKeys(body, ['action', 'templateName', 'language', 'parameters']) || + !safeText(body['templateName'], 128) || !safeText(body['language'], 16) || !Array.isArray(body['parameters']) || + body['parameters'].length > 100 || !body['parameters'].every((item) => safeText(item, 1024))) throw new BadRequestException(); + return { action: 'SEND_TEMPLATE', templateName: body['templateName'], language: body['language'], parameters: body['parameters'] }; + } + if (body['action'] === 'REPLY' || body['action'] === 'REPLY_COMMENT') { + if (!exactKeys(body, ['action', 'replyText']) || !safeText(body['replyText'], 4096)) throw new BadRequestException(); + return { action: 'REPLY_COMMENT', replyText: body['replyText'] }; + } + if (body['action'] === 'SEND_MESSAGE') { + if (!exactKeys(body, ['action', 'messageText']) || !safeText(body['messageText'], 4096)) throw new BadRequestException(); + return { action: 'SEND_MESSAGE', messageText: body['messageText'] }; + } + throw new BadRequestException(); +} + +@Controller() +export class NotificationsController { + constructor(private readonly notifications: NotificationsService) {} + + @Get('notifications') + @PublicRoute() + @UseGuards(PortalSessionGuard) + async list( + @Query() query: Record, + @Req() request: PortalRequest, + @Headers('x-tenant-context') headerContext?: string, + ): Promise { + try { + if (query['cursor'] !== undefined && typeof query['cursor'] !== 'string') throw new BadRequestException(); + return await this.notifications.list(request.portalSession, tenantContextRef(request, headerContext), parseLimit(query['limit']), + query['cursor'] as string | undefined, requestId(request), { + channel: parseChannel(query['channel']), + number: parseNumber(query['number']), + }); + } catch (error) { + if (error instanceof NotificationInputError) throw new BadRequestException(); + throw error; + } + } + + @Post('notifications/:notificationId/actions') + @PublicRoute() + @UseGuards(PortalSessionGuard) + @HttpCode(HttpStatus.OK) + async act( + @Param('notificationId') notificationId: string, + @Headers('x-tenant-context') headerContext: string | undefined, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Req() request: PortalRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + try { + const result = await this.notifications.act(request.portalSession, tenantContextRef(request, headerContext), notificationId, + idempotencyKey ?? '', parseAction(request.body), requestId(request)); + if ('jobId' in result) { + response.status(HttpStatus.ACCEPTED); + response.setHeader('Location', result.location); + } + return result; + } catch (error) { + if (error instanceof NotificationInputError) throw new BadRequestException(); + if (error instanceof NotificationNotFoundError) throw new NotFoundException(); + if (error instanceof NotificationIdempotencyConflictError) throw new ConflictException(); + if (error instanceof NotificationActionUnresolvedError) throw new ConflictException(); + if (error instanceof NotificationExternalActionUnavailableError) throw new ServiceUnavailableException(); + throw error; + } + } + + @Get('operations/:operationId') + @PublicRoute() + @UseGuards(PortalSessionGuard) + async operation( + @Param('operationId') operationId: string, + @Req() request: PortalRequest, + @Headers('x-tenant-context') headerContext?: string, + ): Promise { + const result = await this.notifications.operation(request.portalSession, + tenantContextRef(request, headerContext), operationId, requestId(request)); + if (!result) throw new NotFoundException(); + return result; + } +} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 00000000..fde7a376 --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -0,0 +1,368 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { components } from '@ca-meta/contracts'; +import { IdentityDeniedError } from '../identity/identity.errors.js'; +import { isVerifiedPortalSession, type VerifiedPortalSession } from '../identity/identity.trust.js'; +import { isVerifiedServiceTenantScope, ServiceTenantAuthorizationService, TenantAuthorizationService, TenantContextService, +} from '../identity/identity.service.js'; +import type { AuthorizedTenantScope, VerifiedServiceTenantScope } from '../identity/identity.types.js'; +import type { DurableWorkDatabase } from '../jobs/durable-work-database.js'; +import type { DurableJobStore } from '../jobs/job-store.js'; +import { canonicalJson } from '../jobs/event-validation.js'; + +export class NotificationNotFoundError extends Error { + constructor(message = 'Notification is unavailable in the authorized tenant context.') { + super(message); + this.name = 'NotificationNotFoundError'; + } +} +export class NotificationInputError extends Error {} +export class NotificationIdempotencyConflictError extends Error {} +export class NotificationActionUnresolvedError extends Error {} +export class NotificationExternalActionUnavailableError extends Error {} + +type Notification = components['schemas']['Notification']; +type NotificationAction = components['schemas']['NotificationActionRequest']; +type NotificationPage = components['schemas']['NotificationListPage']; +type ActionResult = components['schemas']['NotificationActionResult']; +type ActionAccepted = components['schemas']['NotificationActionAccepted']; + +interface NotificationRow { + id: string; + kind: Notification['kind']; + asset_id: string; + message_ref: string | null; + status: Notification['status']; + created_at: string | Date; +} + +interface ActionReceiptRow { + id: string; + notification_id: string; + action: string; + request_hash: Buffer | string; + operation_id: string | null; + status: string; +} + +interface Cursor { createdAt: string; id: string } +interface NotificationListFilters { + readonly channel?: 'FACEBOOK' | 'INSTAGRAM' | 'WHATSAPP'; + readonly number?: string; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CURSOR_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SAFE_KIND_SUMMARY: Readonly> = Object.freeze({ + MESSAGE_RECEIVED: 'A new WhatsApp message is available.', + COMMENT_RECEIVED: 'A new comment is available.', + ASSET_STATUS_CHANGED: 'An account status changed.', + ACTION_REQUIRED: 'A connection needs your attention.', +}); + +function digest(value: string): Buffer { return createHash('sha256').update(value, 'utf8').digest(); } +function hex(value: Buffer | string): string { return Buffer.isBuffer(value) ? value.toString('hex') : value.toLowerCase(); } +function canonicalCursorDate(value: string): string | undefined { + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.(\d{1,6})Z$/.exec(value); + if (!match) return undefined; + const timestamp = `${match[1]}.${match[2].padEnd(6, '0')}Z`; + const parsed = new Date(timestamp); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 19) !== timestamp.slice(0, 19)) return undefined; + return timestamp; +} +function cursorDate(value: string | Date): string { + if (value instanceof Date) { + if (!Number.isFinite(value.getTime())) throw new Error('Stored notification timestamp is invalid.'); + return value.toISOString().replace(/\.(\d{3})Z$/, (_match, milliseconds: string) => `.${milliseconds}000Z`); + } + const timestamp = canonicalCursorDate(value); + if (!timestamp) throw new Error('Stored notification timestamp is invalid.'); + return timestamp; +} +function cursorSqlDate(value: string): string { + return `${value.slice(0, 10)} ${value.slice(11, -1)}`; +} +function encodeCursor(value: Cursor): string { return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); } +function decodeCursor(value: string | undefined): Cursor | undefined { + if (value === undefined) return undefined; + if (value.length > 512 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new NotificationInputError(); + try { + const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record; + const createdAt = typeof decoded['createdAt'] === 'string' ? canonicalCursorDate(decoded['createdAt']) : undefined; + const id = decoded['id']; + if (!createdAt || typeof id !== 'string' || !CURSOR_ID.test(id)) throw new Error(); + return { createdAt, id }; + } catch { throw new NotificationInputError(); } +} + +function requiredId(value: string): string { + if (!UUID.test(value)) throw new NotificationNotFoundError(); + return value; +} + +export class NotificationsService { + constructor( + private readonly database: DurableWorkDatabase, + private readonly contexts: TenantContextService, + private readonly authorization: TenantAuthorizationService, + private readonly serviceAuthorization: ServiceTenantAuthorizationService, + private readonly jobs?: DurableJobStore, + private readonly externalActionsEnabled = false, + ) {} + + async list(session: VerifiedPortalSession, contextRef: string, limit: number, rawCursor: string | undefined, + requestId: string, filters: NotificationListFilters = {}): Promise { + const scope = await this.portalScope(session, contextRef); + await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId); + const cursor = decodeCursor(rawCursor); + const pageSize = Number.isSafeInteger(limit) && limit >= 1 && limit <= 100 ? limit : 50; + const database = await this.database.getDataSource(); + const cursorDateValue = cursor ? cursorSqlDate(cursor.createdAt) : null; + const clauses = ['n.tenant_id = ?', "n.status <> 'DISMISSED'"]; + const values: Array = [scope.tenantId]; + if (filters.channel === 'FACEBOOK') clauses.push("a.asset_kind = 'FACEBOOK_PAGE'"); + if (filters.channel === 'INSTAGRAM') clauses.push("a.asset_kind IN ('INSTAGRAM_BUSINESS_ACCOUNT', 'INSTAGRAM_CREATOR_ACCOUNT')"); + if (filters.channel === 'WHATSAPP') clauses.push("a.asset_kind IN ('WHATSAPP_PHONE_NUMBER', 'WHATSAPP_BUSINESS_ACCOUNT')"); + if (filters.number !== undefined) { + clauses.push("a.asset_kind = 'WHATSAPP_PHONE_NUMBER' AND a.external_id = ?"); + values.push(filters.number); + } + if (cursor) { + clauses.push('(n.created_at < ? OR (n.created_at = ? AND n.id < ?))'); + values.push(cursorDateValue, cursorDateValue, cursor.id); + } + values.push(pageSize + 1); + const rows = await database.query( + `SELECT n.id, n.kind, n.asset_id, n.message_ref, n.status, + DATE_FORMAT(n.created_at, '%Y-%m-%dT%H:%i:%s.%fZ') AS created_at + FROM ca_meta_notifications n + INNER JOIN ca_meta_assets a ON a.tenant_id = n.tenant_id AND a.id = n.asset_id + WHERE ${clauses.join(' AND ')} + ORDER BY n.created_at DESC, n.id DESC LIMIT ?`, + values, + ) as NotificationRow[]; + const hasNext = rows.length > pageSize; + const visible = rows.slice(0, pageSize); + const items = visible.map((row) => Object.freeze({ + id: row.id, + kind: row.kind, + assetRef: row.asset_id, + messageRef: row.message_ref, + status: row.status, + safeSummary: SAFE_KIND_SUMMARY[row.kind] ?? 'A notification is available.', + createdAt: cursorDate(row.created_at), + actions: Object.freeze([ + ...(row.status === 'UNREAD' ? ['MARK_READ' as const] : []), + 'DISMISS' as const, + ]), + })); + const last = items.at(-1); + return Object.freeze({ items: Object.freeze(items), nextCursor: hasNext && last ? encodeCursor({ createdAt: last.createdAt, id: last.id }) : null, requestId }); + } + + async act(session: VerifiedPortalSession, contextRef: string, notificationId: string, idempotencyKey: string, + action: NotificationAction, requestId: string): Promise { + const scope = await this.portalScope(session, contextRef); + const normalizedAction = action.action === 'REPLY' ? { action: 'REPLY_COMMENT' as const, replyText: action.replyText } : action; + const notificationRef = requiredId(notificationId); + if (!/^[A-Za-z0-9._~-]{16,128}$/.test(idempotencyKey)) throw new NotificationInputError(); + const database = await this.database.getDataSource(); + const keyHash = digest(`${scope.tenantId}\0${idempotencyKey}`); + const requestHash = digest(canonicalJson(normalizedAction)); + + if (normalizedAction.action === 'SEND_MESSAGE') { + await this.authorization.authorize(scope, 'CONNECT', scope.tenantId); + throw new NotificationExternalActionUnavailableError(); + } + if (normalizedAction.action === 'SEND_TEMPLATE' || normalizedAction.action === 'REPLY_COMMENT') { + await this.authorization.authorize(scope, 'CONNECT', scope.tenantId); + if (!this.jobs || !this.externalActionsEnabled) throw new NotificationExternalActionUnavailableError(); + return await this.queueExternalAction({ scope, notificationId: notificationRef, + keyHash, requestHash, action: normalizedAction, requestId }); + } + await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId); + + return database.transaction(async (manager) => { + const notification = await manager.query( + `SELECT id, status FROM ca_meta_notifications WHERE id = ? AND tenant_id = ? FOR UPDATE`, + [notificationRef, scope.tenantId], + ) as Array<{ id: string; status: 'UNREAD' | 'READ' | 'DISMISSED' }>; + const current = notification[0]; + if (!current) throw new NotificationNotFoundError(); + + await manager.query( + `INSERT IGNORE INTO ca_meta_notification_action_receipts + (id, tenant_id, notification_id, action, idempotency_key_hash, request_hash, operation_id, status, safe_error_code, actor_ref_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, 'PENDING', NULL, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, + [randomUUID(), scope.tenantId, notificationRef, normalizedAction.action, keyHash, requestHash, digest(scope.principalId)], + ); + const receiptRows = await manager.query( + `SELECT id, notification_id, action, request_hash, operation_id, status + FROM ca_meta_notification_action_receipts WHERE tenant_id = ? AND idempotency_key_hash = ? FOR UPDATE`, + [scope.tenantId, keyHash], + ) as ActionReceiptRow[]; + const receipt = receiptRows[0]; + if (!receipt || receipt.notification_id !== notificationRef || receipt.action !== normalizedAction.action || hex(receipt.request_hash) !== requestHash.toString('hex')) { + throw new NotificationIdempotencyConflictError(); + } + if (receipt.status === 'SUCCEEDED') { + const latest = await manager.query( + `SELECT status FROM ca_meta_notifications WHERE id = ? AND tenant_id = ?`, [notificationRef, scope.tenantId], + ) as Array<{ status: 'READ' | 'DISMISSED' | 'UNREAD' }>; + return Object.freeze({ notificationId: notificationRef, action: normalizedAction.action, + status: latest[0]?.status === 'DISMISSED' ? 'DISMISSED' : 'READ', requestId }); + } + const target = normalizedAction.action === 'MARK_READ' ? (current.status === 'DISMISSED' ? 'DISMISSED' : 'READ') : 'DISMISSED'; + await manager.query( + `UPDATE ca_meta_notifications SET status = ?, updated_at = UTC_TIMESTAMP(6) WHERE id = ? AND tenant_id = ?`, + [target, notificationRef, scope.tenantId], + ); + await manager.query( + `UPDATE ca_meta_notification_action_receipts SET status = 'SUCCEEDED', updated_at = UTC_TIMESTAMP(6) WHERE id = ? AND tenant_id = ?`, + [receipt.id, scope.tenantId], + ); + return Object.freeze({ notificationId: notificationRef, action: normalizedAction.action, status: target, requestId }); + }); + } + + private async queueExternalAction(input: { + readonly scope: AuthorizedTenantScope; + readonly notificationId: string; + readonly keyHash: Buffer; + readonly requestHash: Buffer; + readonly action: Extract; + readonly requestId: string; + }): Promise { + const database = await this.database.getDataSource(); + return await database.transaction(async (manager) => { + const targets = await manager.query( + `SELECT n.id, n.kind, n.status AS notification_status, a.id AS asset_id, + a.app_configuration_id, a.environment, a.asset_kind + FROM ca_meta_notifications n JOIN ca_meta_assets a + ON a.tenant_id = n.tenant_id AND a.id = n.asset_id + WHERE n.id = ? AND n.tenant_id = ? FOR UPDATE`, + [input.notificationId, input.scope.tenantId], + ) as Array<{ id: string; kind: string; notification_status: string; asset_id: string; app_configuration_id: string; environment: string; asset_kind: string }>; + const target = targets[0]; + if (!target || !UUID.test(target.asset_id) || !UUID.test(target.app_configuration_id) || + !['development', 'test', 'staging', 'production'].includes(target.environment)) { + throw new NotificationNotFoundError('Notification target is unavailable in the authorized tenant context.'); + } + if ((input.action.action === 'SEND_TEMPLATE' && target.asset_kind !== 'WHATSAPP_PHONE_NUMBER') || + (input.action.action === 'REPLY_COMMENT' && (target.kind !== 'COMMENT_RECEIVED' || + !['FACEBOOK_PAGE', 'INSTAGRAM_BUSINESS_ACCOUNT', 'INSTAGRAM_CREATOR_ACCOUNT'].includes(target.asset_kind)))) { + throw new NotificationNotFoundError('Notification action is unavailable for this asset.'); + } + await manager.query( + `INSERT IGNORE INTO ca_meta_notification_action_receipts + (id, tenant_id, notification_id, action, idempotency_key_hash, request_hash, operation_id, status, safe_error_code, actor_ref_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, 'PENDING', NULL, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, + [randomUUID(), input.scope.tenantId, input.notificationId, input.action.action, + input.keyHash, input.requestHash, digest(input.scope.principalId)], + ); + const receipts = await manager.query( + `SELECT id, notification_id, action, request_hash, operation_id, status + FROM ca_meta_notification_action_receipts WHERE tenant_id = ? AND idempotency_key_hash = ? FOR UPDATE`, + [input.scope.tenantId, input.keyHash], + ) as ActionReceiptRow[]; + const receipt = receipts[0]; + if (!receipt || receipt.notification_id !== input.notificationId || receipt.action !== input.action.action || + hex(receipt.request_hash) !== input.requestHash.toString('hex')) throw new NotificationIdempotencyConflictError(); + if (!receipt.operation_id && target.notification_status !== 'UNREAD') { + throw new NotificationNotFoundError('Notification is not unread in the authorized tenant context.'); + } + if (!receipt.operation_id) { + const unresolved = await manager.query( + `SELECT id FROM ca_meta_notification_action_receipts + WHERE tenant_id = ? AND notification_id = ? + AND id <> ? AND action IN ('SEND_TEMPLATE', 'REPLY_COMMENT') AND status IN ('PENDING', 'RUNNING', 'UNKNOWN') + LIMIT 1 FOR UPDATE`, + [input.scope.tenantId, input.notificationId, receipt.id], + ) as Array<{ id: string }>; + if (unresolved.length > 0) throw new NotificationActionUnresolvedError(); + const queued = await this.jobs!.enqueueWithin(manager, { + tenantId: input.scope.tenantId, + type: 'NOTIFICATION_ACTION', + idempotencyKey: input.keyHash.toString('hex'), + correlationId: input.requestId, + resourceScopeRef: input.notificationId, + payload: { + kind: 'NOTIFICATION_ACTION', notificationId: input.notificationId, + assetId: target.asset_id, appConfigurationId: target.app_configuration_id, + environment: target.environment, request: input.action, + }, + }); + await manager.query( + `UPDATE ca_meta_notification_action_receipts SET operation_id = ?, updated_at = UTC_TIMESTAMP(6) + WHERE id = ? AND tenant_id = ?`, [queued.id, receipt.id, input.scope.tenantId], + ); + return Object.freeze({ notificationId: input.notificationId, jobId: queued.id, operationId: queued.id, status: 'PENDING', + location: `/api/v1/jobs/${queued.id}`, requestId: input.requestId }); + } + return Object.freeze({ notificationId: input.notificationId, jobId: receipt.operation_id, operationId: receipt.operation_id, status: 'PENDING', + location: `/api/v1/jobs/${receipt.operation_id}`, requestId: input.requestId }); + }); + } + + async operation(session: VerifiedPortalSession, contextRef: string, operationId: string, + requestId: string): Promise { + const scope = await this.portalScope(session, contextRef); + await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId); + if (!UUID.test(operationId)) return undefined; + const database = await this.database.getDataSource(); + const rows = await database.query( + `SELECT j.id, r.action, j.status, j.updated_at + FROM ca_meta_notification_action_receipts r + JOIN ca_meta_jobs j ON j.tenant_id = r.tenant_id AND j.id = r.operation_id + WHERE r.tenant_id = ? AND r.operation_id = ? LIMIT 1`, + [scope.tenantId, operationId], + ) as Array<{ id: string; action: string; status: string; updated_at: string | Date }>; + const row = rows[0]; + if (!row) return undefined; + const status: components['schemas']['OperationStatus']['status'] = row.status === 'QUEUED' || row.status === 'RETRY_PENDING' ? 'PENDING' + : row.status === 'RUNNING' ? 'RUNNING' : row.status === 'SUCCEEDED' ? 'SUCCEEDED' + : row.status === 'UNKNOWN' ? 'UNKNOWN' : 'FAILED'; + return Object.freeze({ + id: row.id, + kind: row.action, + status, + requestId, + updatedAt: cursorDate(row.updated_at), + nextAction: status === 'PENDING' || status === 'RUNNING' ? 'WAIT' : status === 'UNKNOWN' ? 'CHECK_STATUS' : 'NONE', + }); + } + + /** Trusted server projection entry point. Summaries are selected from a fixed allowlist. */ + async recordProjection(scope: VerifiedServiceTenantScope, input: { + readonly id: string; readonly assetId: string; readonly sourceEventRef: string; + readonly kind: keyof typeof SAFE_KIND_SUMMARY; readonly messageRef?: string; + }): Promise { + if (!isVerifiedServiceTenantScope(scope) || !UUID.test(input.id) || !UUID.test(input.assetId) || !input.sourceEventRef || + !/^[a-f0-9]{64}$/.test(input.sourceEventRef) || !Object.hasOwn(SAFE_KIND_SUMMARY, input.kind) || + (input.messageRef !== undefined && !UUID.test(input.messageRef)) || Object.hasOwn(input, 'providerTargetRef')) { + throw new IdentityDeniedError(); + } + await this.serviceAuthorization.authorize(scope, 'NOTIFICATION_PROJECTION_WRITE', scope.tenantId); + const database = await this.database.getDataSource(); + const eventRows = await database.query( + `SELECT id FROM ca_meta_webhook_events + WHERE tenant_id = ? AND asset_id = ? AND event_ref = ? + AND route_state = 'ROUTED' AND process_state = 'PROCESSED' + LIMIT 2`, + [scope.tenantId, input.assetId, input.sourceEventRef], + ) as Array<{ id: string }>; + if (eventRows.length !== 1) throw new IdentityDeniedError(); + await database.query( + `INSERT IGNORE INTO ca_meta_notifications + (id, tenant_id, asset_id, kind, message_ref, provider_target_ref, source_event_hash, status, safe_summary, created_at, updated_at) + SELECT ?, a.tenant_id, a.id, ?, ?, NULL, ?, 'UNREAD', ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6) + FROM ca_meta_assets a WHERE a.tenant_id = ? AND a.id = ?`, + [input.id, input.kind, input.messageRef ?? null, digest(input.sourceEventRef), + SAFE_KIND_SUMMARY[input.kind], scope.tenantId, input.assetId], + ); + } + + private async portalScope(session: VerifiedPortalSession, contextRef: string) { + if (!isVerifiedPortalSession(session)) throw new IdentityDeniedError(); + return this.contexts.resolveCurrent(session, contextRef); + } +} diff --git a/apps/api/src/worker/main.ts b/apps/api/src/worker/main.ts index b94a737f..12bfd2c4 100644 --- a/apps/api/src/worker/main.ts +++ b/apps/api/src/worker/main.ts @@ -24,6 +24,8 @@ import { createOnboardingSagaHandler } from '../onboarding/onboarding-saga.worke import { MetaOnboardingConfigurationResolver } from '../onboarding/meta-onboarding-configuration.resolver.js'; import { MetaAppConfigRegistry } from '../meta-config/meta-app-config.registry.js'; import { createTemplateCreateJobHandler, type WhatsAppTemplateDispatchAdapter } from '../whatsapp-templates/template-create.worker.js'; +import { createNotificationActionJobHandler, notificationActionFinalizer } from '../notifications/notification-action.worker.js'; +import type { NotificationActionAdapter } from '../notifications/notification-action.worker.js'; interface CredentialLifecycleAdapters { readonly policies: CredentialLifecyclePolicyPort; @@ -51,6 +53,11 @@ function configuredWhatsAppTemplateDispatchAdapter(): WhatsAppTemplateDispatchAd return undefined; } +/** External notification actions have no confirmed policy, credential profile or provider adapter. */ +function configuredNotificationActionAdapter(): NotificationActionAdapter | undefined { + return undefined; +} + interface MetaWebhookWorkerAdapters { readonly payloadProtector: MetaWebhookPayloadProtector; readonly processor: MetaWebhookItemProcessor; @@ -115,6 +122,11 @@ export async function bootstrapWorker(): Promise { } const templateAdapter = configuredWhatsAppTemplateDispatchAdapter(); if (templateAdapter) handlers.set('TEMPLATE_CREATE', createTemplateCreateJobHandler(templateAdapter)); + const notificationActionAdapter = configuredNotificationActionAdapter(); + if (notificationActionAdapter) { + handlers.set('NOTIFICATION_ACTION', createNotificationActionJobHandler(notificationActionAdapter)); + finalizers.set('NOTIFICATION_ACTION', notificationActionFinalizer); + } let authorization: JobExecutionAuthorizationPort = new FailClosedJobExecutionAuthorizationPort(); if (handlers.size > 0) { const configuredIdentity = new WorkerServiceIdentityVerifier(environment.identity?.workerServiceCredentials ?? []) diff --git a/apps/api/test/m5-06-notification-action.spec.ts b/apps/api/test/m5-06-notification-action.spec.ts new file mode 100644 index 00000000..8275b09e --- /dev/null +++ b/apps/api/test/m5-06-notification-action.spec.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import type { EntityManager } from 'typeorm'; +import type { CredentialResolverService } from '../src/credentials/credential-resolver.service.js'; +import { ServiceTenantAuthorizationService } from '../src/identity/identity.service.js'; +import { IdentityDeniedError } from '../src/identity/identity.errors.js'; +import { ServiceCredentialVerifier } from '../src/identity/identity.trust.js'; +import type { IdentityRepository } from '../src/identity/identity.repository.js'; +import type { ServiceScope, ServiceTenantGrant } from '../src/identity/identity.types.js'; +import { JOB_POLICIES } from '../src/jobs/event-validation.js'; +import type { ClaimedJob, JobOutcome, JobStore } from '../src/jobs/job-store.js'; +import { DurableWorker, type JobHandler } from '../src/worker/durable-worker.js'; +import type { JobExecutionAuthorizationPort } from '../src/jobs/job-execution-authorization.js'; +import type { MetaAppConfigRegistry } from '../src/meta-config/meta-app-config.registry.js'; +import type { MetaGraphCallResult } from '../src/meta-graph/meta-graph.types.js'; +import type { DurableWorkDatabase } from '../src/jobs/durable-work-database.js'; +import { NotificationsService } from '../src/notifications/notifications.service.js'; +import { NotificationsController } from '../src/notifications/notifications.controller.js'; +import { + MetaGraphNotificationActionAdapter, + notificationActionFinalizer, + type NotificationActionDirectory, + type NotificationActionProvider, + type NotificationActionTarget, +} from '../src/notifications/notification-action.worker.js'; + +const tenantId = '90000000-0000-4000-8000-000000000061'; +const principalId = '90000000-0000-4000-8000-000000000062'; +const notificationId = '90000000-0000-4000-8000-000000000063'; +const assetId = '90000000-0000-4000-8000-000000000064'; +const appConfigurationId = '90000000-0000-4000-8000-000000000065'; +const targetId = 'provider-target-private-synthetic-001'; +const providerAssetId = 'provider-asset-private-synthetic-001'; + +test('notification controller preserves legacy REPLY parsing and accepted job response', async () => { + const calls: unknown[][] = []; + const jobId = randomUUID(); + const accepted = { notificationId, jobId, operationId: jobId, status: 'PENDING' as const, + location: `/api/v1/jobs/${jobId}`, requestId: 'req-m5-06-controller' }; + const service = { async act(...args: unknown[]) { calls.push(args); return accepted; } } as unknown as NotificationsService; + const controller = new NotificationsController(service); + const statuses: number[] = []; + const headers: Array<[string, string]> = []; + const response = { + status(value: number) { statuses.push(value); return this; }, + setHeader(name: string, value: string) { headers.push([name, value]); return this; }, + } as never; + const request = { portalSession: {}, requestId: 'req-m5-06-controller', + body: { action: 'REPLY', replyText: 'Synthetic legacy reply.' } } as never; + const result = await controller.act(notificationId, 'm5-06-controller-tenant-context', 'm5-06-idempotency-key-001', + request, response); + + assert.deepEqual(result, accepted); + assert.deepEqual(statuses, [202]); + assert.deepEqual(headers, [['Location', accepted.location]]); + const action = calls[0]?.[4] as { action?: string; replyText?: string } | undefined; + assert.deepEqual(action, { action: 'REPLY_COMMENT', replyText: 'Synthetic legacy reply.' }); +}); + +class SyntheticCredentialVerifier extends ServiceCredentialVerifier { + mint() { return this.issueVerifiedServiceCredential(principalId, tenantId); } + async verifyCredential() { return undefined; } +} + +async function serviceScope() { + const grant: ServiceTenantGrant = { + tenantId, principalId, principalKind: 'SERVICE', principalStatus: 'ACTIVE', tenantStatus: 'ACTIVE', scopes: ['CREDENTIAL_USE'], + }; + const repository = { + async findServiceTenantGrant(principal: string, tenant: string) { + return principal === principalId && tenant === tenantId ? grant : undefined; + }, + } as unknown as IdentityRepository; + return await new ServiceTenantAuthorizationService(repository).resolveScope(new SyntheticCredentialVerifier().mint()); +} + +function target(overrides: Partial = {}): NotificationActionTarget { + return { + tenantId, notificationId, assetId, appConfigurationId, environment: 'test', + assetKind: 'WHATSAPP_PHONE_NUMBER', providerAssetId, providerTargetRef: targetId, + flowVariant: 'WHATSAPP_EXCLUSIVE', authVariant: 'WHATSAPP_EMBEDDED_SIGNUP', ...overrides, + }; +} + +function successResult(): MetaGraphCallResult { + return { outcome: 'SUCCEEDED', data: { messages: [{ id: 'synthetic-id' }] }, attempts: 1, retries: 0, retryable: false, ambiguous: false }; +} + +function failedHttp200(): MetaGraphCallResult { + return { outcome: 'FAILED', error: { category: 'PROVIDER_ERROR', httpStatus: 200, retryable: false, ambiguous: false }, + httpStatus: 200, attempts: 1, retries: 0, retryable: false, ambiguous: false }; +} + +test('notification action resolves capability through the internal asset and tenant service scope', async () => { + const scope = await serviceScope(); + const targetCalls: unknown[] = []; + const credentialCalls: unknown[] = []; + const providerCalls: unknown[] = []; + const directory: NotificationActionDirectory = { async resolve(input) { + targetCalls.push(input); + return target(); + } }; + const credentials = { async resolve(context: unknown, actor: unknown) { + credentialCalls.push({ context, actor }); + return { handle: {}, status: 'READY', capability: 'SEND_MESSAGE', family: 'SYNTHETIC', purpose: 'CUSTOMER_OPERATION' }; + } } as unknown as Pick; + const configurations = { resolve: () => ({ configurationRef: 'synthetic-profile', versions: { graphApi: { status: 'VERIFIED' } } }) } as unknown as MetaAppConfigRegistry; + const provider: NotificationActionProvider = { async execute(input) { providerCalls.push(input); return successResult(); } }; + const adapter = new MetaGraphNotificationActionAdapter(directory, configurations, credentials, provider); + const action = { action: 'SEND_TEMPLATE', templateName: 'local-approved-template', language: 'en', parameters: ['synthetic'] } as const; + const result = await adapter.execute({ tenantId, notificationId, assetId, appConfigurationId, environment: 'test', + request: action, serviceScope: scope, correlationId: 'req-m5-06-credential-test' }, new AbortController().signal); + assert.deepEqual(result, { kind: 'SUCCEEDED' }); + assert.equal(targetCalls.length, 1); + assert.equal(credentialCalls.length, 1); + const call = credentialCalls[0] as { context: { tenantId: string; assetId: string; operation: string } }; + assert.deepEqual(call.context, { tenantId, provider: 'META', appConfigurationId, environment: 'test', assetId, operation: 'SEND_MESSAGE' }); + assert.equal(providerCalls.length, 1); + const providerInput = providerCalls[0] as { context: { assetId: string }; targetRef: string }; + assert.equal(providerInput.context.assetId, assetId); + assert.equal(providerInput.targetRef, targetId); +}); + +test('notification projections require a write-only grant, a processed event bound to the asset, and no forged target', async () => { + const queries: Array<{ sql: string; parameters?: readonly unknown[] }> = []; + const database = { async getDataSource() { return { async query(sql: string, parameters?: readonly unknown[]) { + queries.push({ sql, parameters }); + return []; + } }; } } as unknown as DurableWorkDatabase; + async function projectionHarness(scopes: readonly ServiceScope[]) { + const grant: ServiceTenantGrant = { tenantId, principalId, principalKind: 'SERVICE', principalStatus: 'ACTIVE', + tenantStatus: 'ACTIVE', scopes }; + const repository = { async findServiceTenantGrant(principal: string, tenant: string) { + return principal === principalId && tenant === tenantId ? grant : undefined; + } } as unknown as IdentityRepository; + const serviceAuthorization = new ServiceTenantAuthorizationService(repository); + const scope = await serviceAuthorization.resolveScope(new SyntheticCredentialVerifier().mint()); + const service = new NotificationsService(database, {} as never, {} as never, serviceAuthorization); + return { service, scope }; + } + const input = { id: randomUUID(), assetId, sourceEventRef: 'a'.repeat(64), kind: 'MESSAGE_RECEIVED' as const }; + + const readOnly = await projectionHarness(['TENANT_RESOURCE_READ']); + await assert.rejects(readOnly.service.recordProjection(readOnly.scope, input), IdentityDeniedError); + assert.equal(queries.length, 0, 'read-only producer grants cannot reach the database writer'); + + const writer = await projectionHarness(['NOTIFICATION_PROJECTION_WRITE']); + const forgedProjection = { ...input, providerTargetRef: targetId } as unknown as Parameters[1]; + await assert.rejects(writer.service.recordProjection(writer.scope, forgedProjection), IdentityDeniedError); + assert.equal(queries.length, 0, 'an arbitrary provider target is rejected before it can be stored'); + + await assert.rejects(writer.service.recordProjection(writer.scope, input), IdentityDeniedError); + assert.equal(queries.length, 1, 'the source event must already be processed for the exact tenant and asset'); + assert.match(queries[0]?.sql ?? '', /tenant_id = \? AND asset_id = \? AND event_ref = \?/); + assert.deepEqual(queries[0]?.parameters, [tenantId, assetId, input.sourceEventRef]); +}); + +test('HTTP 200 error object and null provider results never become success; only exact success may mark READ', async () => { + const scope = await serviceScope(); + let result: MetaGraphCallResult | null | undefined = failedHttp200(); + const adapter = new MetaGraphNotificationActionAdapter( + { async resolve() { return target({ assetKind: 'FACEBOOK_PAGE', flowVariant: 'FACEBOOK', authVariant: 'FACEBOOK_LOGIN_FOR_BUSINESS' }); } }, + { resolve: () => ({ configurationRef: 'synthetic-profile', versions: { graphApi: { status: 'VERIFIED' } } }) } as unknown as MetaAppConfigRegistry, + { async resolve(context: { operation: string }) { + return { handle: {}, status: 'READY', capability: context.operation, family: 'SYNTHETIC', purpose: 'CUSTOMER_OPERATION' }; + } } as unknown as Pick, + { async execute() { return result; } }, + ); + const input = { tenantId, notificationId, assetId, appConfigurationId, environment: 'test' as const, + request: { action: 'REPLY_COMMENT', replyText: 'Synthetic response never sent.' } as const, + serviceScope: scope, correlationId: 'req-m5-06-comment-test' }; + assert.deepEqual(await adapter.execute(input, new AbortController().signal), { kind: 'FAILED_NO_EFFECT' }); + + result = null; + assert.deepEqual(await adapter.execute(input, new AbortController().signal), { kind: 'UNKNOWN' }); + result = undefined; + assert.deepEqual(await adapter.execute(input, new AbortController().signal), { kind: 'UNKNOWN' }); + result = { outcome: 'ACCEPTED' } as unknown as MetaGraphCallResult; + assert.deepEqual(await adapter.execute(input, new AbortController().signal), { kind: 'UNKNOWN' }, + 'only an explicit SUCCEEDED provider outcome is treated as success'); + + const queries: string[] = []; + const manager = { async query(sql: string) { queries.push(sql); return []; } } as unknown as EntityManager; + const job = { id: randomUUID(), tenantId, type: 'NOTIFICATION_ACTION', + payload: { kind: 'NOTIFICATION_ACTION', notificationId, assetId, appConfigurationId, environment: 'test', request: input.request } } as ClaimedJob; + await notificationActionFinalizer(manager, job, { status: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN', safeErrorAction: 'CHECK_STATUS' }); + assert.equal(queries.some((sql) => sql.includes('UPDATE ca_meta_notifications')), false, + 'UNKNOWN leaves notification unread'); + queries.length = 0; + await notificationActionFinalizer(manager, job, { status: 'SUCCEEDED', safeErrorCode: null, safeErrorAction: null }); + assert.equal(queries.some((sql) => sql.includes("SET status = 'READ'")), true, + 'only persisted provider success can mark the notification read'); +}); + +test('ambiguous notification worker timeout becomes UNKNOWN once and is not retried', async () => { + assert.equal(JOB_POLICIES.NOTIFICATION_ACTION.maxAttempts, 1); + assert.equal(JOB_POLICIES.NOTIFICATION_ACTION.effectMode, 'AMBIGUOUS'); + const job = { + id: randomUUID(), tenantId, resourceScopeRef: notificationId, type: 'NOTIFICATION_ACTION', lane: 'ONBOARDING', + effectMode: 'AMBIGUOUS', payload: { kind: 'NOTIFICATION_ACTION', notificationId, assetId, appConfigurationId, + environment: 'test', request: { action: 'REPLY_COMMENT', replyText: 'Synthetic reply' } }, + idempotencyKey: 'm5-06-worker-key-0001', correlationId: 'req-m5-06-timeout', causationId: null, attempt: 1, maxAttempts: 1, + claimToken: randomUUID(), workerRef: 'synthetic-worker', budgetStartedAt: new Date().toISOString(), createdAt: new Date().toISOString(), + } as ClaimedJob; + let claimed = false; + const outcomes: JobOutcome[] = []; + const stop = new AbortController(); + const store: JobStore = { + async enqueue() { throw new Error('not used'); }, + async enqueueWithin() { throw new Error('not used'); }, + async claimNext() { if (claimed) return null; claimed = true; return job; }, + async renew() { return true; }, + async finish(_job, outcome) { outcomes.push(outcome); stop.abort(); return true; }, + }; + const handler: JobHandler = async (_job, signal) => await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ kind: 'SUCCEEDED' }), { once: true }); + }); + const authorization: JobExecutionAuthorizationPort = { async authorize() { + return { jobId: job.id, tenantId } as never; + } }; + const worker = new DurableWorker(store, { workerRef: 'm5-06-timeout-worker', leaseMs: 100, + heartbeatMs: 20, idlePollMs: 1, handlerTimeoutOverrides: { NOTIFICATION_ACTION: 5 }, handlerCancellationGraceMs: 10 }, + new Map([['NOTIFICATION_ACTION', handler]]), authorization); + await worker.run(stop.signal); + assert.equal(outcomes.length, 1); + assert.deepEqual(outcomes[0], { kind: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN' }); +}); diff --git a/apps/api/test/m5-06-notifications.integration.spec.ts b/apps/api/test/m5-06-notifications.integration.spec.ts new file mode 100644 index 00000000..52618bb7 --- /dev/null +++ b/apps/api/test/m5-06-notifications.integration.spec.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import type { ApiEnvironment } from '../src/config/environment.js'; +import { DurableWorkDatabase } from '../src/jobs/durable-work-database.js'; +import { DurableJobStore } from '../src/jobs/job-store.js'; +import type { IdentityRepository } from '../src/identity/identity.repository.js'; +import type { TenantMembership } from '../src/identity/identity.types.js'; +import { hashTenantContextReference, ServiceTenantAuthorizationService, TenantAuthorizationService, TenantContextService } from '../src/identity/identity.service.js'; +import { PortalSessionVerifier, type VerifiedPortalSession } from '../src/identity/identity.trust.js'; +import { NotificationsService, NotificationActionUnresolvedError, NotificationExternalActionUnavailableError, NotificationNotFoundError } from '../src/notifications/notifications.service.js'; +import { notificationActionFinalizer } from '../src/notifications/notification-action.worker.js'; +import type { ClaimedJob } from '../src/jobs/job-store.js'; + +const enabled = process.env.M5_06_NOTIFICATION_TEST === '1'; +const principalId = '90000000-0000-4000-8000-000000000061'; +const contextA = 'm5-06-synthetic-current-context-aaaaaaaa'; +const contextB = 'm5-06-synthetic-current-context-bbbbbbbb'; + +class SyntheticIdentityRepository implements IdentityRepository { + constructor(private readonly memberships: ReadonlyMap) {} + async findPrincipalByIssuerSubject() { return undefined; } + async findMembership(requestPrincipalId: string, tenantId: string) { + return requestPrincipalId === principalId ? this.memberships.get(tenantId) : undefined; + } + async listActiveMemberships() { return []; } + async findServiceTenantGrant() { return undefined; } + async findLegacyTenantMapping() { return undefined; } +} + +async function verifiedSession(tenantId: string, contextRef: string): Promise { + const cookie = `m5-06-test-session-${tenantId}-${randomUUID()}-with-more-than-32-bytes`; + const expected = createHash('sha256').update(cookie, 'utf8').digest(); + const result = await new PortalSessionVerifier({ + async findActiveBySecretHash(hash) { + return Buffer.from(hash).equals(expected) ? { + sessionId: randomUUID(), principalId, tenantId, contextVersion: 1, + contextRefHash: hashTenantContextReference(contextRef), csrfTokenHash: 'a'.repeat(64), entryMode: 'PORTAL_TOP_LEVEL' as const, + } : undefined; + }, + }).verify(cookie); + if (!result) throw new Error('Synthetic verified portal session could not be created.'); + return result; +} + +test('notifications stay isolated by company tenant, paginate, and local actions are transactionally idempotent', { skip: !enabled }, async () => { + const required = ['API_DATABASE_HOST', 'API_DATABASE_PORT', 'API_DATABASE_NAME', 'API_DATABASE_USER', 'API_DATABASE_PASSWORD'] as const; + assert.deepEqual(required.filter((key) => !process.env[key]), [], 'isolated API database environment is required'); + const environment = { database: { + host: process.env.API_DATABASE_HOST!, port: Number(process.env.API_DATABASE_PORT), name: process.env.API_DATABASE_NAME!, + user: process.env.API_DATABASE_USER!, password: process.env.API_DATABASE_PASSWORD!, connectTimeoutMs: 5000, + } } as ApiEnvironment; + const database = new DurableWorkDatabase(environment); + const source = await database.getDataSource(); + const tenantA = randomUUID(); + const tenantB = randomUUID(); + const appA = randomUUID(); + const appB = randomUUID(); + const assetA = randomUUID(); + const assetB = randomUUID(); + const notificationA = randomUUID(); + const notificationA2 = randomUUID(); + const notificationB = randomUUID(); + const memberships = new Map([tenantA, tenantB].map((tenantId) => [tenantId, { + tenantId, principalId, principalKind: 'USER', principalStatus: 'ACTIVE', tenantStatus: 'ACTIVE', roles: ['owner'], status: 'ACTIVE', version: 1, + }])); + const identities = new SyntheticIdentityRepository(memberships); + const contexts = new TenantContextService(identities); + const authorization = new TenantAuthorizationService(identities); + const jobs = new DurableJobStore(database); + const service = new NotificationsService(database, contexts, authorization, new ServiceTenantAuthorizationService(identities)); + const queueService = new NotificationsService(database, contexts, authorization, new ServiceTenantAuthorizationService(identities), jobs, true); + const sessionA = await verifiedSession(tenantA, contextA); + const sessionB = await verifiedSession(tenantB, contextB); + const actionKey = `m5-06-idempotency-${randomUUID()}`; + + try { + for (const [tenantId, appId, assetId, legacyCompanyId, phoneNumberRef] of [ + [tenantA, appA, assetA, `company-a-${randomUUID()}`, '15550000001'], + [tenantB, appB, assetB, `company-b-${randomUUID()}`, '15550000002'], + ] as const) { + await source.query(`INSERT INTO ca_meta_tenants (id,status,version,created_at,updated_at) VALUES (?,'ACTIVE',1,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, [tenantId]); + await source.query(`INSERT INTO ca_meta_tenant_legacy_maps (id,tenant_id,legacy_database_id,legacy_company_id,environment,created_at) + VALUES (?,?,?,?, 'test',UTC_TIMESTAMP(6))`, [randomUUID(), tenantId, 'same-id-database-m5-06', legacyCompanyId]); + await source.query(`INSERT INTO ca_meta_app_configurations + (id,tenant_id,provider,app_external_id,environment,credential_ref,version,created_at,updated_at) + VALUES (?,?,'META',?,'test',NULL,1,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))`, [appId, tenantId, `synthetic-app-${tenantId}`]); + await source.query(`INSERT INTO ca_meta_assets + (id,tenant_id,app_configuration_id,provider,environment,asset_kind,external_id,display_name,external_name,version,created_at,updated_at) + VALUES (?, ?, ?, 'META', 'test', 'WHATSAPP_PHONE_NUMBER', ?, 'Synthetic phone', 'Synthetic phone', 1, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, + [assetId, tenantId, appId, phoneNumberRef]); + } + for (const [tenantId, assetId, id, secondsAgo] of [ + [tenantA, assetA, notificationA, 2], [tenantA, assetA, notificationA2, 1], [tenantB, assetB, notificationB, 0], + ] as const) { + await source.query(`INSERT INTO ca_meta_notifications + (id,tenant_id,asset_id,kind,message_ref,provider_target_ref,source_event_hash,status,safe_summary,created_at,updated_at) + VALUES (?,?,?,'MESSAGE_RECEIVED',NULL,?,?, 'UNREAD','Synthetic summary',DATE_SUB(UTC_TIMESTAMP(6), INTERVAL ? SECOND),UTC_TIMESTAMP(6))`, + [id, tenantId, assetId, `private-target-${randomUUID()}`, createHash('sha256').update(randomUUID()).digest(), secondsAgo]); + } + await source.query(`UPDATE ca_meta_notifications SET created_at = ? WHERE id = ? AND tenant_id = ?`, + ['2026-11-12 12:34:56.123456', notificationA, tenantA]); + await source.query(`UPDATE ca_meta_notifications SET created_at = ? WHERE id = ? AND tenant_id = ?`, + ['2026-11-12 12:34:56.123457', notificationA2, tenantA]); + + const firstPage = await service.list(sessionA, contextA, 1, undefined, 'req-m5-06-list-a'); + assert.equal(firstPage.items.length, 1); + assert.equal(firstPage.items[0]?.id, notificationA2, 'DATETIME(6) order keeps the newer microsecond first'); + assert.equal(firstPage.items[0]?.createdAt, '2026-11-12T12:34:56.123457Z', 'serialized timestamp keeps all six fractional digits'); + assert.notEqual(firstPage.items[0]?.id, notificationB); + assert.equal(firstPage.items[0]?.safeSummary, 'A new WhatsApp message is available.'); + assert.equal(firstPage.items[0]?.actions.includes('SEND_MESSAGE'), false); + assert.ok(firstPage.nextCursor); + const firstNumberFiltered = await service.list(sessionA, contextA, 1, undefined, 'req-m5-06-list-number', { + channel: 'WHATSAPP', number: '15550000001', + }); + assert.deepEqual(firstNumberFiltered.items.map((item) => item.id), [notificationA2]); + assert.ok(firstNumberFiltered.nextCursor); + const secondNumberFiltered = await service.list(sessionA, contextA, 1, firstNumberFiltered.nextCursor, + 'req-m5-06-list-number-next', { channel: 'WHATSAPP', number: '15550000001' }); + assert.deepEqual(secondNumberFiltered.items.map((item) => item.id), [notificationA]); + const channelFiltered = await service.list(sessionA, contextA, 100, undefined, 'req-m5-06-list-channel', { channel: 'FACEBOOK' }); + assert.deepEqual(channelFiltered.items, [], 'channel filter keeps unrelated assets out of the result'); + const secondPage = await service.list(sessionA, contextA, 1, firstPage.nextCursor, 'req-m5-06-list-next'); + assert.equal(secondPage.items.length, 1); + assert.equal(secondPage.items[0]?.id, notificationA, 'next cursor resumes after the exact microsecond, without repeating the first row'); + assert.equal(secondPage.items[0]?.createdAt, '2026-11-12T12:34:56.123456Z'); + const pageB = await service.list(sessionB, contextB, 100, undefined, 'req-m5-06-list-b'); + assert.deepEqual(pageB.items.map((item) => item.id), [notificationB]); + await assert.rejects(service.act(sessionB, contextB, notificationA, `m5-06-forged-${randomUUID()}`, + { action: 'MARK_READ' }, 'req-m5-06-cross-company'), NotificationNotFoundError); + + const initial = await service.act(sessionA, contextA, notificationA, actionKey, { action: 'MARK_READ' }, 'req-m5-06-read'); + assert.equal(initial.status, 'READ'); + const duplicate = await service.act(sessionA, contextA, notificationA, actionKey, { action: 'MARK_READ' }, 'req-m5-06-read-replay'); + assert.equal(duplicate.status, 'READ'); + const concurrentKey = `m5-06-idempotency-${randomUUID()}`; + const concurrent = await Promise.all([ + service.act(sessionA, contextA, notificationA2, concurrentKey, { action: 'DISMISS' }, 'req-m5-06-dismiss-1'), + service.act(sessionA, contextA, notificationA2, concurrentKey, { action: 'DISMISS' }, 'req-m5-06-dismiss-2'), + ]); + assert.deepEqual(concurrent.map((item) => item.status), ['DISMISSED', 'DISMISSED']); + const receiptRows = await source.query(`SELECT COUNT(*) AS count FROM ca_meta_notification_action_receipts WHERE tenant_id = ?`, [tenantA]) as Array<{ count: number | string }>; + assert.equal(Number(receiptRows[0]?.count), 2, 'same-key concurrent requests create only one receipt'); + + await assert.rejects(service.act(sessionA, contextA, notificationB, `m5-06-send-${randomUUID()}`, + { action: 'SEND_TEMPLATE', templateName: 'synthetic template', language: 'en', parameters: [] }, 'req-m5-06-send'), + NotificationExternalActionUnavailableError); + await assert.rejects(service.act(sessionB, contextB, notificationB, `m5-06-reply-${randomUUID()}`, + { action: 'REPLY_COMMENT', replyText: 'Synthetic reply, never delivered.' }, 'req-m5-06-reply'), + NotificationExternalActionUnavailableError); + await assert.rejects(service.act(sessionB, contextB, notificationB, `m5-06-message-${randomUUID()}`, + { action: 'SEND_MESSAGE', messageText: 'Synthetic message, never delivered.' }, 'req-m5-06-message'), + NotificationExternalActionUnavailableError); + const stillUnread = await source.query(`SELECT status FROM ca_meta_notifications WHERE id = ? AND tenant_id = ?`, [notificationB, tenantB]) as Array<{ status: string }>; + assert.equal(stillUnread[0]?.status, 'UNREAD', 'unavailable send cannot mark notification read'); + + const externalKey = `m5-06-external-${randomUUID()}`; + const externalAction = { action: 'SEND_TEMPLATE', templateName: 'synthetic_template', language: 'en', parameters: ['never delivered'] } as const; + const acceptedAttempts = await Promise.allSettled([ + queueService.act(sessionB, contextB, notificationB, externalKey, externalAction, 'req-m5-06-external-1'), + queueService.act(sessionB, contextB, notificationB, externalKey, externalAction, 'req-m5-06-external-2'), + ] as const); + const [firstAttempt, secondAttempt] = acceptedAttempts; + if (firstAttempt.status === 'rejected') throw new Error(`Concurrent external action failed: ${firstAttempt.reason.name}: ${firstAttempt.reason.message}`); + if (secondAttempt.status === 'rejected') throw new Error(`Concurrent external action failed: ${secondAttempt.reason.name}: ${secondAttempt.reason.message}`); + const firstAccepted = firstAttempt.value; + const secondAccepted = secondAttempt.value; + assert.ok('jobId' in firstAccepted && 'jobId' in secondAccepted); + if (!('jobId' in firstAccepted) || !('jobId' in secondAccepted) || + typeof firstAccepted.operationId !== 'string' || typeof secondAccepted.operationId !== 'string') { + throw new Error('External action was not accepted.'); + } + assert.equal(firstAccepted.operationId, secondAccepted.operationId, 'concurrent idempotent external requests share one operation'); + assert.equal(firstAccepted.jobId, firstAccepted.operationId, 'operation id remains available through the existing job contract'); + assert.equal(firstAccepted.location, `/api/v1/jobs/${firstAccepted.jobId}`); + const operationId = firstAccepted.operationId; + const jobRows = await source.query(`SELECT id FROM ca_meta_jobs WHERE tenant_id = ? AND job_type = 'NOTIFICATION_ACTION'`, [tenantB]) as Array<{ id: string }>; + assert.deepEqual(jobRows.map((row) => row.id), [operationId], 'one durable notification action job is created'); + + await source.query(`UPDATE ca_meta_jobs SET status = 'UNKNOWN', attempt = 1, + last_safe_error_code = 'PROVIDER_RESULT_UNKNOWN', last_safe_error_action = 'CHECK_STATUS', + completed_at = UTC_TIMESTAMP(6), updated_at = UTC_TIMESTAMP(6) WHERE tenant_id = ? AND id = ?`, [tenantB, operationId]); + const actionJob = { id: operationId, tenantId: tenantB, type: 'NOTIFICATION_ACTION', + payload: { kind: 'NOTIFICATION_ACTION', notificationId: notificationB, assetId: assetB, + appConfigurationId: appB, environment: 'test', request: externalAction } } as unknown as ClaimedJob; + await notificationActionFinalizer(source.manager, actionJob, + { status: 'UNKNOWN', safeErrorCode: 'PROVIDER_RESULT_UNKNOWN', safeErrorAction: 'CHECK_STATUS' }); + const operation = await service.operation(sessionB, contextB, operationId, 'req-m5-06-operation'); + assert.equal(operation?.status, 'UNKNOWN'); + assert.equal(operation?.nextAction, 'CHECK_STATUS'); + assert.equal('payload' in (operation ?? {}), false, 'operation status never returns the action body'); + assert.equal(await service.operation(sessionA, contextA, operationId, 'req-m5-06-operation-foreign'), undefined, + 'an operation identifier cannot cross tenant scope'); + const replay = await queueService.act(sessionB, contextB, notificationB, externalKey, externalAction, 'req-m5-06-external-replay'); + assert.ok('jobId' in replay && 'operationId' in replay); + if ('jobId' in replay) assert.equal(replay.status, 'PENDING', 'accepted replay preserves the v1 response; the job resource carries current status'); + await assert.rejects(queueService.act(sessionB, contextB, notificationB, `m5-06-new-key-${randomUUID()}`, + externalAction, 'req-m5-06-external-no-blind-retry'), NotificationActionUnresolvedError); + const afterUnknown = await source.query(`SELECT status FROM ca_meta_notifications WHERE id = ? AND tenant_id = ?`, [notificationB, tenantB]) as Array<{ status: string }>; + assert.equal(afterUnknown[0]?.status, 'UNREAD', 'ambiguous action never marks notification read'); + } finally { + for (const tenantId of [tenantA, tenantB]) { + await source.query('DELETE FROM ca_meta_notification_action_receipts WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_jobs WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_notifications WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_assets WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_app_configurations WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_tenant_legacy_maps WHERE tenant_id = ?', [tenantId]); + await source.query('DELETE FROM ca_meta_tenants WHERE id = ?', [tenantId]); + } + await database.close(); + } +}); diff --git a/apps/api/test/migration-unit.spec.ts b/apps/api/test/migration-unit.spec.ts index fe2461ce..2bbbf3ef 100644 --- a/apps/api/test/migration-unit.spec.ts +++ b/apps/api/test/migration-unit.spec.ts @@ -22,6 +22,7 @@ import { CONNECTION_LIFECYCLE_MIGRATION } from '../src/database/migrations/conne import { INTERNAL_ASSET_OPERATIONS_MIGRATION } from '../src/database/migrations/internal-asset-operations.js'; import { ONBOARDING_COEXISTENCE_LOCK_MIGRATION, ONBOARDING_COEXISTENCE_LOCK_TABLES } from '../src/database/migrations/onboarding-coexistence-lock.js'; import { AUTOMATION_CONFIG_MIGRATION, AUTOMATION_CONFIG_TABLES } from '../src/database/migrations/automation-config.js'; +import { NOTIFICATIONS_MIGRATION } from '../src/database/migrations/notifications.js'; import { assertMigrationGrantRecords, CoexistenceLockDeleteGrantRequiredError, safeMigrationError } from '../src/database/migrations/migration-runner.js'; const validEnvironment: NodeJS.ProcessEnv = { @@ -105,9 +106,9 @@ test('migration grants require the lock-trigger definer privilege only on its ex test('versioned migrations carry deterministic checksums and explicit lifecycle phases', () => { const schemaVersions = MIGRATIONS.map((migration) => migration.schemaVersion); - assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]); - assert.equal(schemaVersions.at(-1), 23); - assert.equal(MIGRATIONS.length, 23); + assert.deepEqual(schemaVersions, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]); + assert.equal(schemaVersions.at(-1), 24); + assert.equal(MIGRATIONS.length, 24); for (const migration of MIGRATIONS) assert.match(migration.checksum, /^[a-f0-9]{64}$/); assert.deepEqual(MIGRATIONS[0]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'expand']); assert.deepEqual(MIGRATIONS[1]?.steps.map((step) => step.phase), ['expand', 'expand', 'expand', 'backfill', 'validate']); @@ -155,6 +156,9 @@ test('versioned migrations carry deterministic checksums and explicit lifecycle assert.equal(ONBOARDING_COEXISTENCE_LOCK_MIGRATION.schemaVersion, 22); assert.equal(AUTOMATION_CONFIG_MIGRATION.id, '202611120023-asset-automation-config'); assert.equal(AUTOMATION_CONFIG_MIGRATION.schemaVersion, 23); + assert.equal(NOTIFICATIONS_MIGRATION.id, '202611120024-tenant-notifications'); + assert.equal(NOTIFICATIONS_MIGRATION.schemaVersion, 24); + assert.equal(MIGRATIONS.find((migration) => migration.schemaVersion === NOTIFICATIONS_MIGRATION.schemaVersion)?.id, NOTIFICATIONS_MIGRATION.id); const coexistenceLockTable = ONBOARDING_COEXISTENCE_LOCK_MIGRATION.steps.find((step) => step.id === 'create-onboarding-coexistence-locks'); assert.ok(coexistenceLockTable && 'sql' in coexistenceLockTable); assert.match(coexistenceLockTable.sql, /PRIMARY KEY \(tenant_id\)/); diff --git a/apps/api/tsconfig.m5-06.json b/apps/api/tsconfig.m5-06.json new file mode 100644 index 00000000..ccecc938 --- /dev/null +++ b/apps/api/tsconfig.m5-06.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.test.json", + "include": ["src/**/*.ts", "test/m5-06-notifications.integration.spec.ts", "test/m5-06-notification-action.spec.ts"], + "exclude": ["node_modules", "dist", "dist-test"] +} diff --git a/apps/web/package.json b/apps/web/package.json index bc6fcce6..85884b15 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "test:m5-03": "node ../../tools/m5-03/run-focused-web-tests.mjs", "test:m5-04": "node ../../tools/m5-04/run-focused-web-tests.mjs", "test:m5-05": "node ../../tools/m5-05/run-focused-web-tests.mjs", + "test:m5-06": "node ../../tools/m5-06/run-focused-web-tests.mjs", "typecheck": "ngc -p tsconfig.app.json --noEmit", "lint": "node ../../tools/m2/check-web-boundary.mjs" }, diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index ace028ad..1172fac1 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -22,8 +22,7 @@ export const routes: Routes = [ }, { path: 'notifications', - loadComponent: () => import('./features/portal/portal-section.component').then((m) => m.PortalSectionComponent), - data: { title: 'Notificações', description: 'As notificações serão exibidas após a confirmação do contexto e da empresa pelo servidor.' }, + loadComponent: () => import('./features/portal/notifications.component').then((m) => m.NotificationsComponent), }, { path: 'setup/coexistence', loadComponent: () => import('./features/coexistence/coexistence-setup.component').then((m) => m.CoexistenceSetupComponent) }, // Legacy entry points never carry authentication authority; the session guard still applies. diff --git a/apps/web/src/app/core/portal/notifications.service.ts b/apps/web/src/app/core/portal/notifications.service.ts new file mode 100644 index 00000000..3ee6981a --- /dev/null +++ b/apps/web/src/app/core/portal/notifications.service.ts @@ -0,0 +1,39 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import type { components } from '@ca-meta/contracts'; +import { META_API_BASE_URL } from '../config/public-runtime-config'; +import { normalizeApiV1BaseUrl } from './portal-session.service'; + +export type PortalNotification = components['schemas']['Notification']; +export type NotificationPage = components['schemas']['NotificationListPage']; +export type NotificationAction = components['schemas']['NotificationActionRequest']; +export type NotificationActionResult = components['schemas']['NotificationActionResult']; +export type NotificationOperation = components['schemas']['OperationStatus']; + +@Injectable({ providedIn: 'root' }) +export class NotificationsService { + private readonly baseUrl: string; + + constructor(private readonly http: HttpClient, @Inject(META_API_BASE_URL) baseUrl: string) { + this.baseUrl = normalizeApiV1BaseUrl(baseUrl); + } + + list(cursor?: string): Promise { + let params = new HttpParams().set('limit', '50'); + if (cursor) params = params.set('cursor', cursor); + return firstValueFrom(this.http.get(`${this.baseUrl}/notifications`, { params })); + } + + act(notificationId: string, action: NotificationAction, idempotencyKey: string): Promise { + if (!/^[A-Za-z0-9._~-]{16,128}$/.test(idempotencyKey)) throw new Error('A valid idempotency key is required.'); + return firstValueFrom(this.http.post( + `${this.baseUrl}/notifications/${encodeURIComponent(notificationId)}/actions`, action, + { headers: new HttpHeaders({ 'Idempotency-Key': idempotencyKey }) }, + )); + } + + operation(operationId: string): Promise { + return firstValueFrom(this.http.get(`${this.baseUrl}/operations/${encodeURIComponent(operationId)}`)); + } +} diff --git a/apps/web/src/app/features/portal/notifications.component.html b/apps/web/src/app/features/portal/notifications.component.html new file mode 100644 index 00000000..2700fe00 --- /dev/null +++ b/apps/web/src/app/features/portal/notifications.component.html @@ -0,0 +1,33 @@ +
+
+
+

Central da empresa

+

Notificações

+

Acompanhe avisos disponíveis para o contexto atual da empresa.

+
+ +
+ +

{{ message }}

+ +

Referência: {{ requestId }}

+
Carregando notificações…
+
Nenhuma notificação disponível.
+ +
    +
  • +
    + {{ item.status === 'UNREAD' ? 'Não lida' : 'Lida' }} +

    {{ item.safeSummary }}

    + +
    +
    + + +
    +
  • +
+ +
diff --git a/apps/web/src/app/features/portal/notifications.component.scss b/apps/web/src/app/features/portal/notifications.component.scss new file mode 100644 index 00000000..d417d67f --- /dev/null +++ b/apps/web/src/app/features/portal/notifications.component.scss @@ -0,0 +1,21 @@ +.notifications-page { max-width: 960px; margin: 0 auto; padding: 2rem; color: var(--text-primary, #172033); } +.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem; margin-bottom: 1.5rem; } +.eyebrow { margin: 0 0 .4rem; color: var(--text-muted, #617087); font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; } +h1 { margin: 0; font-size: 1.8rem; } +.description { margin: .5rem 0 0; color: var(--text-muted, #617087); } +button { border: 0; border-radius: .55rem; padding: .65rem .9rem; background: #2356c7; color: #fff; font: inherit; cursor: pointer; } +button.secondary { background: #edf1f7; color: #24334b; } +button:disabled { opacity: .55; cursor: wait; } +.notification-list { display: grid; gap: .8rem; padding: 0; list-style: none; } +.notification-card { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border: 1px solid #d9e0eb; border-radius: .8rem; padding: 1rem; background: #fff; } +.notification-card.unread { border-left: 4px solid #2356c7; } +.notification-copy p { margin: .55rem 0; } +.notification-copy time,.request-id { color: #617087; font-size: .85rem; } +.status-pill { display: inline-block; border-radius: 999px; padding: .2rem .55rem; background: #edf1f7; font-size: .75rem; } +.notification-actions { display: flex; flex-wrap: wrap; gap: .5rem; } +.empty-state { border: 1px dashed #c8d1df; border-radius: .8rem; padding: 2rem; text-align: center; color: #617087; } +.notice { border-radius: .55rem; padding: .7rem .9rem; } +.notice.success { background: #edf8f0; color: #175b2b; } +.notice.error { background: #fff1f1; color: #8d2323; } +.next-button { margin-top: .75rem; } +@media (max-width: 640px) { .notifications-page { padding: 1.25rem; } .page-heading,.notification-card { align-items: stretch; flex-direction: column; } .notification-actions { justify-content: flex-start; } } diff --git a/apps/web/src/app/features/portal/notifications.component.spec.ts b/apps/web/src/app/features/portal/notifications.component.spec.ts new file mode 100644 index 00000000..81aecde4 --- /dev/null +++ b/apps/web/src/app/features/portal/notifications.component.spec.ts @@ -0,0 +1,121 @@ +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import type { components } from '@ca-meta/contracts'; +import { PortalContextStore } from '../../core/portal/portal-context.store'; +import { PortalSessionService } from '../../core/portal/portal-session.service'; +import { NotificationsService } from '../../core/portal/notifications.service'; +import { NotificationsComponent } from './notifications.component'; + +type Notification = components['schemas']['Notification']; + +const notice: Notification = { + id: '00000000-0000-4000-8000-000000000061', + kind: 'MESSAGE_RECEIVED', + assetRef: '00000000-0000-4000-8000-000000000062', + messageRef: null, + status: 'UNREAD', + safeSummary: 'A new WhatsApp message is available.', + createdAt: '2026-11-11T12:00:00.000Z', + actions: ['MARK_READ', 'DISMISS'], +}; + +describe('NotificationsComponent', () => { + it('shows tenant scoped safe notices and records READ only after a successful local action', async () => { + const contextChanged$ = new Subject(); + const sessionExpired$ = new Subject(); + const service = { + list: vi.fn().mockResolvedValue({ items: [notice], nextCursor: null, requestId: 'req-notifications-ui' }), + act: vi.fn().mockResolvedValue({ notificationId: notice.id, action: 'MARK_READ', status: 'READ', requestId: 'req-notifications-action' }), + }; + await TestBed.configureTestingModule({ + imports: [NotificationsComponent], + providers: [ + { provide: NotificationsService, useValue: service }, + { provide: PortalSessionService, useValue: { ensureCurrentSession: vi.fn().mockResolvedValue(true) } }, + { provide: PortalContextStore, useValue: { contextChanged$, sessionExpired$ } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(NotificationsComponent); + fixture.detectChanges(); + await fixture.componentInstance.load(); + fixture.componentRef.changeDetectorRef.detectChanges(); + let view = fixture.nativeElement as HTMLElement; + expect(view.textContent).toContain('A new WhatsApp message is available.'); + expect(view.textContent).toContain('Marcar como lida'); + expect(view.textContent).toContain('Dispensar'); + expect(view.textContent).not.toContain('Enviar mensagem'); + + const readButton = Array.from(view.querySelectorAll('button')).find((button) => button.textContent?.includes('Marcar como lida')); + expect(readButton).toBeTruthy(); + readButton?.click(); + await fixture.whenStable(); + fixture.componentRef.changeDetectorRef.detectChanges(); + expect(service.act).toHaveBeenCalledWith(notice.id, { action: 'MARK_READ' }, expect.stringMatching(/^[0-9a-f-]{36}$/i)); + expect(fixture.componentInstance.items[0]?.status).toBe('READ'); + expect(fixture.componentInstance.message).toBe('Notificação marcada como lida.'); + + service.act.mockRejectedValueOnce({ status: 503, error: { requestId: 'req-action-unavailable' } }); + await fixture.componentInstance.apply({ ...notice, status: 'UNREAD' }, 'DISMISS'); + expect(fixture.componentInstance.error).toContain('Não foi possível confirmar a ação'); + expect(fixture.componentInstance.message).toBe(''); + fixture.destroy(); + }); + + it('clears the displayed tenant context before loading after a session context change', async () => { + const contextChanged$ = new Subject(); + const sessions = { ensureCurrentSession: vi.fn().mockResolvedValue(true) }; + const service = { + list: vi.fn().mockResolvedValueOnce({ items: [notice], nextCursor: null, requestId: 'req-notifications-old' }) + .mockResolvedValueOnce({ items: [], nextCursor: null, requestId: 'req-notifications-new' }), + act: vi.fn(), + }; + await TestBed.configureTestingModule({ + imports: [NotificationsComponent], + providers: [ + { provide: NotificationsService, useValue: service }, + { provide: PortalSessionService, useValue: sessions }, + { provide: PortalContextStore, useValue: { contextChanged$, sessionExpired$: new Subject() } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(NotificationsComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentRef.changeDetectorRef.detectChanges(); + expect(fixture.componentInstance.items.length).toBe(1); + contextChanged$.next(); + expect(sessions.ensureCurrentSession).toHaveBeenCalledWith(true); + expect(fixture.componentInstance.items).toEqual([]); + fixture.destroy(); + }); + + it('keeps earlier notices when loading the next page', async () => { + const contextChanged$ = new Subject(); + const nextNotice: Notification = { ...notice, id: '00000000-0000-4000-8000-000000000063', + createdAt: '2026-11-11T12:01:00.000Z' }; + const service = { + list: vi.fn() + .mockResolvedValueOnce({ items: [notice], nextCursor: 'cursor-next-page', requestId: 'req-notifications-page-one' }) + .mockResolvedValueOnce({ items: [nextNotice], nextCursor: null, requestId: 'req-notifications-page-two' }), + act: vi.fn(), + }; + await TestBed.configureTestingModule({ + imports: [NotificationsComponent], + providers: [ + { provide: NotificationsService, useValue: service }, + { provide: PortalSessionService, useValue: { ensureCurrentSession: vi.fn().mockResolvedValue(true) } }, + { provide: PortalContextStore, useValue: { contextChanged$, sessionExpired$: new Subject() } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(NotificationsComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentRef.changeDetectorRef.detectChanges(); + expect(fixture.componentInstance.items.map((item) => item.id)).toEqual([notice.id]); + await fixture.componentInstance.nextPage(); + expect(service.list).toHaveBeenNthCalledWith(2, 'cursor-next-page'); + expect(fixture.componentInstance.items.map((item) => item.id)).toEqual([notice.id, nextNotice.id]); + fixture.destroy(); + }); +}); diff --git a/apps/web/src/app/features/portal/notifications.component.ts b/apps/web/src/app/features/portal/notifications.component.ts new file mode 100644 index 00000000..07c427b4 --- /dev/null +++ b/apps/web/src/app/features/portal/notifications.component.ts @@ -0,0 +1,113 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subject, takeUntil } from 'rxjs'; +import { PortalContextStore } from '../../core/portal/portal-context.store'; +import { PortalSessionService } from '../../core/portal/portal-session.service'; +import { safeRequestId } from '../../core/portal/safe-api-error'; +import { NotificationsService, PortalNotification } from '../../core/portal/notifications.service'; + +@Component({ + selector: 'app-notifications', + standalone: true, + imports: [CommonModule], + templateUrl: './notifications.component.html', + styleUrl: './notifications.component.scss', +}) +export class NotificationsComponent implements OnInit, OnDestroy { + items: readonly PortalNotification[] = []; + nextCursor: string | null = null; + isLoading = false; + error = ''; + message = ''; + requestId: string | null = null; + private readonly destroy$ = new Subject(); + private generation = 0; + private readonly acting = new Set(); + + constructor( + private readonly service: NotificationsService, + private readonly sessions: PortalSessionService, + private readonly context: PortalContextStore, + ) {} + + ngOnInit(): void { + void this.load(); + this.context.contextChanged$.pipe(takeUntil(this.destroy$)).subscribe(() => void this.reloadForContext()); + this.context.sessionExpired$.pipe(takeUntil(this.destroy$)).subscribe(() => this.clearForExpiredSession()); + } + + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } + + async load(cursor?: string): Promise { + const generation = ++this.generation; + this.isLoading = true; + this.error = ''; + this.message = ''; + this.requestId = null; + try { + const page = await this.service.list(cursor); + if (generation !== this.generation) return; + this.items = cursor === undefined ? page.items : [...this.items, ...page.items]; + this.nextCursor = page.nextCursor; + this.requestId = page.requestId; + } catch (error: unknown) { + if (generation !== this.generation) return; + this.items = []; + this.nextCursor = null; + this.error = 'Não foi possível carregar as notificações desta empresa.'; + this.requestId = safeRequestId(error); + } finally { + if (generation === this.generation) this.isLoading = false; + } + } + + async apply(item: PortalNotification, action: 'MARK_READ' | 'DISMISS'): Promise { + if (this.acting.has(item.id) || !item.actions.includes(action)) return; + this.acting.add(item.id); + this.error = ''; + this.message = ''; + try { + const key = globalThis.crypto?.randomUUID?.(); + if (!key) throw new Error('A secure idempotency key is unavailable.'); + const result = await this.service.act(item.id, { action }, key); + this.message = result.status === 'DISMISSED' ? 'Notificação dispensada.' : 'Notificação marcada como lida.'; + this.requestId = result.requestId; + if (action === 'DISMISS') this.items = this.items.filter((entry) => entry.id !== item.id); + else this.items = this.items.map((entry) => entry.id === item.id ? { ...entry, status: result.status, + actions: entry.actions.filter((available) => available !== 'MARK_READ') } : entry); + } catch (error: unknown) { + this.error = 'Não foi possível confirmar a ação. Atualize as notificações antes de repetir.'; + this.requestId = safeRequestId(error); + } finally { + this.acting.delete(item.id); + } + } + + isActing(notificationId: string): boolean { return this.acting.has(notificationId); } + async nextPage(): Promise { if (this.nextCursor) await this.load(this.nextCursor); } + trackById(_index: number, item: PortalNotification): string { return item.id; } + + private async reloadForContext(): Promise { + this.generation += 1; + this.items = []; + this.nextCursor = null; + this.requestId = null; + this.message = ''; + this.error = ''; + if (!await this.sessions.ensureCurrentSession(true)) { + this.error = 'A sessão não está disponível para este contexto. Entre novamente.'; + return; + } + await this.load(); + } + + private clearForExpiredSession(): void { + this.generation += 1; + this.items = []; + this.nextCursor = null; + this.isLoading = false; + this.requestId = null; + this.message = ''; + this.error = 'Sua sessão terminou. Entre novamente para consultar as notificações.'; + } +} diff --git a/docs/delivery/M5/M5.06.md b/docs/delivery/M5/M5.06.md new file mode 100644 index 00000000..e424553d --- /dev/null +++ b/docs/delivery/M5/M5.06.md @@ -0,0 +1,82 @@ +# M5.06 — Notificações do portal e ações duráveis + +## Escopo e decisões + +Issue [#61](https://github.com/costaapps/ca_meta_app/issues/61). A tela `/notifications` lista apenas avisos do tenant resolvido pela sessão verificada, membership ativa e contexto atual do servidor. `cUUID`, `company_uuid` e identificadores enviados pelo navegador não estabelecem identidade nem selecionam a empresa. A consulta pagina por cursor limitado e expõe somente resumo escolhido no servidor, estado, referências internas e horário; conteúdo de mensagem, payload bruto e destino do provider não são retornados. + +`MARK_READ` e `DISMISS` são transições locais em transação MariaDB e têm recibo idempotente com hash da chave e do pedido. `SEND_TEMPLATE` é aceito somente para ativo interno `WHATSAPP_PHONE_NUMBER`; `REPLY_COMMENT`, somente para aviso de comentário e Página/conta profissional autorizada. A fila `NOTIFICATION_ACTION` usa efeito `AMBIGUOUS` e uma tentativa. O recibo e o job são criados na mesma transação; uma repetição da chave retorna a mesma operação. Outra chave é bloqueada enquanto existir tentativa PENDING/RUNNING/UNKNOWN. Estado desconhecido não autoriza retry automático. + +O worker tipado resolve o aviso, o ativo interno e a autorização ativa/verificada no tenant do job. Em seguida resolve credencial pelo `assetId` e capability correspondente antes de chamar o provider. Somente `outcome === 'SUCCEEDED'` explícito pode concluir SUCCEEDED e marcar o aviso READ. Erro explícito, inclusive objeto de erro com HTTP 200, termina sem marcar READ; timeout, exceção durante dispatch, resposta nula ou status sem sucesso explícito terminam UNKNOWN. `SEND_MESSAGE` permanece indisponível. + +No processo da API, o adapter de provider e a política externa continuam ausentes e o gate de execução permanece fechado. Portanto, `SEND_TEMPLATE` e `REPLY_COMMENT` retornam 503 em runtime até que autorização, elegibilidade, política e capability externas sejam revisadas e instaladas. O adapter e worker podem ser exercitados somente por providers/resolvers sintéticos nos testes. Não houve chamadas Meta, envio real, importação de credencial ou leitura de conteúdo de cliente. + +O produtor de projeções agora requer o scope de serviço `NOTIFICATION_PROJECTION_WRITE`; `TENANT_RESOURCE_READ` sozinho não autoriza escrita. A referência de origem precisa corresponder a um evento já processado, roteado, do mesmo tenant e ativo. Até existir um parser/projetor confiável que extraia o destino do item protegido e prove esse vínculo, `recordProjection` rejeita `providerTargetRef` e grava o campo nulo. Assim, nenhum target fornecido pelo caller pode chegar ao worker por esta porta. + +## Critérios e evidência + +| Critério | Estado | Evidência local | +|---|---|---| +| Lista tenant-scoped com cursor e resumo seguro | Implementado | `notifications.service.ts`; integração com duas empresas no mesmo `IdDatabase`, membership/contextos distintos, paginação e exclusão entre tenants. | +| Ações locais de leitura/dispensa com repetição e concorrência seguras | Implementado | Serviço transacional com recibos; teste MariaDB cobre replay e requisições concorrentes. | +| Ações externas duráveis por ativo, com operação consultável e sem retry cego | Implementado com dispatch fail-closed | Job `NOTIFICATION_ACTION`, receipt e consulta de operação tenant-scoped; teste concorrente cria uma operação e simula UNKNOWN, sem alteração para READ ou nova tentativa com chave diferente. | +| Credencial selecionada pelo ativo interno do aviso | Adaptador implementado; runtime desabilitado | `notification-action.worker.ts` exige ativo/autorização verificados e chama `CredentialResolverService.resolve` com o asset interno. Teste usa resolver/provider sintéticos; não há envio real. | +| Resposta de erro/null/timeout não é sucesso nem leitura | Implementado | Testes cobrem HTTP 200 com `error`, null/undefined, worker timeout→UNKNOWN, e finalizer que não marca READ em UNKNOWN. | +| `SEND_MESSAGE` e efeitos não confirmados indisponíveis | Fail-closed | API não registra adapter nem habilita gate externo; UI oferece somente ações locais. O código não declara aprovação ou sucesso a partir de HTTP 202. | +| Origem operacional de `MetaAppReviewNotifications` preservada sem inferência | Not confirmed | Nenhum producer ou fonte operacional foi confirmado. O novo `recordProjection` é uma porta interna confiável e não está ligada a webhook público nem ao fallback/demo App Review legado. Não houve importação automática. | + +## Contrato e limitações externas + +O OpenAPI documenta a separação entre ações locais (200) e operação durável externa (202), status e semântica UNKNOWN, campos fechados e o 503 fail-closed atual. A resposta 202 mantém `jobId`, `status: PENDING` e `Location: /api/v1/jobs/{id}` do contrato v1, acrescentando `operationId` opcional com o mesmo identificador; a rota `GET /api/v1/operations/{operationId}` retorna o estado atualizado. O cliente antigo `REPLY` é aceito e normalizado para a ação tipada de comentário, que só pode enfileirar aviso de comentário após validação server-side; outras ações sem efeito continuam fail-closed. Os filtros de canal e número da listagem permanecem compatíveis e são aplicados sobre os ativos tenant-scoped. + +A geração de artefatos, `check:generated` e lint são executados no candidato; o lint preserva quatro avisos preexistentes sobre respostas 4XX ausentes em GET/HEAD de magic-link. + +Semântica operacional da tabela `MetaAppReviewNotifications`, origem/garantias dos avisos, destino de comentário e capability de envio permanecem **Not confirmed**. Nenhuma equivalência com webhook ativo, evento Meta, estado App Review ou dados de produção foi presumida. As permissões e elegibilidade atuais para `SEND_TEMPLATE`/`REPLY_COMMENT`, os limites do produto e a resposta que comprova efeito também permanecem **Not confirmed**. O cliente/worker real não é habilitado até haver evidência externa atual e revisão de segurança. + +A instalação futura do adapter segue bloqueada por duas limitações locais adicionais: a membership e o grant `CONNECT` do ator que pediu a ação ainda não são revalidados pelo worker no momento do dispatch, e o texto da ação persiste no JSON durável do job sem proteção em repouso. Antes de habilitar dispatch, implementar revogação/revalidação do ator no worker, proteger ou deixar de persistir conteúdo de texto, e fornecer a resolução confiável do target a partir do evento exato. O runtime atual não executa ações externas. + +## Banco e harness + +A migração M5.06 está registrada como schema version `24`, ID `202611120024-tenant-notifications`, após M5.07 v21, M5.03 v22 e M5.05 v23. O checksum é calculado da definição versionada. As tabelas são tenant-scoped e vinculam `(tenant_id, asset_id)`, `(tenant_id, notification_id)` e `(tenant_id, operation_id)`; índices e FKs não dependem de identificador do navegador. + +O harness `tools/m5-06/run-focused-api-test.mjs` exige Node 24 e npm 11, compila apenas as fontes/testes indicados, executa quatro testes sintéticos de adapter/worker/projeção e um teste MariaDB focado quando `M5_06_NOTIFICATION_TEST=1`. `M5_06_MIGRATE=1` aplica migrations pela CLI antes do teste. A API lê `API_DATABASE_*`; a CLI lê `MIGRATION_DATABASE_*`, `MIGRATION_DATABASE_CONNECT_TIMEOUT_MS`, `MIGRATION_LOCK_TIMEOUT_SECONDS`, `MIGRATION_STATEMENT_TIMEOUT_SECONDS`, `MIGRATION_MIN_SUPPORTED_API_VERSION` e `API_DATABASE_USER`. Para repetir o teste, mapeie as credenciais provisionadas no processo local, configure host `127.0.0.1`, porta `13307`, schema `ca_meta_m5_06`, timeout de conexão `5000`, lock timeout `30`, statement timeout `300` e baseline `1`; habilite `M5_06_NOTIFICATION_TEST=1` e `M5_06_MIGRATE=1`, então execute `npm run test:m5-06 --workspace=@ca-meta/api`. Não copie credenciais para o repo, imprima valores ou inclua-os neste documento. O teste remove somente fixtures pelos tenant IDs gerados; não apaga schema nem container. `npm run test:m5-06 --workspace=@ca-meta/web` executa somente o spec Angular desta tela e limpa sua pasta temporária própria. + +## Evidência de desenvolvimento anterior à recomposição + +Worktree: `C:\Users\user\.codex\worktrees\m5-06-notifications\ca_meta_app` · branch `desktop/m5-06` · Node `v24.22.2` · npm `11.19.1` · PowerShell. Junction de `node_modules` compartilhado, com overlay local de `@ca-meta/contracts`. O schema de teste foi `ca_meta_m5_06`; nenhum segredo foi registrado. + +| Comando | Resultado | +|---|---| +| `npm run generate --workspace=@ca-meta/contracts` | Passou; 12 artefatos determinísticos gerados. | +| `npm run check:generated --workspace=@ca-meta/contracts` | Passou; 12 artefatos verificados. | +| `npm run lint --workspace=@ca-meta/contracts` | Passou com quatro avisos preexistentes em GET/HEAD magic-link. | +| `npm run typecheck --workspace=@ca-meta/api` | Passou para aplicação e testes. | +| `npm run typecheck --workspace=@ca-meta/web` | Passou. | +| `npm run test:m5-06 --workspace=@ca-meta/api` com teste/migrate habilitados e variáveis locais | Passou; migration aplicada e 4/4 testes focados, incluindo MariaDB isolado. | +| `npm run test:m5-06 --workspace=@ca-meta/web` | Passou; 2/2 specs Angular focados. | +| `git diff --check` | Passou no snapshot final antes do commit; apenas avisos do Git sobre conversão LF/CRLF em arquivos gerados. | + +A suíte completa, validação de Meta/Suite4/PHP implantado, chamadas reais e execução no runtime operacional não foram executadas nem reivindicadas. + +## Correções focadas após revisão independente + +No commit `87e7be7eebbee7f037b89fab5448d73eaeb66c1f`, carregar próxima página passou a anexar os avisos anteriores; carga inicial e troca de contexto continuam substituindo o conteúdo anterior. O spec focal adiciona uma regressão de duas páginas. No snapshot desse commit, `npm run test:m5-06 --workspace=@ca-meta/web` passou 3/3, `npm run typecheck --workspace=@ca-meta/web` passou e `git diff --check` passou. + +No commit `ed0ec4f3579b0fc27c25c97808946a56758cae98`, projeção passou a exigir o scope de serviço separado `NOTIFICATION_PROJECTION_WRITE`, valida `event_ref` com estado PROCESSED/ROUTED no mesmo tenant e asset, e rejeita qualquer `providerTargetRef` até haver extração confiável vinculada ao evento protegido. O adapter só reconhece sucesso se `outcome` for exatamente `SUCCEEDED`; estado desconhecido nunca é convertido em sucesso. Novos testes negam grant somente READ, target forjado e evento sem vínculo. Nesse snapshot, `npm run test:m5-06 --workspace=@ca-meta/api` passou 4 testes sintéticos; o teste MariaDB foi ignorado pois este rerun não habilitou `M5_06_NOTIFICATION_TEST`/migration. `npm run typecheck --workspace=@ca-meta/api` e `git diff --check` passaram. Não houve alteração de schema/migration para estas correções. + +O adapter continua desabilitado no runtime. Além da semântica e elegibilidade externas Not confirmed, sua instalação permanece bloqueada até o worker revalidar membership e `CONNECT` do ator no momento do dispatch, proteger o texto persistido no payload do job em repouso e implementar extração/binding confiável do target a partir do evento protegido. + +## Revalidação após integrar M5.05 + +O candidato `480b44cd6e405d55a1f7f8a15a1f6ef6a4f1b421` foi recomposto sobre `8f83cb66be773655b756461e3c1d03f1e6f1ff40`; a migration M5.06 é v24 após M5.05 v23. Com Node 24.22.2/npm 11.19.1, foram executados neste SHA: `npm run typecheck --workspace=@ca-meta/api` (passou); `npm run test:m5-06 --workspace=@ca-meta/api` com flags de MariaDB/migrate desabilitadas (4 testes passaram, o teste MariaDB foi skipped); `npm run test:m5-06 --workspace=@ca-meta/web` (3/3); `tools/m3-03/run-migration-unit.ps1` (9/9, registry contínua v1–v24); e `git diff --check 8f83cb66...HEAD` (passou). Reviews funcional e de segurança foram PASS no mesmo SHA. + +O teste MariaDB específico de M5.06 permanece **Not confirmed** após a recomposição. O schema local retido `ca_meta_m5_06` é anterior, com checksum/ordem v21/v22 incompatíveis; não foi alterado. A preparação automatizada de um projeto MariaDB descartável foi rejeitada pela revisão automática antes de iniciar, sem criar serviço, schema ou credencial temporária; não houve tentativa de contornar essa rejeição. O workflow M2.08 do PR executa o harness M2.03 em MariaDB descartável e deve aplicar/verificar a registry v1–v24, mas não habilita o teste de integração específico de M5.06. A issue #61 permanece aberta até esse limite e os gates externos serem resolvidos. + +Os dispatches externos seguem desabilitados e os efeitos Meta permanecem **Not confirmed**. Antes de habilitá-los, a revisão de segurança requer revalidar membership/`CONNECT` do ator no momento do dispatch, proteger conteúdo persistido no payload durável e confirmar resolução confiável do target a partir do evento autorizado. + +## Compatibilidade e revalidação local + +No commit `a69a8f6b434909335d428b5ff161bd5abb162f1b`, a resposta de ação externa preserva o `jobId`, o `status: PENDING` e o `Location` do contrato v1; `operationId` é aditivo/opcional e identifica a rota de status da ação. A entrada legada `REPLY` continua aceita e é normalizada para a ação tipada de comentário. Os filtros `channel` e `number` da listagem foram restaurados e agora filtram ativos dentro do tenant autorizado. + +Com Node 24.22.2/npm 11.19.1, no mesmo commit: geração e `check:generated` de 12 artefatos em `@ca-meta/contracts` passaram; `check:breaking` reportou nenhuma quebra frente ao baseline v1; geração e `check:generated` M1.07 passaram com 53 linhas de compatibilidade; `npm run typecheck --workspace=@ca-meta/api` e `git diff --check` passaram. O typecheck inclui o teste de integração atualizado, mas a integração MariaDB específica não foi executada neste commit e permanece **Not confirmed**. Os workflows GitHub deste SHA devem fornecer a validação completa de PR. + +No commit `fd8bba0b750d29b70f3eb02199f96e4f273e8e41`, `npm run test:contract --prefix tools/m1-07` passou 11/11; o gerador/checker de 12 artefatos e `check:breaking` permaneceram verdes. O workflow M1.07 do head anterior `2cb2c55` falhou somente porque um fixture exige que a política declare explicitamente “no automatic retry”; o contrato agora explicita que há no máximo um despacho inicial e nenhuma repetição automática. O teste focal passou após esse ajuste. O workflow será executado novamente no novo head. diff --git a/docs/meta-v2/contracts/openapi.yaml b/docs/meta-v2/contracts/openapi.yaml index f9c45cb5..2e241ac1 100644 --- a/docs/meta-v2/contracts/openapi.yaml +++ b/docs/meta-v2/contracts/openapi.yaml @@ -2151,7 +2151,7 @@ paths: summary: Sanitized success example value: id: op_example_01 - kind: SEND_REPLY + kind: REPLY_COMMENT status: UNKNOWN requestId: req_example_35 updatedAt: 2026-10-29T12:03:20Z @@ -2298,7 +2298,8 @@ paths: - Notifications summary: List tenant-scoped operational notifications description: Returns safe metadata, references and freshness; no message body or raw provider - payload is included. Cursor and limit are bounded. + payload is included. Cursor and limit are bounded. Repeat the same channel and number filters + on every page request when continuing with a cursor. security: *a10 parameters: - $ref: "#/components/parameters/TenantContext" @@ -2328,7 +2329,7 @@ paths: actions: - MARK_READ - DISMISS - - REPLY + - SEND_TEMPLATE nextCursor: null requestId: req_example_38 "400": *a11 @@ -2359,10 +2360,13 @@ paths: tags: - Notifications summary: Apply a notification action - description: The action is checked against current membership, asset grant and role. - MARK_READ/DISMISS are local; REPLY is an external message effect and enters the bounded - async job/reconciliation path. This endpoint never reports a provider send as complete on - HTTP acceptance. + description: The server derives the tenant from the verified session and current membership, + then resolves the notification and asset in that tenant. MARK_READ/DISMISS are local state + changes and return 200 only after persistence. When an eligible external adapter is available, + SEND_TEMPLATE/REPLY_COMMENT return a durable operation; HTTP acceptance is not provider success. + The current runtime has no confirmed credential or action adapter and returns 503 for those + external actions. SEND_MESSAGE is unavailable until current eligibility and supported message + context are confirmed. Browser input cannot select an asset/provider ID or tenant. security: *a10 parameters: *a23 requestBody: @@ -2376,24 +2380,43 @@ paths: sanitized: summary: Sanitized request example value: - action: REPLY - replyText: Thanks, we will follow up shortly. + action: MARK_READ responses: + "200": + description: Local READ/DISMISS state persisted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationActionResult" + examples: + read: + summary: Local action result + value: + notificationId: 20000000-0000-4000-8000-000000000001 + action: MARK_READ + status: READ + requestId: req_example_39 "202": - description: Sanitized success example - headers: *a24 + description: Durable external action accepted for processing + headers: + Location: + description: Existing tenant-scoped job resource; operationId also identifies GET /api/v1/operations/{operationId}. + schema: + type: string + pattern: ^/api/v1/jobs/[A-Za-z0-9_-]+$ content: application/json: schema: $ref: "#/components/schemas/NotificationActionAccepted" examples: sanitized: - summary: Sanitized success example + summary: Sanitized accepted operation value: - notificationId: notice_example_01 - jobId: job_example_07 + notificationId: 20000000-0000-4000-8000-000000000001 + jobId: 20000000-0000-4000-8000-000000000002 + operationId: 20000000-0000-4000-8000-000000000002 status: PENDING - location: /api/v1/jobs/job_example_07 + location: /api/v1/jobs/20000000-0000-4000-8000-000000000002 requestId: req_example_39 "400": *a11 "401": *a12 @@ -2406,12 +2429,13 @@ paths: "429": *a19 "503": *a20 default: *a21 - x-external-effect: REPLY may send an outbound message. MARK_READ and DISMISS only update local - notification state. - x-idempotency-scope: tenant + operation + Idempotency-Key plus payload hash. - x-retry-policy: For REPLY, at most one automatic attempt including the initial send. A proven - pre-effect failure may permit a new explicit user request; an ambiguous outcome is UNKNOWN until - reconciled. Do not automatically retry. + x-external-effect: SEND_TEMPLATE and REPLY_COMMENT may send an outbound message when an adapter + is enabled; the current runtime has none. SEND_MESSAGE remains unavailable. MARK_READ and + DISMISS only update local notification state. + x-idempotency-scope: tenant + notification action + Idempotency-Key and canonical request payload hash. + x-retry-policy: External actions use at most one automatic attempt, which is the initial dispatch, + with no automatic retry. A null/error/timeout or ambiguous result becomes UNKNOWN and is never + blindly retried. A failed action does not mark the notification read or dismissed. /internal/v1/assets/{assetId}/operations: parameters: - name: assetId @@ -4631,6 +4655,7 @@ components: type: string enum: - MESSAGE_RECEIVED + - COMMENT_RECEIVED - ASSET_STATUS_CHANGED - ACTION_REQUIRED assetRef: @@ -4665,6 +4690,9 @@ components: - DISMISS - REPLY - REFRESH + - SEND_TEMPLATE + - REPLY_COMMENT + - SEND_MESSAGE NotificationListPage: type: object additionalProperties: false @@ -4713,6 +4741,88 @@ components: minLength: 1 maxLength: 4096 writeOnly: true + - type: object + additionalProperties: false + required: + - action + - templateName + - language + - parameters + properties: + action: + type: string + const: SEND_TEMPLATE + templateName: + type: string + minLength: 1 + maxLength: 128 + language: + type: string + minLength: 2 + maxLength: 16 + parameters: + type: array + maxItems: 100 + items: + type: string + minLength: 1 + maxLength: 1024 + writeOnly: true + - type: object + additionalProperties: false + required: + - action + - replyText + properties: + action: + type: string + const: REPLY_COMMENT + replyText: + type: string + minLength: 1 + maxLength: 4096 + writeOnly: true + - type: object + additionalProperties: false + required: + - action + - messageText + properties: + action: + type: string + const: SEND_MESSAGE + messageText: + type: string + minLength: 1 + maxLength: 4096 + writeOnly: true + NotificationActionResult: + type: object + additionalProperties: false + required: + - notificationId + - action + - status + - requestId + properties: + notificationId: + type: string + minLength: 1 + maxLength: 128 + action: + type: string + enum: + - MARK_READ + - DISMISS + status: + type: string + enum: + - READ + - DISMISSED + requestId: + type: string + minLength: 1 + maxLength: 128 NotificationActionAccepted: type: object additionalProperties: false @@ -4741,6 +4851,11 @@ components: type: string minLength: 1 maxLength: 128 + operationId: + type: string + minLength: 1 + maxLength: 128 + description: Same identifier as jobId; use GET /api/v1/operations/{operationId} for action status. InternalAssetOperationRequest: oneOf: - type: object diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 27e8fb5e..e938313c 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -2300,7 +2300,7 @@ paths: summary: Sanitized success example value: id: op_example_01 - kind: SEND_REPLY + kind: REPLY_COMMENT status: UNKNOWN requestId: req_example_35 updatedAt: 2026-10-29T12:03:20Z @@ -2454,7 +2454,9 @@ paths: - Notifications summary: List tenant-scoped operational notifications description: Returns safe metadata, references and freshness; no message body or - raw provider payload is included. Cursor and limit are bounded. + raw provider payload is included. Cursor and limit are bounded. Repeat + the same channel and number filters on every page request when + continuing with a cursor. security: *a10 parameters: - $ref: "#/components/parameters/TenantContext" @@ -2484,7 +2486,7 @@ paths: actions: - MARK_READ - DISMISS - - REPLY + - SEND_TEMPLATE nextCursor: null requestId: req_example_38 "400": *a11 @@ -2515,10 +2517,16 @@ paths: tags: - Notifications summary: Apply a notification action - description: The action is checked against current membership, asset grant and - role. MARK_READ/DISMISS are local; REPLY is an external message effect - and enters the bounded async job/reconciliation path. This endpoint - never reports a provider send as complete on HTTP acceptance. + description: The server derives the tenant from the verified session and current + membership, then resolves the notification and asset in that tenant. + MARK_READ/DISMISS are local state changes and return 200 only after + persistence. When an eligible external adapter is available, + SEND_TEMPLATE/REPLY_COMMENT return a durable operation; HTTP acceptance + is not provider success. The current runtime has no confirmed credential + or action adapter and returns 503 for those external actions. + SEND_MESSAGE is unavailable until current eligibility and supported + message context are confirmed. Browser input cannot select an + asset/provider ID or tenant. security: *a10 parameters: *a23 requestBody: @@ -2532,24 +2540,44 @@ paths: sanitized: summary: Sanitized request example value: - action: REPLY - replyText: Thanks, we will follow up shortly. + action: MARK_READ responses: + "200": + description: Local READ/DISMISS state persisted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationActionResult" + examples: + read: + summary: Local action result + value: + notificationId: 20000000-0000-4000-8000-000000000001 + action: MARK_READ + status: READ + requestId: req_example_39 "202": - description: Sanitized success example - headers: *a24 + description: Durable external action accepted for processing + headers: + Location: + description: Existing tenant-scoped job resource; operationId also identifies + GET /api/v1/operations/{operationId}. + schema: + type: string + pattern: ^/api/v1/jobs/[A-Za-z0-9_-]+$ content: application/json: schema: $ref: "#/components/schemas/NotificationActionAccepted" examples: sanitized: - summary: Sanitized success example + summary: Sanitized accepted operation value: - notificationId: notice_example_01 - jobId: job_example_07 + notificationId: 20000000-0000-4000-8000-000000000001 + jobId: 20000000-0000-4000-8000-000000000002 + operationId: 20000000-0000-4000-8000-000000000002 status: PENDING - location: /api/v1/jobs/job_example_07 + location: /api/v1/jobs/20000000-0000-4000-8000-000000000002 requestId: req_example_39 "400": *a11 "401": *a12 @@ -2562,13 +2590,16 @@ paths: "429": *a19 "503": *a20 default: *a21 - x-external-effect: REPLY may send an outbound message. MARK_READ and DISMISS - only update local notification state. - x-idempotency-scope: tenant + operation + Idempotency-Key plus payload hash. - x-retry-policy: For REPLY, at most one automatic attempt including the initial - send. A proven pre-effect failure may permit a new explicit user - request; an ambiguous outcome is UNKNOWN until reconciled. Do not - automatically retry. + x-external-effect: SEND_TEMPLATE and REPLY_COMMENT may send an outbound message + when an adapter is enabled; the current runtime has none. SEND_MESSAGE + remains unavailable. MARK_READ and DISMISS only update local + notification state. + x-idempotency-scope: tenant + notification action + Idempotency-Key and + canonical request payload hash. + x-retry-policy: External actions use at most one automatic attempt, which is the + initial dispatch, with no automatic retry. A null/error/timeout or + ambiguous result becomes UNKNOWN and is never blindly retried. A failed + action does not mark the notification read or dismissed. /api/v1/webhooks/meta/{appConfigRef}: parameters: - name: appConfigRef @@ -4740,6 +4771,7 @@ components: type: string enum: - MESSAGE_RECEIVED + - COMMENT_RECEIVED - ASSET_STATUS_CHANGED - ACTION_REQUIRED assetRef: @@ -4774,6 +4806,9 @@ components: - DISMISS - REPLY - REFRESH + - SEND_TEMPLATE + - REPLY_COMMENT + - SEND_MESSAGE NotificationListPage: type: object additionalProperties: false @@ -4822,6 +4857,88 @@ components: minLength: 1 maxLength: 4096 writeOnly: true + - type: object + additionalProperties: false + required: + - action + - templateName + - language + - parameters + properties: + action: + type: string + const: SEND_TEMPLATE + templateName: + type: string + minLength: 1 + maxLength: 128 + language: + type: string + minLength: 2 + maxLength: 16 + parameters: + type: array + maxItems: 100 + items: + type: string + minLength: 1 + maxLength: 1024 + writeOnly: true + - type: object + additionalProperties: false + required: + - action + - replyText + properties: + action: + type: string + const: REPLY_COMMENT + replyText: + type: string + minLength: 1 + maxLength: 4096 + writeOnly: true + - type: object + additionalProperties: false + required: + - action + - messageText + properties: + action: + type: string + const: SEND_MESSAGE + messageText: + type: string + minLength: 1 + maxLength: 4096 + writeOnly: true + NotificationActionResult: + type: object + additionalProperties: false + required: + - notificationId + - action + - status + - requestId + properties: + notificationId: + type: string + minLength: 1 + maxLength: 128 + action: + type: string + enum: + - MARK_READ + - DISMISS + status: + type: string + enum: + - READ + - DISMISSED + requestId: + type: string + minLength: 1 + maxLength: 128 NotificationActionAccepted: type: object additionalProperties: false @@ -4850,6 +4967,12 @@ components: type: string minLength: 1 maxLength: 128 + operationId: + type: string + minLength: 1 + maxLength: 128 + description: Same identifier as jobId; use GET /api/v1/operations/{operationId} + for action status. WebhookChallengeResponse: type: object additionalProperties: false diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index e17c872d..d4e2061b 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -713,7 +713,7 @@ export interface paths { }; /** * List tenant-scoped operational notifications - * @description Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. + * @description Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. Repeat the same channel and number filters on every page request when continuing with a cursor. */ readonly get: operations["listNotifications"]; readonly put?: never; @@ -738,7 +738,7 @@ export interface paths { readonly put?: never; /** * Apply a notification action - * @description The action is checked against current membership, asset grant and role. MARK_READ/DISMISS are local; REPLY is an external message effect and enters the bounded async job/reconciliation path. This endpoint never reports a provider send as complete on HTTP acceptance. + * @description The server derives the tenant from the verified session and current membership, then resolves the notification and asset in that tenant. MARK_READ/DISMISS are local state changes and return 200 only after persistence. When an eligible external adapter is available, SEND_TEMPLATE/REPLY_COMMENT return a durable operation; HTTP acceptance is not provider success. The current runtime has no confirmed credential or action adapter and returns 503 for those external actions. SEND_MESSAGE is unavailable until current eligibility and supported message context are confirmed. Browser input cannot select an asset/provider ID or tenant. */ readonly post: operations["actOnNotification"]; readonly delete?: never; @@ -1340,7 +1340,7 @@ export interface components { readonly Notification: { readonly id: string; /** @enum {string} */ - readonly kind: "MESSAGE_RECEIVED" | "ASSET_STATUS_CHANGED" | "ACTION_REQUIRED"; + readonly kind: "MESSAGE_RECEIVED" | "COMMENT_RECEIVED" | "ASSET_STATUS_CHANGED" | "ACTION_REQUIRED"; readonly assetRef: string; readonly messageRef: string | null; /** @enum {string} */ @@ -1348,7 +1348,7 @@ export interface components { readonly safeSummary: string; /** Format: date-time */ readonly createdAt: string; - readonly actions: readonly ("MARK_READ" | "DISMISS" | "REPLY" | "REFRESH")[]; + readonly actions: readonly ("MARK_READ" | "DISMISS" | "REPLY" | "REFRESH" | "SEND_TEMPLATE" | "REPLY_COMMENT" | "SEND_MESSAGE")[]; }; readonly NotificationListPage: { readonly items: readonly components["schemas"]["Notification"][]; @@ -1362,6 +1362,28 @@ export interface components { /** @constant */ readonly action: "REPLY"; readonly replyText: string; + } | { + /** @constant */ + readonly action: "SEND_TEMPLATE"; + readonly templateName: string; + readonly language: string; + readonly parameters: readonly string[]; + } | { + /** @constant */ + readonly action: "REPLY_COMMENT"; + readonly replyText: string; + } | { + /** @constant */ + readonly action: "SEND_MESSAGE"; + readonly messageText: string; + }; + readonly NotificationActionResult: { + readonly notificationId: string; + /** @enum {string} */ + readonly action: "MARK_READ" | "DISMISS"; + /** @enum {string} */ + readonly status: "READ" | "DISMISSED"; + readonly requestId: string; }; readonly NotificationActionAccepted: { readonly notificationId: string; @@ -1370,6 +1392,8 @@ export interface components { readonly status: "PENDING"; readonly location: string; readonly requestId: string; + /** @description Same identifier as jobId; use GET /api/v1/operations/{operationId} for action status. */ + readonly operationId?: string; }; readonly WebhookChallengeResponse: { readonly challenge: string; @@ -3214,10 +3238,19 @@ export interface operations { }; }; readonly responses: { - /** @description Sanitized success example */ + /** @description Local READ/DISMISS state persisted successfully */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["NotificationActionResult"]; + }; + }; + /** @description Durable external action accepted for processing */ readonly 202: { headers: { - /** @description Relative URI of the tenant-scoped accepted job; poll it for the outcome. */ + /** @description Existing tenant-scoped job resource; operationId also identifies GET /api/v1/operations/{operationId}. */ readonly Location?: string; readonly [name: string]: unknown; }; diff --git a/tools/m1-07/src/generated/openapi.d.ts b/tools/m1-07/src/generated/openapi.d.ts index e17c872d..d4e2061b 100644 --- a/tools/m1-07/src/generated/openapi.d.ts +++ b/tools/m1-07/src/generated/openapi.d.ts @@ -713,7 +713,7 @@ export interface paths { }; /** * List tenant-scoped operational notifications - * @description Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. + * @description Returns safe metadata, references and freshness; no message body or raw provider payload is included. Cursor and limit are bounded. Repeat the same channel and number filters on every page request when continuing with a cursor. */ readonly get: operations["listNotifications"]; readonly put?: never; @@ -738,7 +738,7 @@ export interface paths { readonly put?: never; /** * Apply a notification action - * @description The action is checked against current membership, asset grant and role. MARK_READ/DISMISS are local; REPLY is an external message effect and enters the bounded async job/reconciliation path. This endpoint never reports a provider send as complete on HTTP acceptance. + * @description The server derives the tenant from the verified session and current membership, then resolves the notification and asset in that tenant. MARK_READ/DISMISS are local state changes and return 200 only after persistence. When an eligible external adapter is available, SEND_TEMPLATE/REPLY_COMMENT return a durable operation; HTTP acceptance is not provider success. The current runtime has no confirmed credential or action adapter and returns 503 for those external actions. SEND_MESSAGE is unavailable until current eligibility and supported message context are confirmed. Browser input cannot select an asset/provider ID or tenant. */ readonly post: operations["actOnNotification"]; readonly delete?: never; @@ -1340,7 +1340,7 @@ export interface components { readonly Notification: { readonly id: string; /** @enum {string} */ - readonly kind: "MESSAGE_RECEIVED" | "ASSET_STATUS_CHANGED" | "ACTION_REQUIRED"; + readonly kind: "MESSAGE_RECEIVED" | "COMMENT_RECEIVED" | "ASSET_STATUS_CHANGED" | "ACTION_REQUIRED"; readonly assetRef: string; readonly messageRef: string | null; /** @enum {string} */ @@ -1348,7 +1348,7 @@ export interface components { readonly safeSummary: string; /** Format: date-time */ readonly createdAt: string; - readonly actions: readonly ("MARK_READ" | "DISMISS" | "REPLY" | "REFRESH")[]; + readonly actions: readonly ("MARK_READ" | "DISMISS" | "REPLY" | "REFRESH" | "SEND_TEMPLATE" | "REPLY_COMMENT" | "SEND_MESSAGE")[]; }; readonly NotificationListPage: { readonly items: readonly components["schemas"]["Notification"][]; @@ -1362,6 +1362,28 @@ export interface components { /** @constant */ readonly action: "REPLY"; readonly replyText: string; + } | { + /** @constant */ + readonly action: "SEND_TEMPLATE"; + readonly templateName: string; + readonly language: string; + readonly parameters: readonly string[]; + } | { + /** @constant */ + readonly action: "REPLY_COMMENT"; + readonly replyText: string; + } | { + /** @constant */ + readonly action: "SEND_MESSAGE"; + readonly messageText: string; + }; + readonly NotificationActionResult: { + readonly notificationId: string; + /** @enum {string} */ + readonly action: "MARK_READ" | "DISMISS"; + /** @enum {string} */ + readonly status: "READ" | "DISMISSED"; + readonly requestId: string; }; readonly NotificationActionAccepted: { readonly notificationId: string; @@ -1370,6 +1392,8 @@ export interface components { readonly status: "PENDING"; readonly location: string; readonly requestId: string; + /** @description Same identifier as jobId; use GET /api/v1/operations/{operationId} for action status. */ + readonly operationId?: string; }; readonly WebhookChallengeResponse: { readonly challenge: string; @@ -3214,10 +3238,19 @@ export interface operations { }; }; readonly responses: { - /** @description Sanitized success example */ + /** @description Local READ/DISMISS state persisted successfully */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["NotificationActionResult"]; + }; + }; + /** @description Durable external action accepted for processing */ readonly 202: { headers: { - /** @description Relative URI of the tenant-scoped accepted job; poll it for the outcome. */ + /** @description Existing tenant-scoped job resource; operationId also identifies GET /api/v1/operations/{operationId}. */ readonly Location?: string; readonly [name: string]: unknown; }; diff --git a/tools/m2-03/README.md b/tools/m2-03/README.md index 4a413f76..c4852957 100644 --- a/tools/m2-03/README.md +++ b/tools/m2-03/README.md @@ -1,6 +1,6 @@ # M2.03 synthetic migration harness -The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–23: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, the M5.03 Coexistence onboarding lock v22, and the M5.05 per-channel asset automation configuration v23. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions. +The harness starts a fresh MariaDB 11.8.8 Compose project for each run. The project name, volume, credentials, generated init SQL, and loopback port are unique to the run. Cleanup targets only that project name. It does not inspect or stop any other Compose project, including the preserved `db-migrations-mariadb-1` container. The integrated registry currently applies ordered schema versions 1–24: M3.04 v5, M3.02 v6, M3.06 v7, M3.03 v8, M3.05 portal-session entry mode v9, M3.05 onboarding sessions v10, M3.07 credential vault v11, M3.10 privacy governance v12, M3.09 credential lifecycle v13, the M3.03 verified-email identity ledger v14, the M3.11 persistent legacy import ledger v15, the M4.03 onboarding saga v16, the M4.10 Meta webhook inbox v17, M4.11 subscription reconciliation v18, append-only M4.12 asset observations v19, M4.13 connection lifecycle v20, the M5.07 internal asset-operation ledger v21, the M5.03 Coexistence onboarding lock v22, the M5.05 per-channel asset automation configuration v23, and the M5.06 tenant notifications and actions v24. Backup/restore checks compare against the compiled registry's contiguous version sequence, so later additive migration versions do not require another hardcoded harness edit; they also verify the ledger is preserved and retain the original v1/v2 definitions. Prerequisites: Node 24.22.2, npm 11.19.1, Docker Engine, Docker Compose v2, and network access to the pinned MariaDB image on its first run. The PowerShell harness verifies those exact versions. It uses `%LOCALAPPDATA%\Codex\runtimes\node-v24.22.2` by default; set `M2_NODE_RUNTIME_DIR` to that runtime directory when it is elsewhere. diff --git a/tools/m5-06/run-focused-api-test.mjs b/tools/m5-06/run-focused-api-test.mjs new file mode 100644 index 00000000..e04f1f05 --- /dev/null +++ b/tools/m5-06/run-focused-api-test.mjs @@ -0,0 +1,45 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +if (Number(process.versions.node.split('.')[0]) !== 24) { + throw new Error(`M5.06 focused API validation requires Node 24; received ${process.version}.`); +} +if (!process.env.npm_execpath) throw new Error('Run this focused harness through the npm workspace script.'); + +const apiRoot = join(root, 'apps', 'api'); +const outputRoot = join(apiRoot, '.test-runs'); +const output = join(outputRoot, `m5-06-${process.pid}-${Date.now()}`); +const safePrefix = `${resolve(outputRoot)}${sep}`; +const compiler = join(root, 'node_modules', 'typescript', 'bin', 'tsc'); + +function run(args) { + const result = spawnSync(process.execPath, args, { cwd: root, stdio: 'inherit', windowsHide: true, env: process.env }); + if (result.error) throw result.error; + return result.status === 0; +} + +try { + await mkdir(outputRoot, { recursive: true }); + await mkdir(output, { recursive: false }); + process.stdout.write(`M5.06 focused API integration — Node ${process.version}\n`); + + if (!run([compiler, '--project', join(apiRoot, 'tsconfig.build.json'), '--pretty', 'false'])) process.exitCode = 1; + if (process.exitCode !== 1 && !run([compiler, '--project', join(apiRoot, 'tsconfig.m5-06.json'), '--outDir', output, '--pretty', 'false'])) process.exitCode = 1; + if (process.exitCode !== 1 && process.env.M5_06_NOTIFICATION_TEST === '1' && process.env.M5_06_MIGRATE === '1') { + if (!run([compiler, '--project', join(apiRoot, 'tsconfig.build.json'), '--pretty', 'false', '--outDir', join(apiRoot, 'dist')]) || + !run([join(apiRoot, 'dist', 'database', 'migrations', 'cli.js'), 'deploy'])) process.exitCode = 1; + } + if (process.exitCode !== 1) { + if (!run(['--test', '--test-concurrency=1', join(output, 'test', 'm5-06-notifications.integration.spec.js'), + join(output, 'test', 'm5-06-notification-action.spec.js')])) process.exitCode = 1; + } +} finally { + const resolvedOutput = resolve(output); + if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-06-`)) { + throw new Error('M5.06 API harness refused to remove output outside its private directory.'); + } + await rm(resolvedOutput, { recursive: true, force: true }); +} diff --git a/tools/m5-06/run-focused-web-tests.mjs b/tools/m5-06/run-focused-web-tests.mjs new file mode 100644 index 00000000..2e5e5861 --- /dev/null +++ b/tools/m5-06/run-focused-web-tests.mjs @@ -0,0 +1,47 @@ +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +if (Number(process.versions.node.split('.')[0]) !== 24) { + throw new Error(`M5.06 focused web validation requires Node 24; received ${process.version}.`); +} +const cli = join(root, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'); +const outputRoot = join(root, 'apps', 'web', '.test-runs'); +await mkdir(outputRoot, { recursive: true }); +const output = join(outputRoot, `m5-06-${process.pid}-${Date.now()}`); +await mkdir(output, { recursive: false }); +const safePrefix = `${resolve(outputRoot)}${sep}`; + +function run(args) { + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: join(root, 'apps', 'web'), stdio: 'inherit', windowsHide: true, env: process.env, + }); + if (result.error) throw result.error; + return result.status === 0; +} + +try { + process.stdout.write(`M5.06 focused Angular specs — Node ${process.version}\n`); + const outputFile = join(output, 'web.junit.xml'); + const commandSucceeded = run([ + 'test', 'meta-integration', '--watch=false', `--output-file=${outputFile}`, + '--include=src/app/features/portal/notifications.component.spec.ts', + ]); + const report = await readFile(outputFile, 'utf8'); + const suites = [...report.matchAll(/]*)>/g)]; + const total = (name) => suites.reduce((sum, entry) => sum + Number(entry[1].match(new RegExp(`\\b${name}="(\\d+)"`))?.[1] ?? 0), 0); + const tests = total('tests'); + const failures = total('failures') + total('errors'); + if (!commandSucceeded || tests === 0 || failures !== 0) { + process.stderr.write(`M5.06 web result: ${tests} test(s), ${failures} failure(s).\n`); + process.exitCode = 1; + } else process.stdout.write(`M5.06 web specs: ${tests} test(s), ${failures} failure(s).\n`); +} finally { + const resolvedOutput = resolve(output); + if (!resolvedOutput.startsWith(safePrefix) || !resolvedOutput.includes(`${sep}m5-06-`)) { + throw new Error('M5.06 web harness refused to remove output outside its private directory.'); + } + await rm(resolvedOutput, { recursive: true, force: true }); +}