Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/database/migrations/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -329,5 +331,6 @@ export const DOMAIN_TABLES = [
...INTERNAL_ASSET_OPERATION_TABLES,
...ONBOARDING_COEXISTENCE_LOCK_TABLES,
...AUTOMATION_CONFIG_TABLES,
...NOTIFICATION_TABLES,
] as const;

139 changes: 139 additions & 0 deletions apps/api/src/database/migrations/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string, { columns: string[]; unique: boolean }>();
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<boolean> {
return await tableExists(context.queryRunner, 'ca_meta_notifications') &&
await tableExists(context.queryRunner, 'ca_meta_notification_action_receipts');
}
1 change: 1 addition & 0 deletions apps/api/src/identity/identity.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type ServiceScope =
| 'TENANT_RESOURCE_READ'
| 'META_CONNECTION_EXECUTE'
| 'CREDENTIAL_USE'
| 'NOTIFICATION_PROJECTION_WRITE'
| 'WEBHOOK_ROUTE'
| 'MAGIC_LINK_DELIVERY';

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/identity/mysql-identity.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const SERVICE_SCOPES = new Set<ServiceScope>([
'TENANT_RESOURCE_READ',
'META_CONNECTION_EXECUTE',
'CREDENTIAL_USE',
'NOTIFICATION_PROJECTION_WRITE',
'WEBHOOK_ROUTE',
'MAGIC_LINK_DELIVERY',
]);
Expand Down
31 changes: 30 additions & 1 deletion apps/api/src/jobs/event-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface OutboxEventEnvelope {
readonly data: Readonly<Record<string, unknown>>;
}

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 }
Expand All @@ -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';
Expand Down Expand Up @@ -112,6 +119,11 @@ export const JOB_POLICIES: Readonly<Record<JobType, JobPolicy>> = 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;
Expand Down Expand Up @@ -200,6 +212,23 @@ const jobPayloadSchemas: Readonly<Record<JobType, object>> = {
},
},
},
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<JobType, ReturnType<typeof ajv.compile>>(
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/jobs/job-execution-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const REQUIRED_JOB_SCOPES: Readonly<Partial<Record<JobType, ServiceScope>>> = Ob
CREDENTIAL_INSPECTION: 'CREDENTIAL_USE',
ASSET_REFRESH: 'META_CONNECTION_EXECUTE',
TEMPLATE_CREATE: 'CREDENTIAL_USE',
NOTIFICATION_ACTION: 'CREDENTIAL_USE',
} as const satisfies Readonly<Partial<Record<JobType, ServiceScope>>>);

function resourceReference(type: JobType, payload: JobPayload): string | undefined {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
}
}
}
1 change: 1 addition & 0 deletions apps/api/src/jobs/job-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/meta-assets/meta-assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@ 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 {
static register(identityModule: DynamicModule, environment: ApiEnvironment): DynamicModule {
return {
module: MetaAssetsModule,
imports: [identityModule, JobsModule.register(environment), PrivacyModule.register(environment)],
controllers: [AssetCatalogController, WhatsAppTemplatesController, AutomationConfigController],
controllers: [AssetCatalogController, WhatsAppTemplatesController, AutomationConfigController, NotificationsController],
providers: [
{
provide: PortalSessionGuard,
Expand Down Expand Up @@ -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 },
{
Expand Down
Loading
Loading