From de32a1d539bd06459d1d8fdadeadc70d3f96405d Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 30 Jun 2026 13:03:22 -0400 Subject: [PATCH 01/15] feat(#272): implement Azure Functions queue trigger handler registration - Add registerAzureFunctionQueueHandler() to Cellix class for Storage Queue triggers - Add applicationServicesFactory.forSystem() for system-scoped passport (no user request context) - Create @ocom/handler-queue-community-update package with queue trigger handler - Add community-update queue schema to @ocom/service-queue-storage - Wire @ocom/handler-queue-community-update into @apps/api Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.github/instructions/api.instructions.md | 70 ++++++-- apps/api/package.json | 1 + apps/api/src/cellix.test.ts | 109 +++++++++++-- apps/api/src/cellix.ts | 71 +++++++- apps/api/src/features/cellix.feature | 20 ++- apps/api/src/index.test.ts | 12 ++ apps/api/src/index.ts | 4 + apps/api/tsconfig.json | 1 + .../src/mock-application-services.ts | 5 +- .../ocom/application-services/src/index.ts | 17 +- .../handler-queue-community-update/.gitignore | 5 + .../package.json | 41 +++++ .../handler-queue-community-update/readme.md | 51 ++++++ .../src/handler.test.ts | 154 ++++++++++++++++++ .../src/handler.ts | 66 ++++++++ .../src/index.ts | 1 + .../tsconfig.json | 10 ++ .../tsconfig.vitest.json | 3 + .../handler-queue-community-update/turbo.json | 3 + .../vitest.config.ts | 13 ++ .../ocom/service-queue-storage/src/index.ts | 1 + .../service-queue-storage/src/registry.ts | 2 + .../inbound/community-update.schema.json | 30 ++++ .../src/schemas/inbound/community-update.ts | 16 ++ .../service-queue-storage/src/service.test.ts | 2 +- pnpm-lock.yaml | 42 ++++- 26 files changed, 714 insertions(+), 36 deletions(-) create mode 100644 packages/ocom/handler-queue-community-update/.gitignore create mode 100644 packages/ocom/handler-queue-community-update/package.json create mode 100644 packages/ocom/handler-queue-community-update/readme.md create mode 100644 packages/ocom/handler-queue-community-update/src/handler.test.ts create mode 100644 packages/ocom/handler-queue-community-update/src/handler.ts create mode 100644 packages/ocom/handler-queue-community-update/src/index.ts create mode 100644 packages/ocom/handler-queue-community-update/tsconfig.json create mode 100644 packages/ocom/handler-queue-community-update/tsconfig.vitest.json create mode 100644 packages/ocom/handler-queue-community-update/turbo.json create mode 100644 packages/ocom/handler-queue-community-update/vitest.config.ts create mode 100644 packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json create mode 100644 packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts diff --git a/apps/api/.github/instructions/api.instructions.md b/apps/api/.github/instructions/api.instructions.md index 4c77747bc..9aedf1f71 100644 --- a/apps/api/.github/instructions/api.instructions.md +++ b/apps/api/.github/instructions/api.instructions.md @@ -11,24 +11,27 @@ The `@ocom/api` package is the main Azure Functions application entry point that ### Cellix Framework - **Central Class**: `Cellix` - The main orchestration class implementing service registry and Azure Functions integration -- **Service Registry Pattern**: All services must be registered via `registerService()` before context creation +- **Service Registry Pattern**: All services must be registered via `registerInfrastructureService()` before context creation - **Context Creation**: Use `setContext()` to build the application context after service registration -- **Handler Registration**: Use `registerAzureFunctionHandler()` to register Azure Functions endpoints +- **HTTP Handler Registration**: Use `registerAzureFunctionHttpHandler()` to register Azure Functions HTTP endpoints +- **Queue Handler Registration**: Use `registerAzureFunctionQueueHandler()` to register Azure Functions Storage Queue trigger endpoints ### Initialization Flow ```typescript -Cellix.initializeServices((serviceRegistry) => { - // Register all services here - serviceRegistry.registerService(new ServiceExample(...)); +Cellix.initializeInfrastructureServices((serviceRegistry) => { + // Register all infrastructure services here + serviceRegistry.registerInfrastructureService(new ServiceExample(...)); }) .setContext((serviceRegistry) => ({ // Build context from registered services - domainDataSource: contextBuilder(serviceRegistry.getService(ServiceExample)) + domainDataSource: contextBuilder(serviceRegistry.getInfrastructureService(ServiceExample)) })) -.then((cellix) => { - // Register Azure Functions handlers - cellix.registerAzureFunctionHandler('name', { route: 'path' }, handlerCreator); -}); +.initializeApplicationServices((context) => buildApplicationServicesFactory(context)) +// Register Azure Functions HTTP handlers +.registerAzureFunctionHttpHandler('name', { route: 'path' }, handlerCreator) +// Register Azure Functions Storage Queue trigger handlers +.registerAzureFunctionQueueHandler('queue-name', { queueName: 'queue-name', connection: 'AzureWebJobsStorage' }, queueHandlerCreator) +.startUp(); ``` ## File Structure @@ -56,23 +59,62 @@ Cellix.initializeServices((serviceRegistry) => { - Use different configs for development vs production ### Azure Functions Integration -- Handler creators must accept context and return `HttpHandler` +- HTTP handler creators must accept `(appServicesHost, infrastructureRegistry)` and return `HttpHandler` +- Queue handler creators must accept `(appServicesHost, infrastructureRegistry)` and return `StorageQueueHandler` - Use descriptive names for function registration -- Configure routes in handler registration, not in individual handlers +- Configure routes/queue names in handler registration, not in individual handlers + +#### HTTP Handler Example +```typescript +cellix.registerAzureFunctionHttpHandler( + 'graphql', + { route: 'graphql/{*segments}', methods: ['GET', 'POST'] }, + (host, infra) => async (req, ctx) => { + const app = await host.forRequest(req.headers.get('authorization') ?? undefined); + return app.GraphQL.handle(req, ctx); + } +); +``` + +#### Queue Handler Example +```typescript +cellix.registerAzureFunctionQueueHandler( + 'my-queue', + { queueName: 'my-queue', connection: 'AzureWebJobsStorage' }, + (host, infra) => async (queueEntry, context) => { + const queueService = infra.getInfrastructureService(ServiceQueueStorage); + // Map Azure Functions trigger metadata to QueueTriggerMetadata + const metadata = { + id: (context.triggerMetadata?.['id'] as string) ?? '', + popReceipt: context.triggerMetadata?.['popReceipt'] as string | undefined, + dequeueCount: context.triggerMetadata?.['dequeueCount'] as number | undefined, + }; + // Validate and decode via the registered queue service + const message = await queueService.receiveFromMyQueue(queueEntry, metadata); + const appServices = await host.forRequest(); + // Process message.payload ... + } +); +``` ### Error Handling - Let Cellix handle service startup/shutdown errors with OpenTelemetry tracing - Use proper error boundaries in individual handlers -- Log errors with context using the built-in tracer +- For queue handlers: catch expected errors (e.g., entity not found) and log them — do **not** rethrow, as requeuing would cause poison-message loops +- Log errors with context using `context.error(...)` provided by the Azure Functions runtime ## Key Dependencies - `@azure/functions` - Azure Functions v4 runtime - `@azure/identity` - Azure authentication - OpenTelemetry integration via `@ocom/service-otel` - Service interfaces from `@cellix/api-services-spec` +- Queue definitions and service from `@ocom/service-queue-storage` ## Development Notes - OpenTelemetry starts automatically on module load - Services have async `startUp()` and `shutDown()` lifecycle methods - Context is immutable once set -- All services must extend `ServiceBase` interface \ No newline at end of file +- All services must extend `ServiceBase` interface +- Queue handlers are registered via `app.storageQueue()` from `@azure/functions` under the hood +- The `connection` field in queue handler options must reference an app setting name (environment variable), not the connection string value itself +- For local development, `AzureWebJobsStorage` maps to the Azurite connection string via `local.settings.json` \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 6467e1ff0..f64a992e9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,6 +36,7 @@ "@ocom/event-handler": "workspace:*", "@ocom/graphql": "workspace:*", "@ocom/graphql-handler": "workspace:*", + "@ocom/handler-queue-community-update": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/rest": "workspace:*", "@ocom/service-apollo-server": "workspace:*", diff --git a/apps/api/src/cellix.test.ts b/apps/api/src/cellix.test.ts index efbe93f40..0f84d0e27 100644 --- a/apps/api/src/cellix.test.ts +++ b/apps/api/src/cellix.test.ts @@ -13,6 +13,7 @@ const test = { for: describeFeature }; vi.mock('@azure/functions', () => ({ app: { http: vi.fn(), + storageQueue: vi.fn(), hook: { appStart: vi.fn(), appTerminate: vi.fn(), @@ -216,7 +217,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService, 'alias-service'); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -293,7 +294,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); When('application services factory is initialized', () => { - const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); expect(result.registerAzureFunctionHttpHandler).toBeDefined(); }); @@ -323,7 +324,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { When('initializeApplicationServices is called', () => { expect(() => { - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); }).toThrow('Context creator must be set before initializing application services'); }); @@ -338,7 +339,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); }); When('an Azure Function HTTP handler is registered', () => { @@ -387,7 +388,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -433,7 +434,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); // Simulate missing context creator Object.defineProperty(cellix, 'contextCreatorInternal', { value: null }); @@ -456,7 +457,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); await cellix.startUp(); }); @@ -476,7 +477,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); await cellix.startUp(); }); @@ -514,7 +515,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -553,7 +554,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); await cellix.startUp(); }); @@ -580,7 +581,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -613,7 +614,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); await cellix.startUp(); }); @@ -637,4 +638,88 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { // The span is mocked, so we can't easily verify this without more complex mocking }); }); + + Scenario('Registering an Azure Function Queue handler', ({ Given, When, Then, And }) => { + Given('a Cellix instance in app-services phase', () => { + cellix = Cellix.initializeInfrastructureServices(() => { + /* no op */ + }) as Cellix; + cellix.setContext(() => ({})); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); + }); + + When('an Azure Function Queue handler is registered', () => { + const result = cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn()); + expect(result).toBe(cellix); + }); + + Then('it should store the queue handler configuration', () => { + // We can't directly test private state, but we can test that startup registers handlers + expect(app.storageQueue).not.toHaveBeenCalled(); // Not called until startup + }); + + And('it should transition to handlers phase', () => { + // Phase transition is internal, but we can test that startup works + expect(cellix.startUp).toBeDefined(); + }); + + And('it should return the registry for chaining', () => { + // Already verified in When step + }); + }); + + Scenario('Registering queue handler in wrong phase', ({ Given, When, Then }) => { + Given('a Cellix instance in infrastructure phase', () => { + cellix = Cellix.initializeInfrastructureServices(() => { + /* no op */ + }) as Cellix; + }); + + When('registerAzureFunctionQueueHandler is called', () => { + expect(() => { + cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn()); + }).toThrow("Invalid operation in phase 'infrastructure'. Allowed phases: app-services, handlers"); + }); + + Then('it should throw an error for invalid phase', () => { + // Error is already thrown in When step + }); + }); + + Scenario('Starting up the application with queue handlers', ({ Given, When, Then, And }) => { + Given('a Cellix instance with both HTTP and queue handlers configured', () => { + cellix = Cellix.initializeInfrastructureServices((registry) => { + registry.registerInfrastructureService(mockService); + }) as Cellix; + cellix.setContext(() => ({})); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); + cellix.registerAzureFunctionHttpHandler('test-http-handler', { authLevel: 'anonymous' }, () => vi.fn()); + cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn()); + }); + + When('startUp is called with queue handlers', async () => { + await cellix.startUp(); + }); + + Then('it should register queue functions with app.storageQueue', () => { + expect(app.storageQueue).toHaveBeenCalledWith( + 'test-queue-handler', + expect.objectContaining({ + queueName: 'test-queue', + connection: 'AzureWebJobsStorage', + handler: expect.any(Function), + }), + ); + }); + + And('it should register HTTP functions with app.http', () => { + expect(app.http).toHaveBeenCalledWith( + 'test-http-handler', + expect.objectContaining({ + authLevel: 'anonymous', + handler: expect.any(Function), + }), + ); + }); + }); }); diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 570a89ed4..3a8855417 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -1,4 +1,4 @@ -import { app, type HttpFunctionOptions, type HttpHandler } from '@azure/functions'; +import { app, type HttpFunctionOptions, type HttpHandler, type StorageQueueFunctionOptions, type StorageQueueHandler } from '@azure/functions'; import type { ServiceBase } from '@cellix/api-services-spec'; import api, { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; @@ -103,6 +103,37 @@ interface AzureFunctionHandlerRegistry, handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, ): AzureFunctionHandlerRegistry; + /** + * Registers an Azure Function Azure Storage Queue trigger endpoint. + * + * @remarks + * The `handlerCreator` is invoked per queue message and receives the application services host and infrastructure registry. + * Use it to create a trigger-scoped handler that processes a single queue entry. + * Registration is allowed in phases `'app-services'` and `'handlers'`. + * + * @typeParam T - The expected message payload type. Defaults to `unknown`. + * @param name - Function name to bind in Azure Functions. + * @param options - Azure Functions storage queue trigger options (excluding the handler). + * @param handlerCreator - Factory that, given the app services host and infrastructure registry, returns a `StorageQueueHandler`. + * @returns The registry (for chaining). + * + * @throws Error - If called before application services are initialized. + * + * @example + * ```ts + * registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, (host, infra) => { + * return async (queueEntry, ctx) => { + * const app = await host.forRequest(); + * await app.Community.Community.updateSettings({ id: queueEntry.communityId }); + * }; + * }); + * ``` + */ + registerAzureFunctionQueueHandler( + name: string, + options: Omit, 'handler'>, + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry; /** * Finalizes configuration and starts the application. * @@ -146,6 +177,7 @@ type UninitializedServiceRegistry type RequestScopedHost = { forRequest(rawAuthHeader?: string, hints?: H): Promise; + forSystem(): Promise; }; type AppHost = RequestScopedHost; @@ -156,6 +188,12 @@ interface PendingHandler { handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; } +interface PendingQueueHandler { + name: string; + options: Omit, 'handler'>; + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; +} + type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'started'; /** @@ -187,6 +225,7 @@ export class Cellix */ private readonly nameMap: Map = new Map(); private readonly pendingHandlers: Array> = []; + private readonly pendingQueueHandlers: Array> = []; private serviceInitializedInternal = false; private phase: Phase = 'infrastructure'; @@ -281,6 +320,21 @@ export class Cellix return this; } + public registerAzureFunctionQueueHandler( + name: string, + options: Omit, 'handler'>, + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry { + this.ensurePhase('app-services', 'handlers'); + this.pendingQueueHandlers.push({ + name, + options: options as Omit, 'handler'>, + handlerCreator: handlerCreator as (appHost: RequestScopedHost, reg: InitializedServiceRegistry) => StorageQueueHandler, + }); + this.phase = 'handlers'; + return this; + } + public startUp(): Promise> { this.ensurePhase('handlers', 'app-services'); if (!this.contextCreatorInternal) { @@ -292,7 +346,7 @@ export class Cellix } private setupLifecycle(): void { - // Register function handlers (deferred execution of creators) + // Register HTTP function handlers (deferred execution of creators) for (const h of this.pendingHandlers) { app.http(h.name, { ...h.options, @@ -305,6 +359,19 @@ export class Cellix }); } + // Register Azure Storage Queue trigger handlers (deferred execution of creators) + for (const h of this.pendingQueueHandlers) { + app.storageQueue(h.name, { + ...h.options, + handler: (queueEntry, context) => { + if (!this.appServicesHostInternal) { + throw new Error('Application not started yet'); + } + return h.handlerCreator(this.appServicesHostInternal, this)(queueEntry, context); + }, + }); + } + // appStart hook app.hook.appStart(async () => { const root = api.context.active(); diff --git a/apps/api/src/features/cellix.feature b/apps/api/src/features/cellix.feature index 7a2a3a8c2..d7e9038de 100644 --- a/apps/api/src/features/cellix.feature +++ b/apps/api/src/features/cellix.feature @@ -123,4 +123,22 @@ Feature: Cellix Application Bootstrap When appTerminate hook executes Then it should record the exception in the span And it should rethrow the error - And it should set span status to ERROR \ No newline at end of file + And it should set span status to ERROR + + Scenario: Registering an Azure Function Queue handler + Given a Cellix instance in app-services phase + When an Azure Function Queue handler is registered + Then it should store the queue handler configuration + And it should transition to handlers phase + And it should return the registry for chaining + + Scenario: Registering queue handler in wrong phase + Given a Cellix instance in infrastructure phase + When registerAzureFunctionQueueHandler is called + Then it should throw an error for invalid phase + + Scenario: Starting up the application with queue handlers + Given a Cellix instance with both HTTP and queue handlers configured + When startUp is called with queue handlers + Then it should register queue functions with app.storageQueue + And it should register HTTP functions with app.http diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index 578b9520d..c2d700300 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -5,6 +5,7 @@ const { setContext, initializeApplicationServices, registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, startUp, initializeInfrastructureServices, registerEventHandlers, @@ -63,6 +64,7 @@ const { setContext: vi.fn(), initializeApplicationServices: vi.fn(), registerAzureFunctionHttpHandler: vi.fn(), + registerAzureFunctionQueueHandler: vi.fn(), startUp: vi.fn(), initializeInfrastructureServices: vi.fn(), registerEventHandlers: vi.fn(), @@ -130,6 +132,9 @@ vi.mock('./service-config/queue-storage/index.ts', () => ({ vi.mock('@ocom/graphql-handler', () => ({ graphHandlerCreator: vi.fn(), })); +vi.mock('@ocom/handler-queue-community-update', () => ({ + communityUpdateQueueHandlerCreator: vi.fn(), +})); vi.mock('@ocom/rest', () => ({ restHandlerCreator: vi.fn(), })); @@ -141,6 +146,7 @@ vi.mock('@ocom/service-queue-storage', () => ({ sendMessageToCommunityCreationQueue: vi.fn(), receiveFromImportRequestsQueue: vi.fn(), peekAtImportRequestsQueue: vi.fn(), + receiveFromCommunityUpdateQueue: vi.fn(), enableLogging: vi.fn(), }; service.enableLogging.mockReturnValue(service); @@ -166,6 +172,12 @@ describe('apps/api bootstrap', () => { }); registerAzureFunctionHttpHandler.mockReturnValue({ registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, + startUp, + }); + registerAzureFunctionQueueHandler.mockReturnValue({ + registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, startUp, }); initializeInfrastructureServices.mockReturnValue({ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 70051f0a6..842ef426b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { RegisterEventHandlers } from '@ocom/event-handler'; import type { GraphContext } from '@ocom/graphql-handler'; import { graphHandlerCreator } from '@ocom/graphql-handler'; +import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; import { restHandlerCreator } from '@ocom/rest'; import { ServiceApolloServer } from '@ocom/service-apollo-server'; import { ServiceBlobStorage, ServiceClientBlobStorage } from '@ocom/service-blob-storage'; @@ -70,4 +71,7 @@ Cellix.initializeInfrastructureServices((se graphHandlerCreator(infrastructureRegistry.getInfrastructureService>(ServiceApolloServer), appServicesFactory), ) .registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator) + .registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, (host, infra) => + communityUpdateQueueHandlerCreator(host, infra.getInfrastructureService(ServiceQueueStorage)), + ) .startUp(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 72c22d78b..6d3291bd7 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../packages/ocom/event-handler" }, { "path": "../../packages/ocom/graphql" }, { "path": "../../packages/ocom/graphql-handler" }, + { "path": "../../packages/ocom/handler-queue-community-update" }, { "path": "../../packages/ocom/persistence" }, { "path": "../../packages/ocom/rest" }, { "path": "../../packages/ocom/service-blob-storage" }, diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 308365adb..eaf8977c2 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -4,8 +4,8 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; -import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { ServiceMongoose } from '@ocom/service-mongoose'; +import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { TokenValidation, TokenValidationResult } from '@ocom/service-token-validation'; import { actors } from '@ocom-verification/verification-shared/test-data'; @@ -139,5 +139,8 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon forRequest: (_rawAuthHeader, hints) => { return mockApplicationServicesFactory.forRequest('Bearer test-token', hints); }, + forSystem: () => { + return mockApplicationServicesFactory.forSystem(); + }, }; } diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index 9e5407886..df6f0071d 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -36,7 +36,7 @@ export type PrincipalHints = { export interface AppServicesHost { forRequest(rawAuthHeader?: string, hints?: PrincipalHints): Promise; - // forSystem: (opts?: unknown) => Promise; + forSystem(): Promise; // forAzureFunction: (opts?: unknown) => Promise; } @@ -80,7 +80,22 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic }; }; + const forSystem = (): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ canManageCommunitySettings: true, isSystemAccount: true }); + const { dataSourcesFactory, blobStorageService, queueStorageService } = context; + const dataSources = dataSourcesFactory.withPassport(systemPassport); + return Promise.resolve({ + Community: Community(dataSources, blobStorageService, queueStorageService), + Service: Service(dataSources), + User: User(dataSources), + get verifiedUser(): VerifiedUser | null { + return null; + }, + }); + }; + return { forRequest, + forSystem, }; }; diff --git a/packages/ocom/handler-queue-community-update/.gitignore b/packages/ocom/handler-queue-community-update/.gitignore new file mode 100644 index 000000000..19af63935 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +coverage +*.log +.DS_Store diff --git a/packages/ocom/handler-queue-community-update/package.json b/packages/ocom/handler-queue-community-update/package.json new file mode 100644 index 000000000..ab498d7f1 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ocom/handler-queue-community-update", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Azure Functions Storage Queue trigger handler for community update messages", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format .", + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "watch": "tsgo --watch", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest", + "clean": "rimraf dist" + }, + "dependencies": { + "@azure/functions": "catalog:", + "@ocom/application-services": "workspace:*", + "@ocom/service-queue-storage": "workspace:*" + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@vitest/coverage-istanbul": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/ocom/handler-queue-community-update/readme.md b/packages/ocom/handler-queue-community-update/readme.md new file mode 100644 index 000000000..3199a9698 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/readme.md @@ -0,0 +1,51 @@ +# @ocom/handler-queue-community-update + +Azure Functions Storage Queue trigger handler for community update messages in the Cellix framework. + +## Purpose + +This package provides the Azure Functions Storage Queue trigger handler that processes `community-update` queue messages. It integrates with `@ocom/service-queue-storage` for message validation and deserialization, and with `@ocom/application-services` to apply community settings updates. + +## Exports + +- `communityUpdateQueueHandlerCreator` — factory function that returns an Azure Functions `StorageQueueHandler` for the `community-update` queue. + +## Usage + +### Creating and registering the handler + +```typescript +import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; +import { ServiceQueueStorage } from '@ocom/service-queue-storage'; + +Cellix + .initializeInfrastructureServices(...) + .setContext(...) + .initializeApplicationServices(...) + .registerAzureFunctionQueueHandler( + 'community-update', + { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, + (host, infra) => + communityUpdateQueueHandlerCreator( + host, + infra.getInfrastructureService(ServiceQueueStorage), + ), + ) + .startUp(); +``` + +### Handler behaviour + +On each queue trigger: + +1. Extracts trigger metadata (`id`, `popReceipt`, `dequeueCount`) from the Azure Functions context. +2. Validates and deserializes the raw queue entry via `@ocom/service-queue-storage` `receiveFromCommunityUpdateQueue`. +3. Creates a request-scoped application services instance via the `ApplicationServicesFactory`. +4. Calls `Community.updateSettings` with the payload fields (`name`, `domain`, `whiteLabelDomain`, `handle`), including only the fields present in the message. +5. Logs and swallows `community not found` errors; rethrows all other errors so the Functions runtime retries the message. + +## Related packages + +- `@ocom/service-queue-storage` — queue registry, schema validation, and typed message deserialization. +- `@ocom/application-services` — application services factory providing request-scoped services. +- `apps/api` — composition root that wires this handler into the Azure Functions host via Cellix. diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts new file mode 100644 index 000000000..b592ae951 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -0,0 +1,154 @@ +import type { InvocationContext } from '@azure/functions'; +import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; +import type { QueueStorageOperations } from '@ocom/service-queue-storage'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { communityUpdateQueueHandlerCreator } from './handler.ts'; + +function makeMockApplicationServicesFactory(updateSettings = vi.fn().mockResolvedValue(undefined)) { + const appServices = { + Community: { + Community: { + updateSettings, + }, + }, + } as unknown as ApplicationServices; + return { + forSystem: vi.fn().mockResolvedValue(appServices), + updateSettings, + }; +} + +function makeMockQueueService(receiveResult?: object) { + return { + receiveFromCommunityUpdateQueue: vi.fn().mockResolvedValue( + receiveResult ?? { + id: 'msg-1', + payload: { + communityId: 'community-abc', + name: 'Test Community', + domain: 'test.example.com', + whiteLabelDomain: null, + handle: null, + }, + }, + ), + } as unknown as QueueStorageOperations; +} + +function makeMockInvocationContext(): InvocationContext { + return { + error: vi.fn(), + triggerMetadata: { + id: 'trigger-id-1', + popReceipt: 'pop-receipt-1', + dequeueCount: 1, + }, + } as unknown as InvocationContext; +} + +describe('communityUpdateQueueHandlerCreator', () => { + let factory: ReturnType; + let queueService: QueueStorageOperations; + let context: InvocationContext; + + beforeEach(() => { + vi.clearAllMocks(); + factory = makeMockApplicationServicesFactory(); + queueService = makeMockQueueService(); + context = makeMockInvocationContext(); + }); + + describe('handler creation', () => { + it('returns a function (the StorageQueueHandler)', () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + expect(typeof handler).toBe('function'); + }); + }); + + describe('handler invocation', () => { + it('calls receiveFromCommunityUpdateQueue with payload and trigger metadata', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + const queueEntry = { communityId: 'community-abc' }; + + await handler(queueEntry, context); + + expect(queueService.receiveFromCommunityUpdateQueue).toHaveBeenCalledWith(queueEntry, { + id: 'trigger-id-1', + popReceipt: 'pop-receipt-1', + dequeueCount: 1, + }); + }); + + it('calls updateSettings with the message payload fields', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler({ communityId: 'community-abc' }, context); + + expect(factory.updateSettings).toHaveBeenCalledWith({ + id: 'community-abc', + name: 'Test Community', + domain: 'test.example.com', + whiteLabelDomain: null, + handle: null, + }); + }); + + it('calls forSystem with no arguments (system queue trigger)', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler({ communityId: 'community-abc' }, context); + + expect(factory.forSystem).toHaveBeenCalledWith(); + }); + + it('defaults triggerMetadata id to empty string when not present', async () => { + const ctx = { + error: vi.fn(), + triggerMetadata: undefined, + } as unknown as InvocationContext; + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler({ communityId: 'community-abc' }, ctx); + + expect(queueService.receiveFromCommunityUpdateQueue).toHaveBeenCalledWith(expect.anything(), { + id: '', + popReceipt: undefined, + dequeueCount: undefined, + }); + }); + }); + + describe('error handling', () => { + it('logs an error and returns (does not rethrow) when receiveFromCommunityUpdateQueue throws', async () => { + vi.mocked(queueService.receiveFromCommunityUpdateQueue).mockRejectedValue(new Error('JSON parse error')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler({ bad: 'payload' } as unknown as { communityId: string }, context)).resolves.toBeUndefined(); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('invalid message payload')); + expect(factory.forSystem).not.toHaveBeenCalled(); + }); + + it('logs an error and returns (does not rethrow) when community is not found', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new Error('Community not found'))); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler({ communityId: 'missing-id' }, context)).resolves.toBeUndefined(); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('community not found')); + }); + + it('rethrows errors from updateSettings that are not "community not found"', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new Error('Database connection failed'))); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler({ communityId: 'community-abc' }, context)).rejects.toThrow('Database connection failed'); + expect(context.error).not.toHaveBeenCalled(); + }); + + it('rethrows non-Error throwables from updateSettings', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue('unexpected string error')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler({ communityId: 'community-abc' }, context)).rejects.toBe('unexpected string error'); + }); + }); +}); diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts new file mode 100644 index 000000000..231d57210 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -0,0 +1,66 @@ +import type { StorageQueueHandler } from '@azure/functions'; +import type { ApplicationServicesFactory } from '@ocom/application-services'; +import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; + +/** + * Creates an Azure Functions Storage Queue handler for processing community update messages. + * + * @param applicationServicesFactory - Factory for building system-scoped application services + * @param queueService - Queue storage operations for receiving and validating messages + * @returns An Azure Functions StorageQueueHandler for the community-update queue + * + * @example + * ```ts + * import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; + * + * Cellix + * .initializeInfrastructureServices(...) + * .setContext(...) + * .initializeApplicationServices(...) + * .registerAzureFunctionQueueHandler( + * 'community-update', + * { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, + * (host, infra) => communityUpdateQueueHandlerCreator(host, infra.getInfrastructureService(ServiceQueueStorage)), + * ) + * .startUp(); + * ``` + */ +export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: ApplicationServicesFactory, queueService: QueueStorageOperations): StorageQueueHandler => { + return async (queueEntry, context) => { + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const id = (context.triggerMetadata?.['id'] as string) ?? ''; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const popReceipt = context.triggerMetadata?.['popReceipt'] as string | undefined; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const dequeueCount = context.triggerMetadata?.['dequeueCount'] as number | undefined; + const metadata = { + id, + ...(popReceipt === undefined ? {} : { popReceipt }), + ...(dequeueCount === undefined ? {} : { dequeueCount }), + }; + let message: Awaited>; + try { + message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); + } catch (err) { + context.error(`community-update: invalid message payload: ${err instanceof Error ? err.message : String(err)}`); + return; + } + const appServices = await applicationServicesFactory.forSystem(); + const { communityId, name, domain, whiteLabelDomain, handle } = message.payload; + try { + await appServices.Community.Community.updateSettings({ + id: communityId, + ...(name === undefined ? {} : { name }), + ...(domain === undefined ? {} : { domain }), + ...(whiteLabelDomain === undefined ? {} : { whiteLabelDomain }), + ...(handle === undefined ? {} : { handle }), + }); + } catch (err) { + if (err instanceof Error && err.message.toLowerCase().includes('community not found')) { + context.error(`community-update: community not found: ${communityId}`); + return; + } + throw err; + } + }; +}; diff --git a/packages/ocom/handler-queue-community-update/src/index.ts b/packages/ocom/handler-queue-community-update/src/index.ts new file mode 100644 index 000000000..3f09dde21 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/index.ts @@ -0,0 +1 @@ +export { communityUpdateQueueHandlerCreator } from './handler.ts'; diff --git a/packages/ocom/handler-queue-community-update/tsconfig.json b/packages/ocom/handler-queue-community-update/tsconfig.json new file mode 100644 index 000000000..b010bf4e4 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../application-services" }, { "path": "../service-queue-storage" }] +} diff --git a/packages/ocom/handler-queue-community-update/tsconfig.vitest.json b/packages/ocom/handler-queue-community-update/tsconfig.vitest.json new file mode 100644 index 000000000..4f806efbc --- /dev/null +++ b/packages/ocom/handler-queue-community-update/tsconfig.vitest.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"] +} diff --git a/packages/ocom/handler-queue-community-update/turbo.json b/packages/ocom/handler-queue-community-update/turbo.json new file mode 100644 index 000000000..5f90b32dd --- /dev/null +++ b/packages/ocom/handler-queue-community-update/turbo.json @@ -0,0 +1,3 @@ +{ + "extends": ["//"] +} diff --git a/packages/ocom/handler-queue-community-update/vitest.config.ts b/packages/ocom/handler-queue-community-update/vitest.config.ts new file mode 100644 index 000000000..8d3110ee1 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/vitest.config.ts @@ -0,0 +1,13 @@ +import { nodeConfig } from '@cellix/config-vitest'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig( + nodeConfig, + defineConfig({ + test: { + coverage: { + exclude: ['**/index.ts'], + }, + }, + }), +); diff --git a/packages/ocom/service-queue-storage/src/index.ts b/packages/ocom/service-queue-storage/src/index.ts index 4bd7d04e7..46e341e98 100644 --- a/packages/ocom/service-queue-storage/src/index.ts +++ b/packages/ocom/service-queue-storage/src/index.ts @@ -1,5 +1,6 @@ export type { QueueLoggingConfig } from '@cellix/service-queue-storage'; export type { QueueStorageOperations } from './queue-storage.contract.ts'; export { ServiceQueueStorage } from './registry.ts'; +export type { CommunityUpdatePayload } from './schemas/inbound/community-update.ts'; export type { EndUserUpdatePayload } from './schemas/inbound/end-user-update.ts'; export type { CommunityCreationPayload } from './schemas/outbound/community-creation.ts'; diff --git a/packages/ocom/service-queue-storage/src/registry.ts b/packages/ocom/service-queue-storage/src/registry.ts index aa822521c..457ea429a 100644 --- a/packages/ocom/service-queue-storage/src/registry.ts +++ b/packages/ocom/service-queue-storage/src/registry.ts @@ -1,4 +1,5 @@ import { createRegisteredQueueService, registerQueues } from '@cellix/service-queue-storage'; +import { communityUpdateQueue } from './schemas/inbound/community-update.ts'; import { endUserUpdateQueue } from './schemas/inbound/end-user-update.ts'; import { communityCreationQueue } from './schemas/outbound/community-creation.ts'; @@ -8,6 +9,7 @@ const outboundQueues = { const inboundQueues = { endUserUpdate: endUserUpdateQueue, + communityUpdate: communityUpdateQueue, }; const queues = registerQueues({ diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json new file mode 100644 index 000000000..e622c8253 --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommunityUpdatePayload", + "description": "Message for updating settings on an existing community", + "type": "object", + "properties": { + "communityId": { + "type": "string", + "description": "Identifier of the community to update (required for lookup)" + }, + "name": { + "type": "string", + "description": "Updated community name" + }, + "domain": { + "type": "string", + "description": "Updated community domain" + }, + "whiteLabelDomain": { + "type": ["string", "null"], + "description": "Updated white label domain (null to clear)" + }, + "handle": { + "type": ["string", "null"], + "description": "Updated community handle (null to clear)" + } + }, + "required": ["communityId"], + "additionalProperties": false +} diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts new file mode 100644 index 000000000..f97d74c6b --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts @@ -0,0 +1,16 @@ +import { defineQueue } from '@cellix/service-queue-storage'; +import { type Schema as CommunityUpdatePayload, schema as communityUpdateSchema } from './community-update.schema.generated.ts'; + +export type { CommunityUpdatePayload }; + +export const communityUpdateQueue = defineQueue()(({ $payload }) => ({ + queueName: 'community-update', + schema: communityUpdateSchema, + loggingTags: { + domain: 'community', + communityId: $payload.communityId, + }, + loggingMetadata: { + updateType: 'community-settings', + }, +})); diff --git a/packages/ocom/service-queue-storage/src/service.test.ts b/packages/ocom/service-queue-storage/src/service.test.ts index 937a8b38f..07143de64 100644 --- a/packages/ocom/service-queue-storage/src/service.test.ts +++ b/packages/ocom/service-queue-storage/src/service.test.ts @@ -15,7 +15,7 @@ describe('ServiceQueueStorage', () => { }; expect(serviceWithOptions.options).toMatchObject({ - provisionQueues: ['community-creation', 'end-user-update'], + provisionQueues: ['community-creation', 'end-user-update', 'community-update'], }); }, 10000); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf5862390..2e97ae78b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: '@ocom/graphql-handler': specifier: workspace:* version: link:../../packages/ocom/graphql-handler + '@ocom/handler-queue-community-update': + specifier: workspace:* + version: link:../../packages/ocom/handler-queue-community-update '@ocom/persistence': specifier: workspace:* version: link:../../packages/ocom/persistence @@ -1762,6 +1765,37 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/handler-queue-community-update: + dependencies: + '@azure/functions': + specifier: 'catalog:' + version: 4.11.0(patch_hash=69772ce521bf6df67d814ff4f419f19b5e966a41c4ce80b5938143ad628e5645) + '@ocom/application-services': + specifier: workspace:* + version: link:../application-services + '@ocom/service-queue-storage': + specifier: workspace:* + version: link:../service-queue-storage + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../../cellix/config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../../cellix/config-vitest + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/persistence: dependencies: '@cellix/domain-seedwork': @@ -15966,7 +16000,7 @@ snapshots: '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.2.0) - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) '@cucumber/messages': 32.2.0 '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0) '@cucumber/tag-expressions': 9.1.0 @@ -16002,7 +16036,7 @@ snapshots: '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) '@cucumber/messages': 32.2.0 commander: 14.0.0 source-map-support: 0.5.21 @@ -16051,9 +16085,9 @@ snapshots: luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0)': + '@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 mime: 3.0.0 '@cucumber/messages@26.0.1': From 420cfdeafafc790b59e4e17399be6face4b0423a Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 30 Jun 2026 14:26:48 -0400 Subject: [PATCH 02/15] fix: update brace-expansion and fast-uri versions in workspace overrides to resolve snyk vulnerabilities --- pnpm-lock.yaml | 37 +++++++++++-------------------------- pnpm-workspace.yaml | 6 ++---- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e97ae78b..b748f3d4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion@1.1.12: 1.1.13 - brace-expansion@5.0.4: 5.0.6 - brace-expansion@5.0.5: 5.0.6 + brace-expansion: ^5.0.7 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -162,7 +160,7 @@ overrides: postcss: 8.5.10 protobufjs: 7.6.3 ip-address: ^10.1.1 - fast-uri: ^3.1.2 + fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 js-yaml@4.1.1: 4.2.0 @@ -7852,14 +7850,11 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -8292,9 +8287,6 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -9080,8 +9072,8 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@4.1.0: + resolution: {integrity: sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -20171,7 +20163,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 4.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -20627,16 +20619,11 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -21093,8 +21080,6 @@ snapshots: compute-scroll-into-view@3.1.1: {} - concat-map@0.0.1: {} - concurrently@9.2.1: dependencies: chalk: 4.1.2 @@ -22018,7 +22003,7 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-uri@3.1.2: {} + fast-uri@4.1.0: {} fast-xml-builder@1.2.0: dependencies: @@ -24206,11 +24191,11 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 5.0.7 minimatch@9.0.9: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0f3e8f83e..7203acbc5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -80,9 +80,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - 'brace-expansion@1.1.12': 1.1.13 - 'brace-expansion@5.0.4': 5.0.6 - 'brace-expansion@5.0.5': 5.0.6 + brace-expansion: ^5.0.7 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -113,7 +111,7 @@ overrides: postcss: 8.5.10 protobufjs: 7.6.3 ip-address: ^10.1.1 - fast-uri: ^3.1.2 + fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 'js-yaml@4.1.1': 4.2.0 From 655c0f15d88380865fb3bdfbc0412168b2a9a7a6 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 30 Jun 2026 14:37:16 -0400 Subject: [PATCH 03/15] fix(#289): address Sourcery code review feedback - Make PendingQueueHandler generic; use any[] to store heterogeneous handlers without per-field unsafe casts - Add CommunityNotFoundError class to @ocom/application-services; replace brittle string-match in handler with instanceof check - Export communityUpdateQueueName constant from @ocom/service-queue-storage; use it in @apps/api to eliminate duplicated queue name string - Add explicit QueueTriggerMetadata type annotation on metadata object; retain conditional spread (required by exactOptionalPropertyTypes: true) - Export QueueTriggerMetadata from @ocom/service-queue-storage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/api/src/cellix.ts | 15 ++++++--------- apps/api/src/index.ts | 4 ++-- .../src/contexts/community/community/index.ts | 1 + .../community/community/update-settings.ts | 11 +++++++++-- .../src/contexts/community/index.ts | 1 + packages/ocom/application-services/src/index.ts | 1 + .../handler-queue-community-update/src/handler.ts | 8 +++++--- packages/ocom/service-queue-storage/src/index.ts | 3 ++- .../src/schemas/inbound/community-update.ts | 4 +++- 9 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 3a8855417..888a9a163 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -188,10 +188,10 @@ interface PendingHandler { handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; } -interface PendingQueueHandler { +interface PendingQueueHandler { name: string; - options: Omit, 'handler'>; - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; + options: Omit, 'handler'>; + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; } type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'started'; @@ -225,7 +225,8 @@ export class Cellix */ private readonly nameMap: Map = new Map(); private readonly pendingHandlers: Array> = []; - private readonly pendingQueueHandlers: Array> = []; + // biome-ignore lint/suspicious/noExplicitAny: each queue handler captures a distinct T; any allows heterogeneous handlers in one array without unsafe per-field casts + private readonly pendingQueueHandlers: Array> = []; private serviceInitializedInternal = false; private phase: Phase = 'infrastructure'; @@ -326,11 +327,7 @@ export class Cellix handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, ): AzureFunctionHandlerRegistry { this.ensurePhase('app-services', 'handlers'); - this.pendingQueueHandlers.push({ - name, - options: options as Omit, 'handler'>, - handlerCreator: handlerCreator as (appHost: RequestScopedHost, reg: InitializedServiceRegistry) => StorageQueueHandler, - }); + this.pendingQueueHandlers.push({ name, options, handlerCreator }); this.phase = 'handlers'; return this; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 842ef426b..ba7f621c3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,7 +11,7 @@ import { restHandlerCreator } from '@ocom/rest'; import { ServiceApolloServer } from '@ocom/service-apollo-server'; import { ServiceBlobStorage, ServiceClientBlobStorage } from '@ocom/service-blob-storage'; import { ServiceMongoose } from '@ocom/service-mongoose'; -import { ServiceQueueStorage } from '@ocom/service-queue-storage'; +import { ServiceQueueStorage, communityUpdateQueueName } from '@ocom/service-queue-storage'; import { ServiceTokenValidation } from '@ocom/service-token-validation'; import { Cellix } from './cellix.ts'; import * as ApolloServerConfig from './service-config/apollo-server/index.ts'; @@ -71,7 +71,7 @@ Cellix.initializeInfrastructureServices((se graphHandlerCreator(infrastructureRegistry.getInfrastructureService>(ServiceApolloServer), appServicesFactory), ) .registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator) - .registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, (host, infra) => + .registerAzureFunctionQueueHandler(communityUpdateQueueName, { queueName: communityUpdateQueueName, connection: 'AzureWebJobsStorage' }, (host, infra) => communityUpdateQueueHandlerCreator(host, infra.getInfrastructureService(ServiceQueueStorage)), ) .startUp(); diff --git a/packages/ocom/application-services/src/contexts/community/community/index.ts b/packages/ocom/application-services/src/contexts/community/community/index.ts index 945af787d..0be616206 100644 --- a/packages/ocom/application-services/src/contexts/community/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/community/index.ts @@ -8,6 +8,7 @@ import { type CommunityQueryByIdCommand, queryById } from './query-by-id.ts'; import { type CommunityUpdateSettingsCommand, updateSettings } from './update-settings.ts'; export type { CommunityUpdateSettingsCommand }; +export { CommunityNotFoundError } from './update-settings.ts'; export interface CommunityApplicationService { create: (command: CommunityCreateCommand) => Promise; diff --git a/packages/ocom/application-services/src/contexts/community/community/update-settings.ts b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts index 65149a5ca..08d1fc219 100644 --- a/packages/ocom/application-services/src/contexts/community/community/update-settings.ts +++ b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts @@ -1,6 +1,13 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; +export class CommunityNotFoundError extends Error { + constructor(id: string) { + super(`Community not found for id: ${id}`); + this.name = 'CommunityNotFoundError'; + } +} + export interface CommunityUpdateSettingsCommand { id: string; name?: string; @@ -15,7 +22,7 @@ export const updateSettings = (dataSources: DataSources) => { await dataSources.domainDataSource.Community.Community.CommunityUnitOfWork.withScopedTransaction(async (repo) => { const community = await repo.get(command.id); if (!community) { - throw new Error(`Community not found for id ${command.id}`); + throw new CommunityNotFoundError(command.id); } if (command.name !== undefined) { @@ -34,7 +41,7 @@ export const updateSettings = (dataSources: DataSources) => { communityToReturn = await repo.save(community); }); if (!communityToReturn) { - throw new Error('community not found'); + throw new CommunityNotFoundError(command.id); } return communityToReturn; }; diff --git a/packages/ocom/application-services/src/contexts/community/index.ts b/packages/ocom/application-services/src/contexts/community/index.ts index 242066634..647db11e8 100644 --- a/packages/ocom/application-services/src/contexts/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/index.ts @@ -6,6 +6,7 @@ import { Member as MemberApi, type MemberApplicationService } from './member/ind import { Role as RoleApi, type RoleContext } from './role/index.ts'; export type { CommunityUpdateSettingsCommand } from './community/index.ts'; +export { CommunityNotFoundError } from './community/index.ts'; export interface CommunityContextApplicationService { Community: CommunityApplicationService; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index df6f0071d..d1aecd8b9 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -5,6 +5,7 @@ import { Service, type ServiceContextApplicationService } from './contexts/servi import { User, type UserContextApplicationService } from './contexts/user/index.ts'; export type { CommunityUpdateSettingsCommand } from './contexts/community/index.ts'; +export { CommunityNotFoundError } from './contexts/community/index.ts'; export interface ApplicationServices { Community: CommunityContextApplicationService; diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index 231d57210..074ee360e 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -1,6 +1,7 @@ import type { StorageQueueHandler } from '@azure/functions'; import type { ApplicationServicesFactory } from '@ocom/application-services'; -import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; +import { CommunityNotFoundError } from '@ocom/application-services'; +import type { CommunityUpdatePayload, QueueStorageOperations, QueueTriggerMetadata } from '@ocom/service-queue-storage'; /** * Creates an Azure Functions Storage Queue handler for processing community update messages. @@ -33,7 +34,8 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A const popReceipt = context.triggerMetadata?.['popReceipt'] as string | undefined; // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access const dequeueCount = context.triggerMetadata?.['dequeueCount'] as number | undefined; - const metadata = { + // exactOptionalPropertyTypes: omit keys whose values are undefined rather than passing explicit undefined + const metadata: QueueTriggerMetadata = { id, ...(popReceipt === undefined ? {} : { popReceipt }), ...(dequeueCount === undefined ? {} : { dequeueCount }), @@ -56,7 +58,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A ...(handle === undefined ? {} : { handle }), }); } catch (err) { - if (err instanceof Error && err.message.toLowerCase().includes('community not found')) { + if (err instanceof CommunityNotFoundError) { context.error(`community-update: community not found: ${communityId}`); return; } diff --git a/packages/ocom/service-queue-storage/src/index.ts b/packages/ocom/service-queue-storage/src/index.ts index 46e341e98..ef8e5ba12 100644 --- a/packages/ocom/service-queue-storage/src/index.ts +++ b/packages/ocom/service-queue-storage/src/index.ts @@ -1,6 +1,7 @@ -export type { QueueLoggingConfig } from '@cellix/service-queue-storage'; +export type { QueueLoggingConfig, QueueTriggerMetadata } from '@cellix/service-queue-storage'; export type { QueueStorageOperations } from './queue-storage.contract.ts'; export { ServiceQueueStorage } from './registry.ts'; export type { CommunityUpdatePayload } from './schemas/inbound/community-update.ts'; +export { communityUpdateQueueName } from './schemas/inbound/community-update.ts'; export type { EndUserUpdatePayload } from './schemas/inbound/end-user-update.ts'; export type { CommunityCreationPayload } from './schemas/outbound/community-creation.ts'; diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts index f97d74c6b..2e5194a5e 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts @@ -3,8 +3,10 @@ import { type Schema as CommunityUpdatePayload, schema as communityUpdateSchema export type { CommunityUpdatePayload }; +export const communityUpdateQueueName = 'community-update' as const; + export const communityUpdateQueue = defineQueue()(({ $payload }) => ({ - queueName: 'community-update', + queueName: communityUpdateQueueName, schema: communityUpdateSchema, loggingTags: { domain: 'community', From dbcdeed3b6a972509291d3a7b4b4594a80aebbf4 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 1 Jul 2026 09:12:35 -0400 Subject: [PATCH 04/15] fix: update Node.js version to 22.22.2 in deploy-docs.yml --- apps/docs/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/deploy-docs.yml b/apps/docs/deploy-docs.yml index f2bdbe375..9601ffa60 100644 --- a/apps/docs/deploy-docs.yml +++ b/apps/docs/deploy-docs.yml @@ -13,7 +13,7 @@ jobs: persistCredentials: true - task: UseNode@1 inputs: - version: '22.x' + version: '22.22.2' displayName: 'Install Node.js' - task: Cache@2 displayName: 'PNPM: Restore Cache' From b6247df45a68307a1fd56a317dddc795a1d376b2 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 1 Jul 2026 10:28:39 -0400 Subject: [PATCH 05/15] fix(#289): update handler test mock to throw CommunityNotFoundError Test was throwing plain Error; handler now checks instanceof CommunityNotFoundError so the mock must use the typed error class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ocom/handler-queue-community-update/src/handler.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts index b592ae951..1045894f9 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.test.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -1,5 +1,6 @@ import type { InvocationContext } from '@azure/functions'; import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; +import { CommunityNotFoundError } from '@ocom/application-services'; import type { QueueStorageOperations } from '@ocom/service-queue-storage'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { communityUpdateQueueHandlerCreator } from './handler.ts'; @@ -129,7 +130,7 @@ describe('communityUpdateQueueHandlerCreator', () => { }); it('logs an error and returns (does not rethrow) when community is not found', async () => { - factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new Error('Community not found'))); + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new CommunityNotFoundError('missing-id'))); const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); await expect(handler({ communityId: 'missing-id' }, context)).resolves.toBeUndefined(); From 7675ae128a80d7f3b292bd7093d55f21be68a890 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 1 Jul 2026 11:48:51 -0400 Subject: [PATCH 06/15] fix(#289): add communityUpdateQueueName to service-queue-storage vi.mock The manual mock was missing the new constant export, causing index.test.ts to fail when index.ts consumed communityUpdateQueueName at import time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/api/src/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index c2d700300..dd0ebafa2 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -139,6 +139,7 @@ vi.mock('@ocom/rest', () => ({ restHandlerCreator: vi.fn(), })); vi.mock('@ocom/service-queue-storage', () => ({ + communityUpdateQueueName: 'community-update', ServiceQueueStorage: vi.fn(function MockServiceQueueStorage() { const service = { startUp: vi.fn(), From 92c9610d2f716b3c7c6e7a5bdc2ccdfc34bc7df5 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 1 Jul 2026 14:33:54 -0400 Subject: [PATCH 07/15] fix(#289): disable pnpm verify-deps-before-run to prevent concurrent install conflicts in CI pnpm 11 defaults to verify-deps-before-run=install, causing each concurrent turbo task to trigger pnpm install when workspace state is stale. Parallel workers SIGINT each other, killing tasks like @ocom/service-queue-storage#gen. CI already runs 'pnpm install --frozen-lockfile' before turbo tasks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .npmrc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..fc360f8f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Disable pnpm 11's auto-install on script runs. CI explicitly runs +# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs +# from parallel turbo workers kill each other with SIGINT. +verify-deps-before-run=false From 14758a25be643ed46d589b83bdb137cb074e3785 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Thu, 2 Jul 2026 13:02:12 -0400 Subject: [PATCH 08/15] fix(#289): use communityUpdateQueueName constant in log message prefixes Replace hardcoded 'community-update:' string literals with the imported communityUpdateQueueName constant so logs stay in sync with config if the queue name ever changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/ocom/handler-queue-community-update/src/handler.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index 074ee360e..b588327c9 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -1,6 +1,7 @@ import type { StorageQueueHandler } from '@azure/functions'; import type { ApplicationServicesFactory } from '@ocom/application-services'; import { CommunityNotFoundError } from '@ocom/application-services'; +import { communityUpdateQueueName } from '@ocom/service-queue-storage'; import type { CommunityUpdatePayload, QueueStorageOperations, QueueTriggerMetadata } from '@ocom/service-queue-storage'; /** @@ -44,7 +45,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A try { message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); } catch (err) { - context.error(`community-update: invalid message payload: ${err instanceof Error ? err.message : String(err)}`); + context.error(`${communityUpdateQueueName}: invalid message payload: ${err instanceof Error ? err.message : String(err)}`); return; } const appServices = await applicationServicesFactory.forSystem(); @@ -59,7 +60,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A }); } catch (err) { if (err instanceof CommunityNotFoundError) { - context.error(`community-update: community not found: ${communityId}`); + context.error(`${communityUpdateQueueName}: community not found: ${communityId}`); return; } throw err; From fe27f9726f8398cbf123ffc14182ff077195892e Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Thu, 2 Jul 2026 14:43:50 -0400 Subject: [PATCH 09/15] fix(#289): pass original error object to context.error for better diagnostics Log both the formatted message string and the original error so stack traces and structured error fields are preserved in Application Insights. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ocom/handler-queue-community-update/src/handler.test.ts | 4 ++-- packages/ocom/handler-queue-community-update/src/handler.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts index 1045894f9..62eb3e582 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.test.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -125,7 +125,7 @@ describe('communityUpdateQueueHandlerCreator', () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); await expect(handler({ bad: 'payload' } as unknown as { communityId: string }, context)).resolves.toBeUndefined(); - expect(context.error).toHaveBeenCalledWith(expect.stringContaining('invalid message payload')); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('invalid message payload'), expect.any(Error)); expect(factory.forSystem).not.toHaveBeenCalled(); }); @@ -134,7 +134,7 @@ describe('communityUpdateQueueHandlerCreator', () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); await expect(handler({ communityId: 'missing-id' }, context)).resolves.toBeUndefined(); - expect(context.error).toHaveBeenCalledWith(expect.stringContaining('community not found')); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('community not found'), expect.any(CommunityNotFoundError)); }); it('rethrows errors from updateSettings that are not "community not found"', async () => { diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index b588327c9..edf89c06e 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -45,7 +45,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A try { message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); } catch (err) { - context.error(`${communityUpdateQueueName}: invalid message payload: ${err instanceof Error ? err.message : String(err)}`); + context.error(`${communityUpdateQueueName}: invalid message payload: ${err instanceof Error ? err.message : String(err)}`, err); return; } const appServices = await applicationServicesFactory.forSystem(); @@ -60,7 +60,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A }); } catch (err) { if (err instanceof CommunityNotFoundError) { - context.error(`${communityUpdateQueueName}: community not found: ${communityId}`); + context.error(`${communityUpdateQueueName}: community not found: ${communityId}`, err); return; } throw err; From ccf49820c067b397f6e64c4d351e1e1406c4eefd Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 7 Jul 2026 09:29:00 -0400 Subject: [PATCH 10/15] feat: enhance system-scoped service handling with least privilege permissions --- apps/api/.github/instructions/api.instructions.md | 4 +++- apps/api/src/cellix.ts | 9 +++++++-- .../acceptance-api/src/mock-application-services.ts | 4 ++-- packages/ocom/application-services/src/index.ts | 13 ++++++++++--- .../ocom/domain/src/domain/contexts/passport.ts | 2 ++ packages/ocom/domain/src/domain/index.ts | 2 +- .../src/handler.test.ts | 4 ++-- .../handler-queue-community-update/src/handler.ts | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 10 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/api/.github/instructions/api.instructions.md b/apps/api/.github/instructions/api.instructions.md index 9aedf1f71..519ac01ad 100644 --- a/apps/api/.github/instructions/api.instructions.md +++ b/apps/api/.github/instructions/api.instructions.md @@ -91,7 +91,9 @@ cellix.registerAzureFunctionQueueHandler( }; // Validate and decode via the registered queue service const message = await queueService.receiveFromMyQueue(queueEntry, metadata); - const appServices = await host.forRequest(); + // Queue triggers have no request/auth context, so use forSystem() rather than forRequest(). + // Pass only the specific permissions this operation needs (least privilege). + const appServices = await host.forSystem({ canManageMyResource: true }); // Process message.payload ... } ); diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 888a9a163..b87351e0e 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -175,9 +175,14 @@ interface InitializedServiceRegistry { type UninitializedServiceRegistry = InfrastructureServiceRegistry; -type RequestScopedHost = { +type RequestScopedHost = { forRequest(rawAuthHeader?: string, hints?: H): Promise; - forSystem(): Promise; + /** + * Builds a system-scoped application services instance, e.g. for background/queue-triggered work with + * no request context. Callers should pass only the specific permissions their operation needs + * (least privilege) rather than relying on an implicit default permission set. + */ + forSystem(permissions?: P): Promise; }; type AppHost = RequestScopedHost; diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index eaf8977c2..849424bb4 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -139,8 +139,8 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon forRequest: (_rawAuthHeader, hints) => { return mockApplicationServicesFactory.forRequest('Bearer test-token', hints); }, - forSystem: () => { - return mockApplicationServicesFactory.forSystem(); + forSystem: (permissions) => { + return mockApplicationServicesFactory.forSystem(permissions); }, }; } diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index d1aecd8b9..2586278ff 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -37,7 +37,14 @@ export type PrincipalHints = { export interface AppServicesHost { forRequest(rawAuthHeader?: string, hints?: PrincipalHints): Promise; - forSystem(): Promise; + /** + * Builds a system-scoped application services instance, e.g. for background/queue-triggered work with no request context. + * + * @param permissions - The specific permissions this system-triggered operation needs. Callers should + * request only the permissions required for their operation (least privilege) rather than relying on a + * shared default permission set. + */ + forSystem(permissions?: Partial): Promise; // forAzureFunction: (opts?: unknown) => Promise; } @@ -81,8 +88,8 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic }; }; - const forSystem = (): Promise => { - const systemPassport = Domain.PassportFactory.forSystem({ canManageCommunitySettings: true, isSystemAccount: true }); + const forSystem = (permissions?: Partial): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ isSystemAccount: true, ...permissions }); const { dataSourcesFactory, blobStorageService, queueStorageService } = context; const dataSources = dataSourcesFactory.withPassport(systemPassport); return Promise.resolve({ diff --git a/packages/ocom/domain/src/domain/contexts/passport.ts b/packages/ocom/domain/src/domain/contexts/passport.ts index 71bfaae9f..bc51ad40a 100644 --- a/packages/ocom/domain/src/domain/contexts/passport.ts +++ b/packages/ocom/domain/src/domain/contexts/passport.ts @@ -7,6 +7,8 @@ import type { PropertyPassport } from './property/property.passport.ts'; import type { ServicePassport } from './service/service.passport.ts'; import type { UserPassport } from './user/user.passport.ts'; +export type { PermissionsSpec }; + export interface Passport { get case(): CasePassport; get community(): CommunityPassport; diff --git a/packages/ocom/domain/src/domain/index.ts b/packages/ocom/domain/src/domain/index.ts index 6b321d748..9b575aed2 100644 --- a/packages/ocom/domain/src/domain/index.ts +++ b/packages/ocom/domain/src/domain/index.ts @@ -1,5 +1,5 @@ export * as Contexts from './contexts/index.ts'; -export { type Passport, PassportFactory } from './contexts/passport.ts'; +export { type Passport, PassportFactory, type PermissionsSpec } from './contexts/passport.ts'; export type { DomainExecutionContext } from './domain-execution-context.ts'; export * as Events from './events/index.ts'; export * as Services from './services/index.ts'; diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts index 62eb3e582..6e5cc3ae0 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.test.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -94,12 +94,12 @@ describe('communityUpdateQueueHandlerCreator', () => { }); }); - it('calls forSystem with no arguments (system queue trigger)', async () => { + it('calls forSystem scoped to only the community-settings permission it needs', async () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); await handler({ communityId: 'community-abc' }, context); - expect(factory.forSystem).toHaveBeenCalledWith(); + expect(factory.forSystem).toHaveBeenCalledWith({ canManageCommunitySettings: true }); }); it('defaults triggerMetadata id to empty string when not present', async () => { diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index edf89c06e..8bea6c2eb 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -48,7 +48,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A context.error(`${communityUpdateQueueName}: invalid message payload: ${err instanceof Error ? err.message : String(err)}`, err); return; } - const appServices = await applicationServicesFactory.forSystem(); + const appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); const { communityId, name, domain, whiteLabelDomain, handle } = message.payload; try { await appServices.Community.Community.updateSettings({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aeb29cbd..7e9d5815d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ overrides: node-forge@<1.3.2: '>=1.3.2' picomatch: ^4.0.4 webpack: ^5.105.4 - webpack-dev-server: ^5.2.5 + webpack-dev-server: ^5.2.6 express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': ^3.4.0 azurite>uuid: ^3.4.0 @@ -13775,8 +13775,8 @@ packages: webpack: optional: true - webpack-dev-server@5.2.5: - resolution: {integrity: sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==} + webpack-dev-server@5.2.6: + resolution: {integrity: sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==} engines: {node: '>= 18.12.0'} hasBin: true peerDependencies: @@ -16295,7 +16295,7 @@ snapshots: update-notifier: 6.0.2 webpack: 5.105.4(esbuild@0.28.1) webpack-bundle-analyzer: 4.10.2 - webpack-dev-server: 5.2.5(webpack@5.105.4(esbuild@0.28.1)) + webpack-dev-server: 5.2.6(webpack@5.105.4(esbuild@0.28.1)) webpack-merge: 6.0.1 transitivePeerDependencies: - '@parcel/css' @@ -27536,7 +27536,7 @@ snapshots: optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) - webpack-dev-server@5.2.5(webpack@5.105.4(esbuild@0.28.1)): + webpack-dev-server@5.2.6(webpack@5.105.4(esbuild@0.28.1)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36094b100..cad496b2c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -101,7 +101,7 @@ overrides: node-forge@<1.3.2: '>=1.3.2' picomatch: ^4.0.4 webpack: ^5.105.4 - webpack-dev-server: ^5.2.5 + webpack-dev-server: ^5.2.6 express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': '^3.4.0' 'azurite>uuid': '^3.4.0' From d98a1df0de52d9fccd53f4a1ea433251d4dceb22 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 7 Jul 2026 12:23:22 -0400 Subject: [PATCH 11/15] feat: enhance community update handling with nested payload support and improved logging --- .../service-queue-storage/src/interfaces.ts | 54 ++++++++++++--- .../src/logging-fields.ts | 67 ++++++++++++++---- .../src/payload-proxy.test.ts | 51 ++++++++++++++ .../src/handler.test.ts | 35 ++++++---- .../src/handler.ts | 2 +- .../inbound/community-update.sample.dat | 15 ++++ .../inbound/community-update.schema.json | 69 ++++++++++++++----- .../src/schemas/inbound/community-update.ts | 2 +- 8 files changed, 240 insertions(+), 55 deletions(-) create mode 100644 packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat diff --git a/packages/cellix/service-queue-storage/src/interfaces.ts b/packages/cellix/service-queue-storage/src/interfaces.ts index 98a65034d..3d5bc1aa6 100644 --- a/packages/cellix/service-queue-storage/src/interfaces.ts +++ b/packages/cellix/service-queue-storage/src/interfaces.ts @@ -218,28 +218,57 @@ export interface IQueueStorageOperations { type QueueMessageSchema = Readonly>; type PayloadFieldRef = { payloadField: TKey }; +/** + * Values treated as opaque leaves during nested-path traversal, even though some + * of them (arrays, `Date`) are technically objects. Traversal stops here instead + * of descending into array indices or built-in object internals. + */ +type PayloadLeafValue = string | number | boolean | null | undefined | readonly unknown[] | Date; + +/** Joins a dotted-path prefix with the next key, omitting the leading dot when the prefix is empty. */ +type ConcatPath = Prefix extends '' ? Key : `${Prefix}.${Key}`; + /** * Typed `$payload` helper shape used when authoring logging tags and metadata. * - * Each property access produces a `{ payloadField: 'fieldName' }` reference that - * can be stored in a queue definition and resolved later against a runtime payload. + * Each property access produces a `{ payloadField: 'a.b.c' }` reference that can + * be stored in a queue definition and resolved later against a runtime payload. + * Property access recurses into nested plain-object fields, building a dotted + * path, while still rejecting field names that do not exist on the payload type. * - * @typeParam TPayload - Queue payload type whose keys should be addressable. + * @typeParam TPayload - Queue payload type whose keys (including nested object keys) should be addressable. + * @typeParam Prefix - Internal accumulator for the dotted path built up during recursion. Do not set explicitly. * * @example * ```ts - * type CommunityCreated = { communityId: string; createdBy: string }; - * const $payload = payloadFields(); + * type CommunityUpdated = { communityId: string; eventPayload: { name: string } }; + * const $payload = payloadFields(); * * const metadata = { * communityId: $payload.communityId, - * createdBy: $payload.createdBy, + * name: $payload.eventPayload.name, // => { payloadField: 'eventPayload.name' } * }; * ``` */ -export type PayloadFieldProxy = { - [K in Extract]-?: PayloadFieldRef; +export type PayloadFieldProxy = { + [K in Extract]-?: NonNullable extends PayloadLeafValue + ? PayloadFieldRef> + : NonNullable extends object + ? PayloadFieldRef> & PayloadFieldProxy, ConcatPath> + : PayloadFieldRef>; }; + +/** Union of all valid dotted field paths (including nested object paths) for a payload type. */ +export type PayloadFieldPath = TPayload extends object + ? { + [K in Extract]: NonNullable extends PayloadLeafValue + ? ConcatPath + : NonNullable extends object + ? ConcatPath | PayloadFieldPath, ConcatPath> + : ConcatPath; + }[Extract] + : never; + export type AnyLoggingFieldSpec = string | PayloadFieldRef; type QueueDefinitionBase = { queueName: string; @@ -250,7 +279,7 @@ type QueueDefinitionBase = { /** * Describes a single logging field value: either a hardcoded string or a reference - * to a top-level field on the message payload. + * to a (possibly nested) field on the message payload. * * Use the {@link $payload} proxy for clarity when extracting from the payload. * @@ -263,11 +292,14 @@ type QueueDefinitionBase = { * * // value extracted from payload.externalId at runtime * const spec: LoggingFieldSpec = $payload.externalId; + * + * // value extracted from a nested payload field at runtime + * const nestedSpec: LoggingFieldSpec = $payload.eventPayload.communityId; * ``` * - * @typeParam TPayload - Payload type whose keys may be referenced when using `{ payloadField: ... }`. + * @typeParam TPayload - Payload type whose (possibly nested) keys may be referenced when using `{ payloadField: ... }`. */ -export type LoggingFieldSpec = string | PayloadFieldRef>; +export type LoggingFieldSpec = string | PayloadFieldRef>; /** * QueueDefinition describes a single logical queue: its physical queue name, diff --git a/packages/cellix/service-queue-storage/src/logging-fields.ts b/packages/cellix/service-queue-storage/src/logging-fields.ts index 21f06e49c..53a0f8343 100644 --- a/packages/cellix/service-queue-storage/src/logging-fields.ts +++ b/packages/cellix/service-queue-storage/src/logging-fields.ts @@ -1,10 +1,30 @@ import type { AnyLoggingFieldSpec, PayloadFieldProxy } from './interfaces.ts'; +/** + * Builds a proxy that produces a `{ payloadField: '' }` reference on + * every property access, and recurses into further property accesses so that + * nested payload fields (e.g. `$payload.eventPayload.communityId`) build up a + * dotted path instead of losing the parent key. + */ +function createPayloadFieldProxy(path: string): unknown { + return new Proxy( + { payloadField: path }, + { + get(target, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop); + if (prop === 'payloadField') return target.payloadField; + return createPayloadFieldProxy(`${path}.${prop}`); + }, + }, + ); +} + const payloadFieldProxy = new Proxy( {}, { - get(_target, prop: string) { - return { payloadField: prop }; + get(target, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop); + return createPayloadFieldProxy(prop); }, }, ); @@ -26,8 +46,10 @@ const payloadFieldProxy = new Proxy( * * @remarks * Because this export is intentionally broad, TypeScript cannot prevent typos in - * field names here. Prefer `defineQueue()` or `payloadFields()` - * when you want payload-key validation during authoring. + * field names here, and nested chaining (e.g. `$payload.eventPayload.communityId`) + * is not type-checked for this export. Prefer `defineQueue()` or + * `payloadFields()` when you want payload-key validation during authoring, + * including for nested object fields. */ export const $payload = payloadFieldProxy as PayloadFieldProxy>; @@ -35,31 +57,50 @@ export const $payload = payloadFieldProxy as PayloadFieldProxy(); - * const metadata = { memberId: $payload.memberId, email: $payload.email }; + * const metadata = { + * memberId: $payload.memberId, + * email: $payload.email, + * communityId: $payload.eventPayload.communityId, + * }; * ``` */ export function payloadFields(): PayloadFieldProxy { return payloadFieldProxy as PayloadFieldProxy; } +/** Reads a (possibly dotted) path off a runtime payload, returning `undefined` if any segment is missing. */ +function readPayloadPath(payload: unknown, dottedPath: string): unknown { + let current = payload; + for (const segment of dottedPath.split('.')) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[segment]; + } + return current; +} + /** * Resolves a map of payload-derived logging fields against a message payload. * - * Hardcoded strings are copied as-is. Payload references are omitted when the - * referenced payload value is `undefined` or `null`. + * Hardcoded strings are copied as-is. Payload references — including nested + * dotted-path references such as `eventPayload.communityId` — are omitted when + * the referenced payload value is `undefined` or `null`, or when any segment + * along the path is missing. * * @param specs - Logging field definitions using either hardcoded strings or payload references. * @param payload - Runtime queue payload to resolve against. @@ -69,8 +110,8 @@ export function payloadFields(): PayloadFieldProxy { domain: 'community', communityId: 'community-123' } @@ -83,7 +124,7 @@ export function resolveLoggingFields(specs: Record if (typeof spec === 'string') { resolved[key] = spec; } else { - const val = (payload as Record)?.[spec.payloadField]; + const val = readPayloadPath(payload, spec.payloadField); if (val !== undefined && val !== null) { resolved[key] = String(val); } diff --git a/packages/cellix/service-queue-storage/src/payload-proxy.test.ts b/packages/cellix/service-queue-storage/src/payload-proxy.test.ts index 7f59ac047..c0178f1e3 100644 --- a/packages/cellix/service-queue-storage/src/payload-proxy.test.ts +++ b/packages/cellix/service-queue-storage/src/payload-proxy.test.ts @@ -147,4 +147,55 @@ describe('$payload proxy', () => { // @ts-expect-error nonexistentField is not part of the payload type expect(invalidPayload.nonexistentField).toBeDefined(); }); + + it('builds a dotted path when chaining into a nested object field', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string; name?: string } }>(); + + expect(scopedPayload.eventPayload.communityId).toEqual({ payloadField: 'eventPayload.communityId' }); + expect(scopedPayload.eventPayload.name).toEqual({ payloadField: 'eventPayload.name' }); + }); + + it('resolves nested payload field references against a nested runtime payload', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + const spec = { + domain: 'community', + communityId: scopedPayload.eventPayload.communityId, + }; + + const payload = { eventPayload: { communityId: 'community-abc' } }; + const resolved = resolveLoggingFields(spec, payload); + + expect(resolved).toEqual({ + domain: 'community', + communityId: 'community-abc', + }); + }); + + it('omits a nested payload field reference when an intermediate segment is missing', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + const spec = { + communityId: scopedPayload.eventPayload.communityId, + }; + + const resolved = resolveLoggingFields(spec, { somethingElse: true }); + + expect(resolved).toBeUndefined(); + }); + + it('omits a nested payload field reference when the leaf value is null', () => { + const scopedPayload = payloadFields<{ eventPayload: { whiteLabelDomain: string | null } }>(); + const spec = { + whiteLabelDomain: scopedPayload.eventPayload.whiteLabelDomain, + }; + + const resolved = resolveLoggingFields(spec, { eventPayload: { whiteLabelDomain: null } }); + + expect(resolved).toBeUndefined(); + }); + + it('rejects nested field names that do not exist on the payload type', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + // @ts-expect-error nonexistentField does not exist on the nested eventPayload type + expect(scopedPayload.eventPayload.nonexistentField).toBeDefined(); + }); }); diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts index 6e5cc3ae0..e6d93306f 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.test.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -1,10 +1,22 @@ import type { InvocationContext } from '@azure/functions'; import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; import { CommunityNotFoundError } from '@ocom/application-services'; -import type { QueueStorageOperations } from '@ocom/service-queue-storage'; +import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { communityUpdateQueueHandlerCreator } from './handler.ts'; +function makeQueueEntry(overrides?: Partial): CommunityUpdatePayload { + return { + eventTimestamp: '2023-03-21T02:53:27.872Z', + eventUuid: 'x-event-id-001', + apiName: 'api-name-001', + eventPayload: { + communityId: 'community-abc', + ...overrides, + }, + }; +} + function makeMockApplicationServicesFactory(updateSettings = vi.fn().mockResolvedValue(undefined)) { const appServices = { Community: { @@ -24,13 +36,12 @@ function makeMockQueueService(receiveResult?: object) { receiveFromCommunityUpdateQueue: vi.fn().mockResolvedValue( receiveResult ?? { id: 'msg-1', - payload: { - communityId: 'community-abc', + payload: makeQueueEntry({ name: 'Test Community', domain: 'test.example.com', whiteLabelDomain: null, handle: null, - }, + }), }, ), } as unknown as QueueStorageOperations; @@ -69,7 +80,7 @@ describe('communityUpdateQueueHandlerCreator', () => { describe('handler invocation', () => { it('calls receiveFromCommunityUpdateQueue with payload and trigger metadata', async () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - const queueEntry = { communityId: 'community-abc' }; + const queueEntry = makeQueueEntry(); await handler(queueEntry, context); @@ -83,7 +94,7 @@ describe('communityUpdateQueueHandlerCreator', () => { it('calls updateSettings with the message payload fields', async () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await handler({ communityId: 'community-abc' }, context); + await handler(makeQueueEntry(), context); expect(factory.updateSettings).toHaveBeenCalledWith({ id: 'community-abc', @@ -97,7 +108,7 @@ describe('communityUpdateQueueHandlerCreator', () => { it('calls forSystem scoped to only the community-settings permission it needs', async () => { const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await handler({ communityId: 'community-abc' }, context); + await handler(makeQueueEntry(), context); expect(factory.forSystem).toHaveBeenCalledWith({ canManageCommunitySettings: true }); }); @@ -109,7 +120,7 @@ describe('communityUpdateQueueHandlerCreator', () => { } as unknown as InvocationContext; const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await handler({ communityId: 'community-abc' }, ctx); + await handler(makeQueueEntry(), ctx); expect(queueService.receiveFromCommunityUpdateQueue).toHaveBeenCalledWith(expect.anything(), { id: '', @@ -124,7 +135,7 @@ describe('communityUpdateQueueHandlerCreator', () => { vi.mocked(queueService.receiveFromCommunityUpdateQueue).mockRejectedValue(new Error('JSON parse error')); const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await expect(handler({ bad: 'payload' } as unknown as { communityId: string }, context)).resolves.toBeUndefined(); + await expect(handler({ bad: 'payload' } as unknown as CommunityUpdatePayload, context)).resolves.toBeUndefined(); expect(context.error).toHaveBeenCalledWith(expect.stringContaining('invalid message payload'), expect.any(Error)); expect(factory.forSystem).not.toHaveBeenCalled(); }); @@ -133,7 +144,7 @@ describe('communityUpdateQueueHandlerCreator', () => { factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new CommunityNotFoundError('missing-id'))); const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await expect(handler({ communityId: 'missing-id' }, context)).resolves.toBeUndefined(); + await expect(handler(makeQueueEntry({ communityId: 'missing-id' }), context)).resolves.toBeUndefined(); expect(context.error).toHaveBeenCalledWith(expect.stringContaining('community not found'), expect.any(CommunityNotFoundError)); }); @@ -141,7 +152,7 @@ describe('communityUpdateQueueHandlerCreator', () => { factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new Error('Database connection failed'))); const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await expect(handler({ communityId: 'community-abc' }, context)).rejects.toThrow('Database connection failed'); + await expect(handler(makeQueueEntry(), context)).rejects.toThrow('Database connection failed'); expect(context.error).not.toHaveBeenCalled(); }); @@ -149,7 +160,7 @@ describe('communityUpdateQueueHandlerCreator', () => { factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue('unexpected string error')); const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); - await expect(handler({ communityId: 'community-abc' }, context)).rejects.toBe('unexpected string error'); + await expect(handler(makeQueueEntry(), context)).rejects.toBe('unexpected string error'); }); }); }); diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index 8bea6c2eb..a6f6e62a8 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -49,7 +49,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A return; } const appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); - const { communityId, name, domain, whiteLabelDomain, handle } = message.payload; + const { communityId, name, domain, whiteLabelDomain, handle } = message.payload.eventPayload; try { await appServices.Community.Community.updateSettings({ id: communityId, diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat new file mode 100644 index 000000000..4be51d06d --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat @@ -0,0 +1,15 @@ +{ + "eventTimestamp": "2026-07-07T02:53:27.872Z", + "replayId": "replay-id-001", + "eventUuid": "x-event-id-001", + "apiName": "api-name-001", + "traceParent": "traceParent-001", + "traceState": "traceState-success", + "eventPayload": { + "communityId": "b00000000000000000000001", + "name": "Updated Community Name", + "domain": "updated-community-domain.com", + "whiteLabelDomain": "updated-white-label-domain.com", + "handle": "updated-community-handle" + } +} diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json index e622c8253..111cc6c39 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json @@ -3,28 +3,63 @@ "title": "CommunityUpdatePayload", "description": "Message for updating settings on an existing community", "type": "object", - "properties": { - "communityId": { + "properties": { + "eventTimestamp": { "type": "string", - "description": "Identifier of the community to update (required for lookup)" + "pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9].[0-9][0-9][0-9]Z$", + "examples": ["2023-03-21T02:53:27.872Z"], + "description": "Date-time value converted to UTC timezone, and represented in ISO format" }, - "name": { + "replayId": { "type": "string", - "description": "Updated community name" + "examples": ["replay-id-001"] }, - "domain": { + "eventUuid": { "type": "string", - "description": "Updated community domain" + "maxLength": 36, + "examples": ["x-event-id-001"], + "description": "unique event Id from the sender" }, - "whiteLabelDomain": { - "type": ["string", "null"], - "description": "Updated white label domain (null to clear)" + "apiName": { + "type": "string", + "examples": ["api-name-001"] + }, + "traceParent": { + "type": "string", + "examples": ["traceParent-001"] + }, + "traceState": { + "type": "string", + "examples": ["traceState-success"] }, - "handle": { - "type": ["string", "null"], - "description": "Updated community handle (null to clear)" - } - }, - "required": ["communityId"], - "additionalProperties": false + "eventPayload": { + "type": "object", + "properties": { + "communityId": { + "type": "string", + "description": "Identifier of the community to update (required for lookup)" + }, + "name": { + "type": "string", + "description": "Updated community name" + }, + "domain": { + "type": "string", + "description": "Updated community domain" + }, + "whiteLabelDomain": { + "type": ["string", "null"], + "description": "Updated white label domain (null to clear)" + }, + "handle": { + "type": ["string", "null"], + "description": "Updated community handle (null to clear)" + } + }, + "additionalProperties": false, + "required": ["communityId"] + } + }, + "additionalProperties": false, + "required": ["eventTimestamp", "eventUuid", "apiName", "eventPayload"] } diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts index 2e5194a5e..c9b0a0140 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts @@ -10,7 +10,7 @@ export const communityUpdateQueue = defineQueue()(({ $pa schema: communityUpdateSchema, loggingTags: { domain: 'community', - communityId: $payload.communityId, + communityId: $payload.eventPayload.communityId, }, loggingMetadata: { updateType: 'community-settings', From ea8c755e306978156e04e349358f7da4752510f3 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 7 Jul 2026 13:52:38 -0400 Subject: [PATCH 12/15] fix: removed export for PayloadFieldPath type in interfaces.ts --- packages/cellix/service-queue-storage/src/interfaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cellix/service-queue-storage/src/interfaces.ts b/packages/cellix/service-queue-storage/src/interfaces.ts index 3d5bc1aa6..dd522f486 100644 --- a/packages/cellix/service-queue-storage/src/interfaces.ts +++ b/packages/cellix/service-queue-storage/src/interfaces.ts @@ -259,7 +259,7 @@ export type PayloadFieldProxy = TPayload extends object +type PayloadFieldPath = TPayload extends object ? { [K in Extract]: NonNullable extends PayloadLeafValue ? ConcatPath From 13e50f3ab28f8c0e598b516354c4e679657c4a9a Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Tue, 7 Jul 2026 14:36:28 -0400 Subject: [PATCH 13/15] feat: enhance error messages in queue handler for better diagnostics --- apps/api/src/cellix.ts | 4 ++-- .../handler-queue-community-update/readme.md | 4 ++-- .../src/handler.ts | 21 +++---------------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index b87351e0e..c140ec198 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -354,7 +354,7 @@ export class Cellix ...h.options, handler: (request, context) => { if (!this.appServicesHostInternal) { - throw new Error('Application not started yet'); + throw new Error(`Application not started yet (function: ${h.name})`); } return h.handlerCreator(this.appServicesHostInternal, this)(request, context); }, @@ -367,7 +367,7 @@ export class Cellix ...h.options, handler: (queueEntry, context) => { if (!this.appServicesHostInternal) { - throw new Error('Application not started yet'); + throw new Error(`Application not started yet (queue function: ${h.name})`); } return h.handlerCreator(this.appServicesHostInternal, this)(queueEntry, context); }, diff --git a/packages/ocom/handler-queue-community-update/readme.md b/packages/ocom/handler-queue-community-update/readme.md index 3199a9698..3cbda4c66 100644 --- a/packages/ocom/handler-queue-community-update/readme.md +++ b/packages/ocom/handler-queue-community-update/readme.md @@ -40,12 +40,12 @@ On each queue trigger: 1. Extracts trigger metadata (`id`, `popReceipt`, `dequeueCount`) from the Azure Functions context. 2. Validates and deserializes the raw queue entry via `@ocom/service-queue-storage` `receiveFromCommunityUpdateQueue`. -3. Creates a request-scoped application services instance via the `ApplicationServicesFactory`. +3. Creates a system-scoped application services instance (via `ApplicationServicesFactory.forSystem`), scoped to only the `canManageCommunitySettings` permission this handler needs. 4. Calls `Community.updateSettings` with the payload fields (`name`, `domain`, `whiteLabelDomain`, `handle`), including only the fields present in the message. 5. Logs and swallows `community not found` errors; rethrows all other errors so the Functions runtime retries the message. ## Related packages - `@ocom/service-queue-storage` — queue registry, schema validation, and typed message deserialization. -- `@ocom/application-services` — application services factory providing request-scoped services. +- `@ocom/application-services` — application services factory providing request-scoped services (HTTP) and system-scoped services (background/queue-triggered work, e.g. via `forSystem`). - `apps/api` — composition root that wires this handler into the Azure Functions host via Cellix. diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index a6f6e62a8..392b3f243 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -10,22 +10,7 @@ import type { CommunityUpdatePayload, QueueStorageOperations, QueueTriggerMetada * @param applicationServicesFactory - Factory for building system-scoped application services * @param queueService - Queue storage operations for receiving and validating messages * @returns An Azure Functions StorageQueueHandler for the community-update queue - * - * @example - * ```ts - * import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; - * - * Cellix - * .initializeInfrastructureServices(...) - * .setContext(...) - * .initializeApplicationServices(...) - * .registerAzureFunctionQueueHandler( - * 'community-update', - * { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, - * (host, infra) => communityUpdateQueueHandlerCreator(host, infra.getInfrastructureService(ServiceQueueStorage)), - * ) - * .startUp(); - * ``` + *``` */ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: ApplicationServicesFactory, queueService: QueueStorageOperations): StorageQueueHandler => { return async (queueEntry, context) => { @@ -45,7 +30,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A try { message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); } catch (err) { - context.error(`${communityUpdateQueueName}: invalid message payload: ${err instanceof Error ? err.message : String(err)}`, err); + context.error(`${communityUpdateQueueName}: invalid message payload (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'}): ${err instanceof Error ? err.message : String(err)}`, err); return; } const appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); @@ -60,7 +45,7 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A }); } catch (err) { if (err instanceof CommunityNotFoundError) { - context.error(`${communityUpdateQueueName}: community not found: ${communityId}`, err); + context.error(`${communityUpdateQueueName}: community not found: ${communityId} (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'})`, err); return; } throw err; From 61f669bcb7c88520728bb99c3f78ef4e79202e74 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 8 Jul 2026 10:37:07 -0400 Subject: [PATCH 14/15] feat: add @ocom/domain dependency and enhance Cellix interfaces for improved type safety for configuring permissions forSystem --- apps/api/package.json | 1 + apps/api/src/cellix.ts | 62 +++++++++++++++++++++--------------------- apps/api/src/index.ts | 3 +- pnpm-lock.yaml | 3 ++ 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index f64a992e9..2997d2840 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,6 +33,7 @@ "@cellix/mongoose-seedwork": "workspace:*", "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", + "@ocom/domain": "workspace:*", "@ocom/event-handler": "workspace:*", "@ocom/graphql": "workspace:*", "@ocom/graphql-handler": "workspace:*", diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index c140ec198..4159b81b0 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -24,7 +24,7 @@ interface InfrastructureServiceRegistry(service: T, name?: string): InfrastructureServiceRegistry; } -interface ContextBuilder { +interface ContextBuilder { /** * Defines the infrastructure context available for the application. * @@ -40,10 +40,10 @@ interface ContextBuilder { * * @throws Error - If called outside the 'infrastructure' phase. */ - setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer; + setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer; } -interface ApplicationServicesInitializer { +interface ApplicationServicesInitializer { /** * Registers the factory that creates the request-scoped application services host. * @@ -68,10 +68,10 @@ interface ApplicationServicesInitializer { * }); * ``` */ - initializeApplicationServices(factory: (infrastructureContext: ContextType) => AppHost): AzureFunctionHandlerRegistry; + initializeApplicationServices(factory: (infrastructureContext: ContextType) => AppHost): AzureFunctionHandlerRegistry; } -interface AzureFunctionHandlerRegistry { +interface AzureFunctionHandlerRegistry { /** * Registers an Azure Function HTTP endpoint. * @@ -101,8 +101,8 @@ interface AzureFunctionHandlerRegistry, - handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, - ): AzureFunctionHandlerRegistry; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, + ): AzureFunctionHandlerRegistry; /** * Registers an Azure Function Azure Storage Queue trigger endpoint. * @@ -132,8 +132,8 @@ interface AzureFunctionHandlerRegistry( name: string, options: Omit, 'handler'>, - handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, - ): AzureFunctionHandlerRegistry; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry; /** * Finalizes configuration and starts the application. * @@ -185,18 +185,18 @@ type RequestScopedHost = { forSystem(permissions?: P): Promise; }; -type AppHost = RequestScopedHost; +type AppHost = RequestScopedHost; -interface PendingHandler { +interface PendingHandler { name: string; options: Omit; - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; } -interface PendingQueueHandler { +interface PendingQueueHandler { name: string; options: Omit, 'handler'>; - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; } type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'started'; @@ -209,18 +209,18 @@ type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'start */ type ServiceKey = { prototype: T }; -export class Cellix +export class Cellix implements InfrastructureServiceRegistry, - ContextBuilder, - ApplicationServicesInitializer, - AzureFunctionHandlerRegistry, + ContextBuilder, + ApplicationServicesInitializer, + AzureFunctionHandlerRegistry, StartedApplication { private contextInternal: ContextType | undefined; - private appServicesHostInternal: RequestScopedHost | undefined; + private appServicesHostInternal: RequestScopedHost | undefined; private contextCreatorInternal: ((serviceRegistry: InitializedServiceRegistry) => ContextType) | undefined; - private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost) | undefined; + private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost) | undefined; private readonly tracer: Tracer; private readonly servicesInternal: Map, ServiceBase> = new Map(); /** @@ -229,9 +229,9 @@ export class Cellix * different names. */ private readonly nameMap: Map = new Map(); - private readonly pendingHandlers: Array> = []; + private readonly pendingHandlers: Array> = []; // biome-ignore lint/suspicious/noExplicitAny: each queue handler captures a distinct T; any allows heterogeneous handlers in one array without unsafe per-field casts - private readonly pendingQueueHandlers: Array> = []; + private readonly pendingQueueHandlers: Array> = []; private serviceInitializedInternal = false; private phase: Phase = 'infrastructure'; @@ -271,8 +271,8 @@ export class Cellix * .startUp(); * ``` */ - public static initializeInfrastructureServices(registerServices: (registry: UninitializedServiceRegistry) => void): ContextBuilder { - const instance = new Cellix(); + public static initializeInfrastructureServices(registerServices: (registry: UninitializedServiceRegistry) => void): ContextBuilder { + const instance = new Cellix(); registerServices(instance); return instance; } @@ -298,14 +298,14 @@ export class Cellix return this; } - public setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer { + public setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer { this.ensurePhase('infrastructure'); this.contextCreatorInternal = contextCreator; this.phase = 'context'; return this; } - public initializeApplicationServices(factory: (infrastructureContext: ContextType) => RequestScopedHost): AzureFunctionHandlerRegistry { + public initializeApplicationServices(factory: (infrastructureContext: ContextType) => RequestScopedHost): AzureFunctionHandlerRegistry { this.ensurePhase('context'); if (!this.contextCreatorInternal) { throw new Error('Context creator must be set before initializing application services'); @@ -318,8 +318,8 @@ export class Cellix public registerAzureFunctionHttpHandler( name: string, options: Omit, - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, - ): AzureFunctionHandlerRegistry { + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, + ): AzureFunctionHandlerRegistry { this.ensurePhase('app-services', 'handlers'); this.pendingHandlers.push({ name, options, handlerCreator }); this.phase = 'handlers'; @@ -329,8 +329,8 @@ export class Cellix public registerAzureFunctionQueueHandler( name: string, options: Omit, 'handler'>, - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, - ): AzureFunctionHandlerRegistry { + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry { this.ensurePhase('app-services', 'handlers'); this.pendingQueueHandlers.push({ name, options, handlerCreator }); this.phase = 'handlers'; @@ -461,7 +461,7 @@ export class Cellix return this.contextInternal; } - public get applicationServices(): RequestScopedHost { + public get applicationServices(): RequestScopedHost { if (!this.appServicesHostInternal) { throw new Error('Application services not initialized'); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ba7f621c3..997ed9eff 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import './service-config/otel-starter.ts'; import type { ApplicationServices } from '@ocom/application-services'; import { buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; +import type { Domain } from '@ocom/domain'; import { RegisterEventHandlers } from '@ocom/event-handler'; import type { GraphContext } from '@ocom/graphql-handler'; import { graphHandlerCreator } from '@ocom/graphql-handler'; @@ -23,7 +24,7 @@ import * as TokenValidationConfig from './service-config/token-validation/index. const { NODE_ENV } = process.env; const isProd = NODE_ENV === 'production'; -Cellix.initializeInfrastructureServices((serviceRegistry) => { +Cellix.initializeInfrastructureServices>((serviceRegistry) => { serviceRegistry .registerInfrastructureService(new ServiceMongoose(MongooseConfig.mongooseConnectionString, MongooseConfig.mongooseConnectOptions)) .registerInfrastructureService( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e9d5815d..72ed35f11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: '@ocom/context-spec': specifier: workspace:* version: link:../../packages/ocom/context-spec + '@ocom/domain': + specifier: workspace:* + version: link:../../packages/ocom/domain '@ocom/event-handler': specifier: workspace:* version: link:../../packages/ocom/event-handler From ed769a475ce454a71d80b7b902468164974c1ac9 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 8 Jul 2026 14:48:14 -0400 Subject: [PATCH 15/15] feat: implement extractQueueTriggerMetadata function and update handlers to use it for improved metadata extraction --- apps/api/src/cellix.ts | 44 ++++++++++----- apps/ui-community/mock-oidc.users.json | 6 +-- apps/ui-staff/mock-oidc.users.json | 2 +- .../tests/file-user-store.test.ts | 14 +---- .../tests/portal-discovery.test.ts | 6 +-- .../cellix/service-queue-storage/src/index.ts | 1 + .../src/trigger-metadata.test.ts | 38 +++++++++++++ .../src/trigger-metadata.ts | 35 ++++++++++++ .../src/handler.test.ts | 9 ++++ .../src/handler.ts | 26 ++++----- .../ocom/service-queue-storage/src/index.ts | 1 + .../inbound/community-update.schema.json | 54 +++++++++---------- 12 files changed, 159 insertions(+), 77 deletions(-) create mode 100644 packages/cellix/service-queue-storage/src/trigger-metadata.test.ts create mode 100644 packages/cellix/service-queue-storage/src/trigger-metadata.ts diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 4159b81b0..cbd0d5794 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -1,4 +1,4 @@ -import { app, type HttpFunctionOptions, type HttpHandler, type StorageQueueFunctionOptions, type StorageQueueHandler } from '@azure/functions'; +import { app, type HttpFunctionOptions, type HttpHandler, type InvocationContext, type StorageQueueFunctionOptions, type StorageQueueHandler } from '@azure/functions'; import type { ServiceBase } from '@cellix/api-services-spec'; import api, { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; @@ -271,7 +271,9 @@ export class Cellix * .startUp(); * ``` */ - public static initializeInfrastructureServices(registerServices: (registry: UninitializedServiceRegistry) => void): ContextBuilder { + public static initializeInfrastructureServices( + registerServices: (registry: UninitializedServiceRegistry) => void, + ): ContextBuilder { const instance = new Cellix(); registerServices(instance); return instance; @@ -347,17 +349,36 @@ export class Cellix return Promise.resolve(this); } + /** + * Wraps a handler creator so its construction is deferred until the first invocation, at which + * point the (by-then initialized) application services host is available. Shared by both HTTP + * and Storage Queue handler registration in {@link setupLifecycle} so lifecycle checks (e.g., + * "has application services started?") stay consistent across handler kinds. + * + * @param kindLabel - Human-readable handler kind used in the "not started yet" error message. + * @param name - The Azure Function name, used in the "not started yet" error message. + * @param handlerCreator - Factory that produces the actual per-invocation handler function. + * @returns A handler function with the same signature as the one `handlerCreator` produces. + */ + private deferHandlerCreation( + kindLabel: string, + name: string, + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => (arg: Arg, context: InvocationContext) => R, + ): (arg: Arg, context: InvocationContext) => R { + return (arg: Arg, context: InvocationContext): R => { + if (!this.appServicesHostInternal) { + throw new Error(`Application not started yet (${kindLabel}: ${name})`); + } + return handlerCreator(this.appServicesHostInternal, this)(arg, context); + }; + } + private setupLifecycle(): void { // Register HTTP function handlers (deferred execution of creators) for (const h of this.pendingHandlers) { app.http(h.name, { ...h.options, - handler: (request, context) => { - if (!this.appServicesHostInternal) { - throw new Error(`Application not started yet (function: ${h.name})`); - } - return h.handlerCreator(this.appServicesHostInternal, this)(request, context); - }, + handler: this.deferHandlerCreation('function', h.name, h.handlerCreator), }); } @@ -365,12 +386,7 @@ export class Cellix for (const h of this.pendingQueueHandlers) { app.storageQueue(h.name, { ...h.options, - handler: (queueEntry, context) => { - if (!this.appServicesHostInternal) { - throw new Error(`Application not started yet (queue function: ${h.name})`); - } - return h.handlerCreator(this.appServicesHostInternal, this)(queueEntry, context); - }, + handler: this.deferHandlerCreation('queue function', h.name, h.handlerCreator), }); } diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index ade0b4f42..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "test@example.com", "sub": "00000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "test@example.com", "given_name": "Test", @@ -15,7 +15,7 @@ "username": "john.doe@example.com", "sub": "00000000-0000-4000-8000-000000000002", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "john.doe@example.com", "given_name": "John", @@ -27,7 +27,7 @@ "username": "owner@test.example", "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee01", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "owner@test.example", "given_name": "Test", diff --git a/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 6b635dfb4..1c753ea7c 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "staff@ownercommunity.onmicrosoft.com", "sub": "10000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "staff-user", + "oidcConfigName": "staff-user", "claims": { "email": "staff@ownercommunity.onmicrosoft.com", "given_name": "Staff", diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts index 04772575f..f6fdee1ca 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts @@ -126,9 +126,7 @@ describe('file user store oidcConfigName filtering', () => { it('users without oidcConfigName are visible to all scoped stores', async () => { if (!tmp) throw new Error('tmp not created'); - writeUsers(tmp, 'mock-oidc.users.json', [ - { username: 'shared@example.com', sub: 'sub-shared' }, - ]); + writeUsers(tmp, 'mock-oidc.users.json', [{ username: 'shared@example.com', sub: 'sub-shared' }]); const endStore = createFileUserStore(tmp, 'end-user'); const staffStore = createFileUserStore(tmp, 'staff-user'); @@ -191,15 +189,7 @@ describe('file user store oidcConfigName filtering', () => { expect(users).toHaveLength(1); expect(users[0]?.username).toBe('good@example.com'); expect(warnSpy.length).toBeGreaterThan(0); - expect( - warnSpy.some((args) => - (args as unknown[]).some( - (arg) => - typeof arg === 'string' && - arg.includes('"oidcConfigName" must be a string'), - ), - ), - ).toBe(true); + expect(warnSpy.some((args) => (args as unknown[]).some((arg) => typeof arg === 'string' && arg.includes('"oidcConfigName" must be a string')))).toBe(true); } finally { console.warn = origWarn; } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts index d364915c8..565ece8ac 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts @@ -569,11 +569,7 @@ describe('discoverPortalConfigs', () => { envVars: { clientId: 'VITE_APP_PORTAL_STAFF_CLIENTID', redirectUri: 'VITE_APP_PORTAL_STAFF_REDIRECT' }, }, ]); - writeEnv( - tmp, - 'ui-portal/.env', - 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n', - ); + writeEnv(tmp, 'ui-portal/.env', 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n'); const portals = discoverPortalConfigs(tmp); expect(portals).toHaveLength(2); diff --git a/packages/cellix/service-queue-storage/src/index.ts b/packages/cellix/service-queue-storage/src/index.ts index 1e24249e1..b0f114968 100644 --- a/packages/cellix/service-queue-storage/src/index.ts +++ b/packages/cellix/service-queue-storage/src/index.ts @@ -16,3 +16,4 @@ export type { RegisteredQueueService, } from './register-queues.ts'; export { createRegisteredQueueService, deriveProvisionQueues, registerQueues } from './register-queues.ts'; +export { extractQueueTriggerMetadata } from './trigger-metadata.ts'; diff --git a/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts b/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts new file mode 100644 index 000000000..00a5bf303 --- /dev/null +++ b/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { extractQueueTriggerMetadata } from './trigger-metadata.ts'; + +describe('extractQueueTriggerMetadata', () => { + it('extracts id, popReceipt, and dequeueCount when all are present', () => { + const metadata = extractQueueTriggerMetadata({ + id: 'msg-1', + popReceipt: 'receipt-1', + dequeueCount: 2, + }); + + expect(metadata).toStrictEqual({ + id: 'msg-1', + popReceipt: 'receipt-1', + dequeueCount: 2, + }); + }); + + it('defaults id to an empty string when triggerMetadata is undefined', () => { + const metadata = extractQueueTriggerMetadata(undefined); + + expect(metadata).toStrictEqual({ id: '' }); + }); + + it('defaults id to an empty string when id is missing', () => { + const metadata = extractQueueTriggerMetadata({ popReceipt: 'receipt-1' }); + + expect(metadata).toStrictEqual({ id: '', popReceipt: 'receipt-1' }); + }); + + it('omits popReceipt and dequeueCount keys entirely when they are undefined', () => { + const metadata = extractQueueTriggerMetadata({ id: 'msg-1' }); + + expect(metadata).toStrictEqual({ id: 'msg-1' }); + expect('popReceipt' in metadata).toBe(false); + expect('dequeueCount' in metadata).toBe(false); + }); +}); diff --git a/packages/cellix/service-queue-storage/src/trigger-metadata.ts b/packages/cellix/service-queue-storage/src/trigger-metadata.ts new file mode 100644 index 000000000..f1366c2e8 --- /dev/null +++ b/packages/cellix/service-queue-storage/src/trigger-metadata.ts @@ -0,0 +1,35 @@ +import type { QueueTriggerMetadata } from './interfaces.ts'; + +/** + * Builds a {@link QueueTriggerMetadata} object from an Azure Functions storage queue trigger's + * `triggerMetadata` bag. + * + * @remarks + * Azure Functions types `InvocationContext.triggerMetadata` as an index-signature record, so callers + * would otherwise need to repeat the same bracket-access extraction (and `exactOptionalPropertyTypes`-safe + * optional key omission) in every queue handler. Centralizing it here keeps that behavior consistent + * across handlers. + * + * @param triggerMetadata - The raw `context.triggerMetadata` value from an Azure Functions storage queue trigger invocation. + * @returns Trigger metadata with `id` defaulted to `''` when absent, and `popReceipt`/`dequeueCount` omitted (rather than set to `undefined`) when not present. + * + * @example + * ```ts + * const metadata = extractQueueTriggerMetadata(context.triggerMetadata); + * await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); + * ``` + */ +export function extractQueueTriggerMetadata(triggerMetadata: Record | undefined): QueueTriggerMetadata { + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const id = (triggerMetadata?.['id'] as string) ?? ''; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const popReceipt = triggerMetadata?.['popReceipt'] as string | undefined; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const dequeueCount = triggerMetadata?.['dequeueCount'] as number | undefined; + // exactOptionalPropertyTypes: omit keys whose values are undefined rather than passing explicit undefined + return { + id, + ...(popReceipt === undefined ? {} : { popReceipt }), + ...(dequeueCount === undefined ? {} : { dequeueCount }), + }; +} diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts index e6d93306f..cbc4653d7 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.test.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -162,5 +162,14 @@ describe('communityUpdateQueueHandlerCreator', () => { await expect(handler(makeQueueEntry(), context)).rejects.toBe('unexpected string error'); }); + + it('logs an error and rethrows when forSystem throws', async () => { + factory.forSystem.mockRejectedValue(new Error('passport factory misconfigured')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler(makeQueueEntry(), context)).rejects.toThrow('passport factory misconfigured'); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('failed to create application services'), expect.any(Error)); + expect(factory.updateSettings).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts index 392b3f243..918647fa7 100644 --- a/packages/ocom/handler-queue-community-update/src/handler.ts +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -1,8 +1,8 @@ import type { StorageQueueHandler } from '@azure/functions'; import type { ApplicationServicesFactory } from '@ocom/application-services'; import { CommunityNotFoundError } from '@ocom/application-services'; -import { communityUpdateQueueName } from '@ocom/service-queue-storage'; -import type { CommunityUpdatePayload, QueueStorageOperations, QueueTriggerMetadata } from '@ocom/service-queue-storage'; +import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; +import { communityUpdateQueueName, extractQueueTriggerMetadata } from '@ocom/service-queue-storage'; /** * Creates an Azure Functions Storage Queue handler for processing community update messages. @@ -14,18 +14,8 @@ import type { CommunityUpdatePayload, QueueStorageOperations, QueueTriggerMetada */ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: ApplicationServicesFactory, queueService: QueueStorageOperations): StorageQueueHandler => { return async (queueEntry, context) => { - // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access - const id = (context.triggerMetadata?.['id'] as string) ?? ''; - // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access - const popReceipt = context.triggerMetadata?.['popReceipt'] as string | undefined; - // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access - const dequeueCount = context.triggerMetadata?.['dequeueCount'] as number | undefined; - // exactOptionalPropertyTypes: omit keys whose values are undefined rather than passing explicit undefined - const metadata: QueueTriggerMetadata = { - id, - ...(popReceipt === undefined ? {} : { popReceipt }), - ...(dequeueCount === undefined ? {} : { dequeueCount }), - }; + const metadata = extractQueueTriggerMetadata(context.triggerMetadata); + const { id, dequeueCount } = metadata; let message: Awaited>; try { message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); @@ -33,7 +23,13 @@ export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: A context.error(`${communityUpdateQueueName}: invalid message payload (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'}): ${err instanceof Error ? err.message : String(err)}`, err); return; } - const appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); + let appServices: Awaited>; + try { + appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); + } catch (err) { + context.error(`${communityUpdateQueueName}: failed to create application services (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'}): ${err instanceof Error ? err.message : String(err)}`, err); + throw err; + } const { communityId, name, domain, whiteLabelDomain, handle } = message.payload.eventPayload; try { await appServices.Community.Community.updateSettings({ diff --git a/packages/ocom/service-queue-storage/src/index.ts b/packages/ocom/service-queue-storage/src/index.ts index ef8e5ba12..68dedeca5 100644 --- a/packages/ocom/service-queue-storage/src/index.ts +++ b/packages/ocom/service-queue-storage/src/index.ts @@ -1,4 +1,5 @@ export type { QueueLoggingConfig, QueueTriggerMetadata } from '@cellix/service-queue-storage'; +export { extractQueueTriggerMetadata } from '@cellix/service-queue-storage'; export type { QueueStorageOperations } from './queue-storage.contract.ts'; export { ServiceQueueStorage } from './registry.ts'; export type { CommunityUpdatePayload } from './schemas/inbound/community-update.ts'; diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json index 111cc6c39..0a9db9018 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json @@ -3,7 +3,7 @@ "title": "CommunityUpdatePayload", "description": "Message for updating settings on an existing community", "type": "object", - "properties": { + "properties": { "eventTimestamp": { "type": "string", "pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9].[0-9][0-9][0-9]Z$", @@ -35,31 +35,31 @@ "eventPayload": { "type": "object", "properties": { - "communityId": { - "type": "string", - "description": "Identifier of the community to update (required for lookup)" - }, - "name": { - "type": "string", - "description": "Updated community name" - }, - "domain": { - "type": "string", - "description": "Updated community domain" - }, - "whiteLabelDomain": { - "type": ["string", "null"], - "description": "Updated white label domain (null to clear)" - }, - "handle": { - "type": ["string", "null"], - "description": "Updated community handle (null to clear)" - } - }, - "additionalProperties": false, - "required": ["communityId"] - } - }, - "additionalProperties": false, + "communityId": { + "type": "string", + "description": "Identifier of the community to update (required for lookup)" + }, + "name": { + "type": "string", + "description": "Updated community name" + }, + "domain": { + "type": "string", + "description": "Updated community domain" + }, + "whiteLabelDomain": { + "type": ["string", "null"], + "description": "Updated white label domain (null to clear)" + }, + "handle": { + "type": ["string", "null"], + "description": "Updated community handle (null to clear)" + } + }, + "additionalProperties": false, + "required": ["communityId"] + } + }, + "additionalProperties": false, "required": ["eventTimestamp", "eventUuid", "apiName", "eventPayload"] }