diff --git a/.snyk b/.snyk index 11a711c6d..473f8f392 100644 --- a/.snyk +++ b/.snyk @@ -33,3 +33,12 @@ ignore: reason: 'Transitive dependency in express, @docusaurus/core, @apollo/server, apollo-link-rest; not exploitable in current usage.' expires: '2026-01-19T00:00:00.000Z' created: '2026-01-05T09:39:00.000Z' + 'SNYK-JS-PNPMNPMCONF-14897556': + - '@docusaurus/core@3.9.2 > update-notifier@6.0.2 > latest-version@7.0.0 > package-json@8.1.1 > registry-auth-token@5.1.0 > @pnpm/npm-conf@2.3.1': + reason: 'Deep transitive dependency in Docusaurus; command injection vulnerability not exploitable in docs build context (static site generator, no untrusted input). Upgrade path blocked until Docusaurus updates update-notifier. Will reassess when Docusaurus 3.10+ is available.' + expires: '2026-07-08T00:00:00.000Z' + created: '2026-01-08T00:00:00.000Z' + - '@docusaurus/preset-classic@3.9.2 > @docusaurus/core@3.9.2 > update-notifier@6.0.2 > latest-version@7.0.0 > package-json@8.1.1 > registry-auth-token@5.1.0 > @pnpm/npm-conf@2.3.1': + reason: 'Deep transitive dependency in Docusaurus; command injection vulnerability not exploitable in docs build context (static site generator, no untrusted input). Upgrade path blocked until Docusaurus updates update-notifier. Will reassess when Docusaurus 3.10+ is available.' + expires: '2026-07-08T00:00:00.000Z' + created: '2026-01-08T00:00:00.000Z' diff --git a/apps/api/package.json b/apps/api/package.json index 141fb4f17..c7a3f4235 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,9 @@ "prebuild": "biome lint", "build": "tsc --build", "watch": "tsc -w", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:coverage:node": "vitest run --coverage", "test:watch": "vitest", "lint": "biome lint", "clean": "rimraf dist", @@ -45,6 +48,7 @@ "@cellix/typescript-config": "workspace:*", "@cellix/vitest-config": "workspace:*", "rimraf": "^6.0.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "catalog:" } } diff --git a/apps/api/src/cellix.test.ts b/apps/api/src/cellix.test.ts index dcceaa94d..262414e47 100644 --- a/apps/api/src/cellix.test.ts +++ b/apps/api/src/cellix.test.ts @@ -227,7 +227,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { }); When('application services factory is initialized', () => { - const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); expect(result.registerAzureFunctionHttpHandler).toBeDefined(); }); @@ -255,7 +255,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { When('initializeApplicationServices is called', () => { expect(() => { - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); }).toThrow('Context creator must be set before initializing application services'); }); @@ -268,7 +268,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { Given('a Cellix instance in app-services phase', () => { cellix = Cellix.initializeInfrastructureServices(() => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); }); When('an Azure Function HTTP handler is registered', () => { @@ -323,7 +323,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -368,7 +368,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { Given('a Cellix instance in handlers phase without context creator', () => { cellix = Cellix.initializeInfrastructureServices(() => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -395,7 +395,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -417,7 +417,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { Given('a started Cellix application with no registered services', async () => { cellix = Cellix.initializeInfrastructureServices(() => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -457,7 +457,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -500,7 +500,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -531,7 +531,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, @@ -568,7 +568,7 @@ test.for(feature, ({ BeforeEachScenario, Scenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystemOperation: vi.fn() })); cellix.registerAzureFunctionHttpHandler( 'test-handler', { authLevel: 'anonymous' }, diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 0d1db292f..46f717283 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -1,101 +1,116 @@ -import { app, type HttpFunctionOptions, type HttpHandler } from '@azure/functions'; +import { + app, + type HttpFunctionOptions, + type HttpHandler, + type TimerHandler, +} from '@azure/functions'; import type { ServiceBase } from '@cellix/api-services-spec'; import api, { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; -interface InfrastructureServiceRegistry { - /** - * Registers an infrastructure service with the application. - * - * @remarks - * Must be called during the {@link Phase | 'infrastructure'} phase. Each - * constructor key can be registered at most once. - * - * @typeParam T - The concrete service type. - * @param service - The service instance to register. - * @returns The registry (for chaining). - * - * @throws Error - If called outside the infrastructure phase or the service key is already registered. - */ - registerInfrastructureService(service: T): InfrastructureServiceRegistry; +interface InfrastructureServiceRegistry< + ContextType = unknown, + AppServices = unknown, +> { + /** + * Registers an infrastructure service with the application. + * + * @remarks + * Must be called during the {@link Phase | 'infrastructure'} phase. Each + * constructor key can be registered at most once. + * + * @typeParam T - The concrete service type. + * @param service - The service instance to register. + * @returns The registry (for chaining). + * + * @throws Error - If called outside the infrastructure phase or the service key is already registered. + */ + registerInfrastructureService( + service: T, + ): InfrastructureServiceRegistry; } interface ContextBuilder { - /** - * Defines the infrastructure context available for the application. - * - * @remarks - * Must be called during the {@link Phase | 'infrastructure'} phase. Stores the `contextCreator` - * and transitions the application to the {@link Phase | 'context'} phase. The provided function - * will be invoked during {@link startUp} (inside the Azure Functions `appStart` hook) after all - * infrastructure services have successfully started. Note that `ContextType` is defined in the - * `api-context-spec` package. - * - * @param contextCreator - Function that builds the infrastructure context from the initialized service registry. - * @returns An {@link ApplicationServicesInitializer} for configuring application services. - * - * @throws Error - If called outside the 'infrastructure' phase. - */ + /** + * Defines the infrastructure context available for the application. + * + * @remarks + * Must be called during the {@link Phase | 'infrastructure'} phase. Stores the `contextCreator` + * and transitions the application to the {@link Phase | 'context'} phase. The provided function + * will be invoked during {@link startUp} (inside the Azure Functions `appStart` hook) after all + * infrastructure services have successfully started. Note that `ContextType` is defined in the + * `api-context-spec` package. + * + * @param contextCreator - Function that builds the infrastructure context from the initialized service registry. + * @returns An {@link ApplicationServicesInitializer} for configuring application services. + * + * @throws Error - If called outside the 'infrastructure' phase. + */ setContext( - contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType, + contextCreator: ( + serviceRegistry: InitializedServiceRegistry, + ) => ContextType, ): ApplicationServicesInitializer; } interface ApplicationServicesInitializer { - /** - * Registers the factory that creates the request-scoped application services host. - * - * @remarks - * Must be called during the {@link Phase | 'context'} phase, after {@link setContext}. Stores the - * factory and transitions the application to the {@link Phase | 'app-services'} phase. The factory - * will be invoked during {@link startUp} to produce an {@link AppHost} that can build - * request-scoped services via {@link AppHost.forRequest}. Note that `AppServices` is defined in the - * `api-application-services` package. - * - * @param factory - Function that produces the application services host from the infrastructure context. - * @returns An {@link AzureFunctionHandlerRegistry} for registering HTTP handlers or starting the app. - * - * @throws Error - If the context creator has not been set via {@link setContext}, or if called outside the 'context' phase. - * - * @example - * ```ts - * initializeApplicationServices((infraCtx) => createAppHost(infraCtx)) - * .registerAzureFunctionHttpHandler('health', { authLevel: 'anonymous' }, (host) => async (req, fnCtx) => { - * const app = await host.forRequest(); - * return app.Health.handle(req, fnCtx); - * }); - * ``` - */ + /** + * Registers the factory that creates the request-scoped application services host. + * + * @remarks + * Must be called during the {@link Phase | 'context'} phase, after {@link setContext}. Stores the + * factory and transitions the application to the {@link Phase | 'app-services'} phase. The factory + * will be invoked during {@link startUp} to produce an {@link AppHost} that can build + * request-scoped services via {@link AppHost.forRequest}. Note that `AppServices` is defined in the + * `api-application-services` package. + * + * @param factory - Function that produces the application services host from the infrastructure context. + * @returns An {@link AzureFunctionHandlerRegistry} for registering HTTP handlers or starting the app. + * + * @throws Error - If the context creator has not been set via {@link setContext}, or if called outside the 'context' phase. + * + * @example + * ```ts + * initializeApplicationServices((infraCtx) => createAppHost(infraCtx)) + * .registerAzureFunctionHttpHandler('health', { authLevel: 'anonymous' }, (host) => async (req, fnCtx) => { + * const app = await host.forRequest(); + * return app.Health.handle(req, fnCtx); + * }); + * ``` + */ initializeApplicationServices( - factory: (infrastructureContext: ContextType) => AppHost + factory: (infrastructureContext: ContextType) => AppHost, ): AzureFunctionHandlerRegistry; } -interface AzureFunctionHandlerRegistry { - /** - * Registers an Azure Function HTTP endpoint. - * - * @remarks - * The `handlerCreator` is invoked per request and receives the application services host. - * Use it to create a request-scoped handler (e.g., to build per-request context). - * Registration is allowed in phases `'app-services'` and `'handlers'`. - * - * @param name - Function name to bind in Azure Functions. - * @param options - Azure Functions HTTP options (excluding the handler). - * @param handlerCreator - Factory that, given the app services host, returns an `HttpHandler`. - * @returns The registry (for chaining). - * - * @throws Error - If called before application services are initialized. - * - * @example - * ```ts - * registerAzureFunctionHttpHandler('graphql', { authLevel: 'anonymous' }, (host) => { - * return async (req, ctx) => { - * const app = await host.forRequest(req.headers.get('authorization') ?? undefined); - * return app.GraphQL.handle(req, ctx); - * }; - * }); - * ``` - */ +interface AzureFunctionHandlerRegistry< + ContextType = unknown, + AppServices = unknown, +> { + /** + * Registers an Azure Function HTTP endpoint. + * + * @remarks + * The `handlerCreator` is invoked per request and receives the application services host. + * Use it to create a request-scoped handler (e.g., to build per-request context). + * Registration is allowed in phases `'app-services'` and `'handlers'`. + * + * @param name - Function name to bind in Azure Functions. + * @param options - Azure Functions HTTP options (excluding the handler). + * @param handlerCreator - Factory that, given the app services host, returns an `HttpHandler`. + * @returns The registry (for chaining). + * + * @throws Error - If called before application services are initialized. + * + * @example + * ```ts + * registerAzureFunctionHttpHandler('graphql', { authLevel: 'anonymous' }, (host) => { + * return async (req, ctx) => { + * const app = await host.forRequest(req.headers.get('authorization') ?? undefined); + * return app.GraphQL.handle(req, ctx); + * }; + * }); + * ``` + */ registerAzureFunctionHttpHandler( name: string, options: Omit, @@ -103,18 +118,50 @@ interface AzureFunctionHandlerRegistry, ) => HttpHandler, ): AzureFunctionHandlerRegistry; - /** - * Finalizes configuration and starts the application. - * - * @remarks - * This registers function handlers with Azure Functions, starts all infrastructure - * services (in parallel), builds the infrastructure context, and initializes - * application services. After this resolves, the application is in the `'started'` phase. - * - * @returns A promise that resolves to the started application facade. - * - * @throws Error - If the context builder or application services factory have not been configured. - */ + /** + * Registers an Azure Function Timer trigger endpoint. + * + * @remarks + * The `handlerCreator` is invoked per timer execution and receives the application services host. + * Use it to create scheduled tasks (e.g., cleanup jobs, data retention enforcement). + * Registration is allowed in phases `'app-services'` and `'handlers'`. + * + * @param name - Function name to bind in Azure Functions. + * @param schedule - NCRONTAB expression for the timer schedule. + * @param handlerCreator - Factory that, given the app services host, returns a `TimerHandler`. + * @returns The registry (for chaining). + * + * @throws Error - If called before application services are initialized. + * + * @example + * ```ts + * registerAzureFunctionTimerHandler('cleanupExpiredConversations', '0 0 2 * * *', (host) => { + * return async (timer, ctx) => { + * const app = await host.forRequest(); + * await app.Cleanup.processExpiredConversations(); + * }; + * }); + * ``` + */ + registerAzureFunctionTimerHandler( + name: string, + schedule: string, + handlerCreator: ( + applicationServicesHost: AppHost, + ) => TimerHandler, + ): AzureFunctionHandlerRegistry; + /** + * Finalizes configuration and starts the application. + * + * @remarks + * This registers function handlers with Azure Functions, starts all infrastructure + * services (in parallel), builds the infrastructure context, and initializes + * application services. After this resolves, the application is in the `'started'` phase. + * + * @returns A promise that resolves to the started application facade. + * + * @throws Error - If the context builder or application services factory have not been configured. + */ startUp(): Promise>; } @@ -124,39 +171,42 @@ interface StartedApplication } interface InitializedServiceRegistry { - /** - * Retrieves a registered infrastructure service by its constructor key. - * - * @remarks - * Services are keyed by their constructor identity (not by name), which is - * minification-safe. You must pass the same class you used when registering - * the service; base classes or interfaces will not match. - * - * @typeParam T - The concrete service type. - * @param serviceKey - The service class (constructor) used at registration time. - * @returns The registered service instance. - * - * @throws Error - If no service is registered for the provided key. - * - * @example - * ```ts - * // registration - * registry.registerInfrastructureService(new BlobStorageService(...)); - * - * // lookup - * const blob = app.getInfrastructureService(BlobStorageService); - * await blob.startUp(); - * ``` - */ + /** + * Retrieves a registered infrastructure service by its constructor key. + * + * @remarks + * Services are keyed by their constructor identity (not by name), which is + * minification-safe. You must pass the same class you used when registering + * the service; base classes or interfaces will not match. + * + * @typeParam T - The concrete service type. + * @param serviceKey - The service class (constructor) used at registration time. + * @returns The registered service instance. + * + * @throws Error - If no service is registered for the provided key. + * + * @example + * ```ts + * // registration + * registry.registerInfrastructureService(new BlobStorageService(...)); + * + * // lookup + * const blob = app.getInfrastructureService(BlobStorageService); + * await blob.startUp(); + * ``` + */ getInfrastructureService(serviceKey: ServiceKey): T; get servicesInitialized(): boolean; } -type UninitializedServiceRegistry = InfrastructureServiceRegistry; - +type UninitializedServiceRegistry< + ContextType = unknown, + AppServices = unknown, +> = InfrastructureServiceRegistry; type RequestScopedHost = { - forRequest(rawAuthHeader?: string, hints?: H): Promise; + forRequest(rawAuthHeader?: string, hints?: H): Promise; + forSystemOperation(): S; }; type AppHost = RequestScopedHost; @@ -164,10 +214,25 @@ type AppHost = RequestScopedHost; interface PendingHandler { name: string; options: Omit; - handlerCreator: (applicationServicesHost: AppHost) => HttpHandler; + handlerCreator: ( + applicationServicesHost: AppHost, + ) => HttpHandler; +} + +interface PendingTimerHandler { + name: string; + schedule: string; + handlerCreator: ( + applicationServicesHost: AppHost, + ) => TimerHandler; } -type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'started'; +type Phase = + | 'infrastructure' + | 'context' + | 'app-services' + | 'handlers' + | 'started'; /** * Minification-safe key for service lookup: the service class (constructor). @@ -186,12 +251,24 @@ export class Cellix StartedApplication { private contextInternal: ContextType | undefined; - private appServicesHostInternal: RequestScopedHost | undefined; - private contextCreatorInternal: ((serviceRegistry: InitializedServiceRegistry) => ContextType) | undefined; - private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost) | undefined; + private appServicesHostInternal: + | RequestScopedHost + | undefined; + private contextCreatorInternal: + | ((serviceRegistry: InitializedServiceRegistry) => ContextType) + | undefined; + private appServicesHostBuilder: + | (( + infrastructureContext: ContextType, + ) => RequestScopedHost) + | undefined; private readonly tracer: Tracer; - private readonly servicesInternal: Map, ServiceBase> = new Map(); + private readonly servicesInternal: Map, ServiceBase> = + new Map(); private readonly pendingHandlers: Array> = []; + private readonly pendingTimerHandlers: Array< + PendingTimerHandler + > = []; private serviceInitializedInternal = false; private phase: Phase = 'infrastructure'; @@ -199,39 +276,42 @@ export class Cellix this.tracer = trace.getTracer('cellix:bootstrap'); } - /** - * Begins configuring a Cellix application by registering infrastructure services. - * - * @remarks - * This is the first step in the bootstrap sequence. It constructs a new Cellix instance in the - * {@link Phase | 'infrastructure'} phase, invokes your `registerServices` callback to register - * infrastructure services, and returns a {@link ContextBuilder} to define the infrastructure context. - * - * The typical flow is: {@link initializeInfrastructureServices} → {@link setContext} → - * {@link initializeApplicationServices} → {@link registerAzureFunctionHttpHandler} → {@link startUp}. - * - * @typeParam ContextType - The shape of your infrastructure context that will be created in {@link setContext}. - * @typeParam AppServices - The application services host type produced by {@link initializeApplicationServices}. - * - * @param registerServices - Callback invoked once to register infrastructure services. - * @returns A {@link ContextBuilder} for defining the infrastructure context. - * - * @example - * ```ts - * Cellix.initializeInfrastructureServices((r) => { - * r.registerInfrastructureService(new BlobStorageService(...)); - * r.registerInfrastructureService(new TokenValidationService(...)); - * }) - * .setContext((registry) => buildInfraContext(registry)) - * .initializeApplicationServices((ctx) => createAppHost(ctx)) - * .registerAzureFunctionHttpHandler('graphql', { authLevel: 'anonymous' }, (host) => async (req, fnCtx) => { - * const app = await host.forRequest(req.headers.get('authorization') ?? undefined); - * return app.GraphQL.handle(req, fnCtx); - * }) - * .startUp(); - * ``` - */ - public static initializeInfrastructureServices( + /** + * Begins configuring a Cellix application by registering infrastructure services. + * + * @remarks + * This is the first step in the bootstrap sequence. It constructs a new Cellix instance in the + * {@link Phase | 'infrastructure'} phase, invokes your `registerServices` callback to register + * infrastructure services, and returns a {@link ContextBuilder} to define the infrastructure context. + * + * The typical flow is: {@link initializeInfrastructureServices} → {@link setContext} → + * {@link initializeApplicationServices} → {@link registerAzureFunctionHttpHandler} → {@link startUp}. + * + * @typeParam ContextType - The shape of your infrastructure context that will be created in {@link setContext}. + * @typeParam AppServices - The application services host type produced by {@link initializeApplicationServices}. + * + * @param registerServices - Callback invoked once to register infrastructure services. + * @returns A {@link ContextBuilder} for defining the infrastructure context. + * + * @example + * ```ts + * Cellix.initializeInfrastructureServices((r) => { + * r.registerInfrastructureService(new BlobStorageService(...)); + * r.registerInfrastructureService(new TokenValidationService(...)); + * }) + * .setContext((registry) => buildInfraContext(registry)) + * .initializeApplicationServices((ctx) => createAppHost(ctx)) + * .registerAzureFunctionHttpHandler('graphql', { authLevel: 'anonymous' }, (host) => async (req, fnCtx) => { + * const app = await host.forRequest(req.headers.get('authorization') ?? undefined); + * return app.GraphQL.handle(req, fnCtx); + * }) + * .startUp(); + * ``` + */ + public static initializeInfrastructureServices< + ContextType, + AppServices = unknown, + >( registerServices: ( registry: UninitializedServiceRegistry, ) => void, @@ -241,17 +321,25 @@ export class Cellix return instance; } - public registerInfrastructureService(service: T): InfrastructureServiceRegistry { + public registerInfrastructureService( + service: T, + ): InfrastructureServiceRegistry { this.ensurePhase('infrastructure'); - const key = service.constructor as ServiceKey; + const key = service.constructor as ServiceKey; if (this.servicesInternal.has(key)) { - throw new Error(`Service already registered for constructor: ${service.constructor.name}`); + throw new Error( + `Service already registered for constructor: ${service.constructor.name}`, + ); } this.servicesInternal.set(key, service); 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'; @@ -259,11 +347,15 @@ export class Cellix } public initializeApplicationServices( - factory: (infrastructureContext: ContextType) => RequestScopedHost, + factory: ( + infrastructureContext: ContextType, + ) => RequestScopedHost, ): AzureFunctionHandlerRegistry { this.ensurePhase('context'); if (!this.contextCreatorInternal) { - throw new Error('Context creator must be set before initializing application services'); + throw new Error( + 'Context creator must be set before initializing application services', + ); } this.appServicesHostBuilder = factory; this.phase = 'app-services'; @@ -283,6 +375,19 @@ export class Cellix return this; } + public registerAzureFunctionTimerHandler( + name: string, + schedule: string, + handlerCreator: ( + applicationServicesHost: RequestScopedHost, + ) => TimerHandler, + ): AzureFunctionHandlerRegistry { + this.ensurePhase('app-services', 'handlers'); + this.pendingTimerHandlers.push({ name, schedule, handlerCreator }); + this.phase = 'handlers'; + return this; + } + public startUp(): Promise> { this.ensurePhase('handlers', 'app-services'); if (!this.contextCreatorInternal) { @@ -302,7 +407,23 @@ export class Cellix if (!this.appServicesHostInternal) { throw new Error('Application not started yet'); } - return h.handlerCreator(this.appServicesHostInternal)(request, context); + return h.handlerCreator(this.appServicesHostInternal)( + request, + context, + ); + }, + }); + } + + // Register timer handlers + for (const t of this.pendingTimerHandlers) { + app.timer(t.name, { + schedule: t.schedule, + handler: (timer, context) => { + if (!this.appServicesHostInternal) { + throw new Error('Application not started yet'); + } + return t.handlerCreator(this.appServicesHostInternal)(timer, context); }, }); } @@ -320,9 +441,13 @@ export class Cellix } this.contextInternal = this.contextCreatorInternal(this); if (!this.appServicesHostBuilder) { - throw new Error('Application services factory not provided. Call initializeApplicationServices().'); + throw new Error( + 'Application services factory not provided. Call initializeApplicationServices().', + ); } - this.appServicesHostInternal = this.appServicesHostBuilder(this.contextInternal); + this.appServicesHostInternal = this.appServicesHostBuilder( + this.contextInternal, + ); span.setStatus({ code: SpanStatusCode.OK }); console.log('Cellix started'); } catch (err) { @@ -342,33 +467,42 @@ export class Cellix app.hook.appTerminate(async () => { const root = api.context.active(); await api.context.with(root, async () => { - await this.tracer.startActiveSpan('cellix.appTerminate', async (span) => { - try { - await this.stopAllServicesWithTracing(); - span.setStatus({ code: SpanStatusCode.OK }); - console.log('Cellix stopped'); - } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); - if (err instanceof Error) { - span.recordException(err); + await this.tracer.startActiveSpan( + 'cellix.appTerminate', + async (span) => { + try { + await this.stopAllServicesWithTracing(); + span.setStatus({ code: SpanStatusCode.OK }); + console.log('Cellix stopped'); + } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR }); + if (err instanceof Error) { + span.recordException(err); + } + throw err; + } finally { + span.end(); } - throw err; - } finally { - span.end(); - } - }); + }, + ); }); }); } private ensurePhase(...allowed: Phase[]): void { if (!allowed.includes(this.phase)) { - throw new Error(`Invalid operation in phase '${this.phase}'. Allowed phases: ${allowed.join(', ')}`); + throw new Error( + `Invalid operation in phase '${this.phase}'. Allowed phases: ${allowed.join(', ')}`, + ); } } - public getInfrastructureService(serviceKey: ServiceKey): T { - const service = this.servicesInternal.get(serviceKey as ServiceKey); + public getInfrastructureService( + serviceKey: ServiceKey, + ): T { + const service = this.servicesInternal.get( + serviceKey as ServiceKey, + ); if (!service) { const name = (serviceKey as { name?: string }).name ?? 'UnknownService'; throw new Error(`Service not found: ${name}`); @@ -401,29 +535,45 @@ export class Cellix private async stopAllServicesWithTracing(): Promise { await this.iterateServicesWithTracing('stop', 'shutDown'); } - private async iterateServicesWithTracing(operationName: 'start' | 'stop', serviceMethod: 'startUp' | 'shutDown'): Promise { + private async iterateServicesWithTracing( + operationName: 'start' | 'stop', + serviceMethod: 'startUp' | 'shutDown', + ): Promise { const operationFullName = `${operationName.charAt(0).toUpperCase() + operationName.slice(1)}Service`; - const operationActionPending = operationName === 'start' ? 'starting' : 'stopping'; - const operationActionCompleted = operationName === 'start' ? 'started' : 'stopped'; + const operationActionPending = + operationName === 'start' ? 'starting' : 'stopping'; + const operationActionCompleted = + operationName === 'start' ? 'started' : 'stopped'; await Promise.all( Array.from(this.servicesInternal.entries()).map(([ctor, service]) => - this.tracer.startActiveSpan(`Service ${(ctor as unknown as { name?: string }).name ?? 'Service'} ${operationName}`, async (span) => { - try { - const ctorName = (ctor as unknown as { name?: string }).name ?? 'Service'; - console.log(`${operationFullName}: Service ${ctorName} ${operationActionPending}`); - await service[serviceMethod](); - span.setStatus({ code: SpanStatusCode.OK, message: `Service ${ctorName} ${operationActionCompleted}` }); - console.log(`${operationFullName}: Service ${ctorName} ${operationActionCompleted}`); - } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); - if (err instanceof Error) { - span.recordException(err); + this.tracer.startActiveSpan( + `Service ${(ctor as unknown as { name?: string }).name ?? 'Service'} ${operationName}`, + async (span) => { + try { + const ctorName = + (ctor as unknown as { name?: string }).name ?? 'Service'; + console.log( + `${operationFullName}: Service ${ctorName} ${operationActionPending}`, + ); + await service[serviceMethod](); + span.setStatus({ + code: SpanStatusCode.OK, + message: `Service ${ctorName} ${operationActionCompleted}`, + }); + console.log( + `${operationFullName}: Service ${ctorName} ${operationActionCompleted}`, + ); + } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR }); + if (err instanceof Error) { + span.recordException(err); + } + throw err; + } finally { + span.end(); } - throw err; - } finally { - span.end(); - } - }), + }, + ), ), ); } diff --git a/apps/api/src/handlers/conversation-cleanup-handler.test.ts b/apps/api/src/handlers/conversation-cleanup-handler.test.ts new file mode 100644 index 000000000..1b798168e --- /dev/null +++ b/apps/api/src/handlers/conversation-cleanup-handler.test.ts @@ -0,0 +1,366 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi, type MockedFunction } from 'vitest'; +import type { Timer, InvocationContext } from '@azure/functions'; +import type { ApplicationServicesFactory } from '@sthrift/application-services'; +import { conversationCleanupHandlerCreator } from './conversation-cleanup-handler.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature( + path.resolve(__dirname, 'features/conversation-cleanup-handler.feature'), +); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let mockApplicationServicesFactory: ApplicationServicesFactory; + let mockTimer: Timer; + let mockContext: InvocationContext; + let mockLog: MockedFunction<(...args: unknown[]) => void>; + + BeforeEachScenario(() => { + mockLog = vi.fn(); + mockContext = { + log: mockLog, + } as unknown as InvocationContext; + + mockTimer = { + isPastDue: false, + } as Timer; + + const mockAppServices = { + Conversation: { + Conversation: { + processConversationsForArchivedListings: vi.fn().mockResolvedValue({ + processedCount: 10, + scheduledCount: 5, + timestamp: new Date(), + errors: [], + }), + processConversationsForArchivedReservationRequests: vi + .fn() + .mockResolvedValue({ + processedCount: 8, + scheduledCount: 3, + timestamp: new Date(), + errors: [], + }), + }, + }, + }; + + mockApplicationServicesFactory = { + forSystemOperation: vi.fn().mockReturnValue(mockAppServices), + } as unknown as ApplicationServicesFactory; + }); + + Scenario( + 'Executing both cleanup phases successfully', + ({ Given, When, Then, And }) => { + Given('a conversation cleanup handler', () => { + // Setup is done in BeforeEachScenario + }); + + When('the timer trigger fires', async () => { + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + await handler(mockTimer, mockContext); + }); + + Then('both cleanup phases should execute successfully', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('[ConversationCleanup] Timer trigger fired'), + ); + }); + + And('the handler should log listings cleanup completion', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Listings cleanup complete. Processed: 10'), + ); + }); + + And( + 'the handler should log reservation requests cleanup completion', + () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + 'Reservation requests cleanup complete. Processed: 8', + ), + ); + }, + ); + + And('the handler should log overall totals', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + 'Overall totals - Processed: 18, Scheduled: 8', + ), + ); + }); + }, + ); + + Scenario('Timer is past due', ({ Given, When, Then, And }) => { + Given('a conversation cleanup handler', () => { + mockTimer.isPastDue = true; + }); + + When('the timer trigger fires and is past due', async () => { + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + await handler(mockTimer, mockContext); + }); + + Then('the handler should log that the timer is past due', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Timer is past due'), + ); + }); + + And('both cleanup phases should still execute', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Listings cleanup complete'), + ); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Reservation requests cleanup complete'), + ); + }); + }); + + Scenario( + 'Logging errors from listings cleanup without preventing reservation cleanup', + ({ Given, When, Then, And }) => { + Given('a conversation cleanup handler', () => { + // Setup is done in BeforeEachScenario + }); + + When('the listings cleanup phase has errors', async () => { + const mockAppServices = { + Conversation: { + Conversation: { + processConversationsForArchivedListings: vi + .fn() + .mockResolvedValue({ + processedCount: 10, + scheduledCount: 5, + timestamp: new Date(), + errors: ['Error 1', 'Error 2'], + }), + processConversationsForArchivedReservationRequests: vi + .fn() + .mockResolvedValue({ + processedCount: 8, + scheduledCount: 3, + timestamp: new Date(), + errors: [], + }), + }, + }, + }; + + mockApplicationServicesFactory.forSystemOperation = vi + .fn() + .mockReturnValue(mockAppServices); + + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + await handler(mockTimer, mockContext); + }); + + Then('the handler should log the listings errors', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Listings errors: Error 1; Error 2'), + ); + }); + + And('the reservation requests cleanup should still execute', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Reservation requests cleanup complete'), + ); + }); + }, + ); + + Scenario( + 'Continuing with reservation cleanup even if listings cleanup throws', + ({ Given, When, Then, And }) => { + let error: Error | undefined; + + Given('a conversation cleanup handler', () => { + // Setup is done in BeforeEachScenario + }); + + When('the listings cleanup phase throws a fatal error', async () => { + const mockAppServices = { + Conversation: { + Conversation: { + processConversationsForArchivedListings: vi + .fn() + .mockRejectedValue(new Error('Listings DB connection failed')), + processConversationsForArchivedReservationRequests: vi + .fn() + .mockResolvedValue({ + processedCount: 8, + scheduledCount: 3, + timestamp: new Date(), + errors: [], + }), + }, + }, + }; + + mockApplicationServicesFactory.forSystemOperation = vi + .fn() + .mockReturnValue(mockAppServices); + + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + + try { + await handler(mockTimer, mockContext); + } catch (err) { + error = err as Error; + } + }); + + Then('the handler should log the fatal listings error', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Fatal error in listings cleanup'), + ); + }); + + And('the reservation requests cleanup should still execute', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Reservation requests cleanup complete'), + ); + }); + + And('the handler should throw the listings error', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Listings DB connection failed'); + }); + }, + ); + + Scenario( + 'Throwing if both cleanup phases fail', + ({ Given, When, Then, And }) => { + let error: Error | undefined; + + Given('a conversation cleanup handler', () => { + // Setup is done in BeforeEachScenario + }); + + When('both cleanup phases throw fatal errors', async () => { + const mockAppServices = { + Conversation: { + Conversation: { + processConversationsForArchivedListings: vi + .fn() + .mockRejectedValue(new Error('Listings error')), + processConversationsForArchivedReservationRequests: vi + .fn() + .mockRejectedValue(new Error('Reservations error')), + }, + }, + }; + + mockApplicationServicesFactory.forSystemOperation = vi + .fn() + .mockReturnValue(mockAppServices); + + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + + try { + await handler(mockTimer, mockContext); + } catch (err) { + error = err as Error; + } + }); + + Then('the handler should log that both phases failed', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Both cleanup phases failed'), + ); + }); + + And('the handler should throw a combined error', () => { + expect(error).toBeDefined(); + expect(error?.message).toContain('Both cleanup phases failed'); + }); + }, + ); + + Scenario( + 'Throwing only reservation error if only reservation phase fails', + ({ Given, When, Then, And }) => { + let error: Error | undefined; + + Given('a conversation cleanup handler', () => { + // Setup is done in BeforeEachScenario + }); + + When( + 'only the reservation cleanup phase throws a fatal error', + async () => { + const mockAppServices = { + Conversation: { + Conversation: { + processConversationsForArchivedListings: vi + .fn() + .mockResolvedValue({ + processedCount: 10, + scheduledCount: 5, + timestamp: new Date(), + errors: [], + }), + processConversationsForArchivedReservationRequests: vi + .fn() + .mockRejectedValue(new Error('Reservations DB error')), + }, + }, + }; + + mockApplicationServicesFactory.forSystemOperation = vi + .fn() + .mockReturnValue(mockAppServices); + + const handler = conversationCleanupHandlerCreator( + mockApplicationServicesFactory, + ); + + try { + await handler(mockTimer, mockContext); + } catch (err) { + error = err as Error; + } + }, + ); + + Then('the listings cleanup should complete successfully', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Listings cleanup complete'), + ); + }); + + And('the handler should log the fatal reservation error', () => { + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + 'Fatal error in reservation requests cleanup', + ), + ); + }); + + And('the handler should throw the reservation error', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Reservations DB error'); + }); + }, + ); +}); diff --git a/apps/api/src/handlers/conversation-cleanup-handler.ts b/apps/api/src/handlers/conversation-cleanup-handler.ts new file mode 100644 index 000000000..4771472f7 --- /dev/null +++ b/apps/api/src/handlers/conversation-cleanup-handler.ts @@ -0,0 +1,105 @@ +import type { TimerHandler, Timer, InvocationContext } from '@azure/functions'; +import type { ApplicationServicesFactory } from '@sthrift/application-services'; + +export const conversationCleanupHandlerCreator = ( + applicationServicesFactory: ApplicationServicesFactory, +): TimerHandler => { + return async (timer: Timer, context: InvocationContext): Promise => { + context.log( + `[ConversationCleanup] Timer trigger fired at ${new Date().toISOString()}`, + ); + + if (timer.isPastDue) { + context.log( + '[ConversationCleanup] Timer is past due, running catch-up execution', + ); + } + + // Use forSystemOperation() to get SystemPassport with canManageConversation permission + // This is required because scheduleForDeletion() checks for this permission + const appServices = applicationServicesFactory.forSystemOperation(); + + let listingsResult = { + processedCount: 0, + scheduledCount: 0, + errors: [] as string[], + }; + let listingsFatalError: Error | null = null; + + try { + listingsResult = + await appServices.Conversation.Conversation.processConversationsForArchivedListings(); + + context.log( + `[ConversationCleanup] Listings cleanup complete. Processed: ${listingsResult.processedCount}, Scheduled: ${listingsResult.scheduledCount}, Errors: ${listingsResult.errors.length}`, + ); + + if (listingsResult.errors.length > 0) { + context.log( + `[ConversationCleanup] Listings errors: ${listingsResult.errors.join('; ')}`, + ); + } + } catch (error) { + listingsFatalError = + error instanceof Error ? error : new Error(String(error)); + context.log( + `[ConversationCleanup] Fatal error in listings cleanup: ${listingsFatalError.message}`, + ); + } + + let reservationsResult = { + processedCount: 0, + scheduledCount: 0, + errors: [] as string[], + }; + let reservationsFatalError: Error | null = null; + + try { + reservationsResult = + await appServices.Conversation.Conversation.processConversationsForArchivedReservationRequests(); + + context.log( + `[ConversationCleanup] Reservation requests cleanup complete. Processed: ${reservationsResult.processedCount}, Scheduled: ${reservationsResult.scheduledCount}, Errors: ${reservationsResult.errors.length}`, + ); + + if (reservationsResult.errors.length > 0) { + context.log( + `[ConversationCleanup] Reservation requests errors: ${reservationsResult.errors.join('; ')}`, + ); + } + } catch (error) { + reservationsFatalError = + error instanceof Error ? error : new Error(String(error)); + context.log( + `[ConversationCleanup] Fatal error in reservation requests cleanup: ${reservationsFatalError.message}`, + ); + } + + const totalProcessed = + listingsResult.processedCount + reservationsResult.processedCount; + const totalScheduled = + listingsResult.scheduledCount + reservationsResult.scheduledCount; + const totalErrors = + listingsResult.errors.length + reservationsResult.errors.length; + + context.log( + `[ConversationCleanup] Overall totals - Processed: ${totalProcessed}, Scheduled: ${totalScheduled}, Errors: ${totalErrors}`, + ); + + if (listingsFatalError && reservationsFatalError) { + context.log( + '[ConversationCleanup] Both cleanup phases failed - throwing combined error', + ); + throw new Error( + `Both cleanup phases failed. Listings: ${listingsFatalError.message}; Reservations: ${reservationsFatalError.message}`, + ); + } + + if (listingsFatalError) { + throw listingsFatalError; + } + if (reservationsFatalError) { + throw reservationsFatalError; + } + }; +}; diff --git a/apps/api/src/handlers/features/conversation-cleanup-handler.feature b/apps/api/src/handlers/features/conversation-cleanup-handler.feature new file mode 100644 index 000000000..4484d281e --- /dev/null +++ b/apps/api/src/handlers/features/conversation-cleanup-handler.feature @@ -0,0 +1,44 @@ +Feature: Conversation Cleanup Handler + As a system administrator + I want to automatically clean up conversations for archived listings and reservation requests + So that data retention policies are enforced + + Scenario: Executing both cleanup phases successfully + Given a conversation cleanup handler + When the timer trigger fires + Then both cleanup phases should execute successfully + And the handler should log listings cleanup completion + And the handler should log reservation requests cleanup completion + And the handler should log overall totals + + Scenario: Timer is past due + Given a conversation cleanup handler + When the timer trigger fires and is past due + Then the handler should log that the timer is past due + And both cleanup phases should still execute + + Scenario: Logging errors from listings cleanup without preventing reservation cleanup + Given a conversation cleanup handler + When the listings cleanup phase has errors + Then the handler should log the listings errors + And the reservation requests cleanup should still execute + + Scenario: Continuing with reservation cleanup even if listings cleanup throws + Given a conversation cleanup handler + When the listings cleanup phase throws a fatal error + Then the handler should log the fatal listings error + And the reservation requests cleanup should still execute + And the handler should throw the listings error + + Scenario: Throwing if both cleanup phases fail + Given a conversation cleanup handler + When both cleanup phases throw fatal errors + Then the handler should log that both phases failed + And the handler should throw a combined error + + Scenario: Throwing only reservation error if only reservation phase fails + Given a conversation cleanup handler + When only the reservation cleanup phase throws a fatal error + Then the listings cleanup should complete successfully + And the handler should log the fatal reservation error + And the handler should throw the reservation error diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5fca8822f..97c4c0317 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -24,17 +24,17 @@ import { ServiceMessagingMock } from '@sthrift/messaging-service-mock'; import { graphHandlerCreator } from '@sthrift/graphql'; import { restHandlerCreator } from '@sthrift/rest'; -import type {PaymentService} from '@cellix/payment-service'; +import { conversationCleanupHandlerCreator } from './handlers/conversation-cleanup-handler.ts'; + +import type { PaymentService } from '@cellix/payment-service'; import { PaymentServiceMock } from '@sthrift/payment-service-mock'; import { PaymentServiceCybersource } from '@sthrift/payment-service-cybersource'; - const { NODE_ENV } = process.env; const isDevelopment = NODE_ENV === 'development'; Cellix.initializeInfrastructureServices( (serviceRegistry) => { - serviceRegistry .registerInfrastructureService( new ServiceMongoose( @@ -47,11 +47,15 @@ Cellix.initializeInfrastructureServices( new ServiceTokenValidation(TokenValidationConfig.portalTokens), ) .registerInfrastructureService( - isDevelopment ? new ServiceMessagingMock() : new ServiceMessagingTwilio(), + isDevelopment + ? new ServiceMessagingMock() + : new ServiceMessagingTwilio(), ) .registerInfrastructureService( - isDevelopment ? new PaymentServiceMock() : new PaymentServiceCybersource() - ); + isDevelopment + ? new PaymentServiceMock() + : new PaymentServiceCybersource(), + ); }, ) .setContext((serviceRegistry) => { @@ -62,12 +66,20 @@ Cellix.initializeInfrastructureServices( ); const messagingService = isDevelopment - ? serviceRegistry.getInfrastructureService(ServiceMessagingMock) - : serviceRegistry.getInfrastructureService(ServiceMessagingTwilio); - - const paymentService = isDevelopment - ? serviceRegistry.getInfrastructureService(PaymentServiceMock) - : serviceRegistry.getInfrastructureService(PaymentServiceCybersource); + ? serviceRegistry.getInfrastructureService( + ServiceMessagingMock, + ) + : serviceRegistry.getInfrastructureService( + ServiceMessagingTwilio, + ); + + const paymentService = isDevelopment + ? serviceRegistry.getInfrastructureService( + PaymentServiceMock, + ) + : serviceRegistry.getInfrastructureService( + PaymentServiceCybersource, + ); const { domainDataSource } = dataSourcesFactory.withSystemPassport(); RegisterEventHandlers(domainDataSource); @@ -79,7 +91,7 @@ Cellix.initializeInfrastructureServices( ServiceTokenValidation, ), paymentService, - messagingService, + messagingService, }; }) .initializeApplicationServices((context) => @@ -98,4 +110,9 @@ Cellix.initializeInfrastructureServices( { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator, ) + .registerAzureFunctionTimerHandler( + 'conversationCleanup', + '0 0 2 * * *', // Runs every day at 02:00 UTC + conversationCleanupHandlerCreator, + ) .startUp(); diff --git a/apps/docs/docs/security-requirements/0012-conversation-data-retention.md b/apps/docs/docs/security-requirements/0012-conversation-data-retention.md new file mode 100644 index 000000000..5c452c664 --- /dev/null +++ b/apps/docs/docs/security-requirements/0012-conversation-data-retention.md @@ -0,0 +1,105 @@ +--- +sidebar_position: 12 +sidebar_label: 0012 Conversation Data Retention +description: "Conversations are automatically deleted 6 months after the associated listing is archived" +status: implemented +contact: +date: 2025-06-27 +deciders: +consulted: +informed: +--- + +# Conversation Data Retention + +## Security Requirement Statement +Conversations must be automatically deleted 6 months after the associated listing is archived to comply with data minimization principles and reduce data exposure risk. + +## Control Classification +- **Timing Control Category**: Detective/Corrective +- **Nature Control Category**: Technical +- **Status**: Implemented +- **Date Identified**: 2025-06-27 +- **Date First Implemented**: 2025-06-27 +- **Date Last Reviewed**: 2025-06-27 +- **Date Retired**: N/A + +## Implementation Details + +### TTL-Based Automatic Deletion (Primary Mechanism) +- **MongoDB TTL Index**: Conversations have an `expiresAt` field with a TTL index (`expires: 0`) +- **Automatic Cleanup**: MongoDB automatically removes documents shortly after the `expiresAt` timestamp is reached via a background TTL process +- **Retention Period**: 6 months (180 days) from listing archival date +- **Trigger**: When a listing is archived, all associated conversations are scheduled for deletion + +### Domain Layer Implementation +- `Conversation.scheduleForDeletion(archivalDate: Date)` method sets the expiration date +- `Conversation.expiresAt` property stores the deletion timestamp +- Authorization enforced through `ConversationDomainPermissions.isSystemAccount` permission +- Only the system passport can schedule conversations for deletion + +### Application Services + +**ScheduleConversationDeletionByListing Service** +- Triggered when a listing is archived +- Finds all conversations associated with the listing +- Schedules each conversation for deletion with a 6-month retention period +- Uses batch processing with configurable batch sizes +- Includes OpenTelemetry tracing for observability + +**CleanupArchivedConversations Service** +- Fallback mechanism for data integrity +- Azure Functions timer trigger runs daily at 2:00 AM UTC +- Queries listings archived more than 6 months ago without corresponding conversation deletions +- Schedules any missed conversations for immediate deletion +- Handles partial failures gracefully with error collection + +### Persistence Layer +- `ConversationModel.expiresAt` field in MongoDB schema +- TTL index configured with `expires: 0` for automatic deletion shortly after the expiration time +- `getByListingId()` repository method for finding conversations by listing +- `getExpired()` repository method for querying already-expired conversations (fallback) + +## Compensating Controls + +### Multi-Layer Deletion Approach +- **Primary**: TTL index provides automatic deletion without application intervention +- **Secondary**: Scheduled cleanup service catches any missed deletions +- **Fallback**: `getExpired()` method allows manual cleanup if both automated systems fail + +### Authorization Framework +- Only `SystemPassport` can schedule conversation deletion +- Domain layer enforces permission checks before setting expiration +- Passport/Visa pattern prevents unauthorized data retention modifications + +### Audit Trail +- OpenTelemetry tracing records all deletion scheduling operations +- Span events capture individual conversation processing +- Error spans track any failures during batch processing + +### Data Integrity +- Conversations linked to listings via a `listingId` foreign key +- Cascade deletion triggered by listing archival status change +- No orphaned conversations due to comprehensive cleanup mechanism + +## Success Criteria + +### Automatic Deletion +- All conversations for archived listings are deleted shortly after 6 months +- MongoDB TTL index and the daily cleanup job remove expired documents; deletions are performed by MongoDB's background TTL task and may be delayed beyond the exact expiration time +- No manual intervention is typically required for the standard deletion flow, aside from operational responses to observed failures + +### Authorization Enforcement +- Domain layer prevents unauthorized expiration date modifications +- Only system-level operations can schedule deletions +- User passports cannot extend or prevent deletion + +### Observability +- Deletion scheduling operations are traced with OpenTelemetry +- Batch processing results are logged with counts +- Failures are recorded with appropriate error context + +### Fallback Coverage +- Daily cleanup job catches any missed deletions +- Partial failures don't prevent other conversations from being processed +- Error collection enables investigation of persistent issues diff --git a/apps/ui-sharethrift/package.json b/apps/ui-sharethrift/package.json index f91951742..01e0cac28 100644 --- a/apps/ui-sharethrift/package.json +++ b/apps/ui-sharethrift/package.json @@ -28,7 +28,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.8.0", + "react-router-dom": "^7.12.0", "rxjs": "^7.8.2" }, "devDependencies": { diff --git a/packages/cellix/ui-core/package.json b/packages/cellix/ui-core/package.json index b5fa12e17..9cdb63efe 100644 --- a/packages/cellix/ui-core/package.json +++ b/packages/cellix/ui-core/package.json @@ -49,7 +49,7 @@ "@vitest/coverage-v8": "catalog:", "jsdom": "^26.1.0", "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.9.3", + "react-router-dom": "^7.12.0", "rimraf": "^6.0.1", "storybook": "catalog:", "typescript": "^5.8.3", diff --git a/packages/sthrift/application-services/package.json b/packages/sthrift/application-services/package.json index 9aa24ebda..46a97f310 100644 --- a/packages/sthrift/application-services/package.json +++ b/packages/sthrift/application-services/package.json @@ -26,7 +26,8 @@ "@sthrift/context-spec": "workspace:*", "@sthrift/domain": "workspace:*", "@sthrift/persistence": "workspace:*", - "@cellix/payment-service": "workspace:*" + "@cellix/payment-service": "workspace:*", + "@opentelemetry/api": "^1.9.0" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.test.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.test.ts new file mode 100644 index 000000000..6547a3bcf --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.test.ts @@ -0,0 +1,352 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { DataSources } from '@sthrift/persistence'; +import { expect, vi } from 'vitest'; +import { + type CleanupResult, + processConversationsForArchivedListings, +} from './cleanup-archived-conversations.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature( + path.resolve(__dirname, 'features/cleanup-archived-conversations.feature'), +); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let mockDataSources: DataSources; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockListingReadRepo: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockConversationReadRepo: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockUnitOfWork: any; + let result: CleanupResult | undefined; + let thrownError: Error | undefined; + + BeforeEachScenario(() => { + mockListingReadRepo = { + getByStates: vi.fn(), + }; + + mockConversationReadRepo = { + getByListingId: vi.fn(), + }; + + mockUnitOfWork = { + withScopedTransaction: vi.fn(), + }; + + mockDataSources = { + readonlyDataSource: { + Listing: { + ItemListing: { + ItemListingReadRepo: mockListingReadRepo, + }, + }, + Conversation: { + Conversation: { + ConversationReadRepo: mockConversationReadRepo, + }, + }, + }, + domainDataSource: { + Conversation: { + Conversation: { + ConversationUnitOfWork: mockUnitOfWork, + }, + }, + }, + // biome-ignore lint/suspicious/noExplicitAny: Test mock type assertion + } as any; + + result = undefined; + thrownError = undefined; + }); + + Scenario( + 'Successfully processing conversations for archived listings', + ({ Given, When, Then, And }) => { + let mockConversations: { + id: string; + expiresAt: Date | undefined; + scheduleForDeletion: ReturnType; + }[]; + + Given( + 'archived listings exist with states "Expired" and "Cancelled"', + () => { + mockListingReadRepo.getByStates.mockResolvedValue([ + { + id: 'listing-1', + state: 'Expired', + updatedAt: new Date('2025-01-01'), + }, + { + id: 'listing-2', + state: 'Cancelled', + updatedAt: new Date('2025-01-02'), + }, + ]); + }, + ); + + And('each listing has conversations without expiration dates', () => { + mockConversations = [ + { + id: 'conv-1', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + { + id: 'conv-2', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + ]; + + mockConversationReadRepo.getByListingId.mockResolvedValue([ + mockConversations[0], + ]); + + mockUnitOfWork.withScopedTransaction.mockImplementation( + async ( + callback: (repo: { + getByListingId: typeof vi.fn; + get: typeof vi.fn; + save: typeof vi.fn; + }) => Promise, + ) => { + const mockRepo = { + getByListingId: vi.fn(() => Promise.resolve(mockConversations)), + get: vi.fn((id: string) => + mockConversations.find((c) => c.id === id), + ), + save: vi.fn(), + }; + await callback(mockRepo); + }, + ); + }); + + When( + 'the processConversationsForArchivedListings command is executed', + async () => { + result = + await processConversationsForArchivedListings(mockDataSources); + }, + ); + + Then('the result should show the correct processed count', () => { + expect(result).toBeDefined(); + expect(result?.processedCount).toBeGreaterThan(0); + }); + + And( + 'conversations without expiresAt should be scheduled for deletion', + () => { + expect(result?.scheduledCount).toBeGreaterThan(0); + }, + ); + + And('the timestamp should be set', () => { + expect(result?.timestamp).toBeInstanceOf(Date); + }); + }, + ); + + Scenario( + 'Processing when no archived listings exist', + ({ Given, When, Then, And }) => { + Given('no archived listings exist', () => { + mockListingReadRepo.getByStates.mockResolvedValue([]); + }); + + When( + 'the processConversationsForArchivedListings command is executed', + async () => { + result = + await processConversationsForArchivedListings(mockDataSources); + }, + ); + + Then('the result should show 0 processed', () => { + expect(result?.processedCount).toBe(0); + }); + + And('the result should show 0 scheduled', () => { + expect(result?.scheduledCount).toBe(0); + }); + }, + ); + + Scenario( + 'Skipping conversations that already have expiration dates', + ({ Given, When, Then, And }) => { + Given('archived listings exist with conversations', () => { + mockListingReadRepo.getByStates.mockResolvedValue([ + { + id: 'listing-1', + state: 'Expired', + updatedAt: new Date('2025-01-01'), + }, + ]); + }); + + And('some conversations already have expiresAt set', () => { + const conversationsWithExpiry = [ + { + id: 'conv-already-scheduled', + expiresAt: new Date('2025-07-01'), + scheduleForDeletion: vi.fn(), + }, + ]; + + mockConversationReadRepo.getByListingId.mockResolvedValue( + conversationsWithExpiry, + ); + + mockUnitOfWork.withScopedTransaction.mockImplementation( + async ( + callback: (repo: { + getByListingId: typeof vi.fn; + get: typeof vi.fn; + save: typeof vi.fn; + }) => Promise, + ) => { + const mockRepo = { + getByListingId: vi.fn(() => + Promise.resolve(conversationsWithExpiry), + ), + get: vi.fn((id: string) => + conversationsWithExpiry.find((c) => c.id === id), + ), + save: vi.fn(), + }; + await callback(mockRepo); + }, + ); + }); + + When( + 'the processConversationsForArchivedListings command is executed', + async () => { + result = + await processConversationsForArchivedListings(mockDataSources); + }, + ); + + Then('only conversations without expiresAt should be scheduled', () => { + // The one conversation with expiresAt should be processed but not scheduled + expect(result?.processedCount).toBe(1); + expect(result?.scheduledCount).toBe(0); + }); + }, + ); + + Scenario( + 'Handling partial failures during cleanup', + ({ Given, When, Then, And }) => { + Given('archived listings exist', () => { + mockListingReadRepo.getByStates.mockResolvedValue([ + { + id: 'listing-good', + state: 'Expired', + updatedAt: new Date('2025-01-01'), + }, + { + id: 'listing-bad', + state: 'Cancelled', + updatedAt: new Date('2025-01-02'), + }, + ]); + }); + + And('an error occurs while processing one listing', () => { + let callCount = 0; + mockUnitOfWork.withScopedTransaction.mockImplementation( + async ( + callback: (repo: { + getByListingId: typeof vi.fn; + get: typeof vi.fn; + save: typeof vi.fn; + }) => Promise, + ) => { + callCount++; + const mockRepo = { + getByListingId: vi.fn(() => { + if (callCount === 1) { + // First listing succeeds + return Promise.resolve([ + { + id: 'conv-1', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + ]); + } + // Second listing fails + return Promise.reject( + new Error('Failed to fetch conversations'), + ); + }), + get: vi.fn(() => ({ + id: 'conv-1', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + })), + save: vi.fn(), + }; + await callback(mockRepo); + }, + ); + }); + + When( + 'the processConversationsForArchivedListings command is executed', + async () => { + result = + await processConversationsForArchivedListings(mockDataSources); + }, + ); + + Then('other listings should still be processed', () => { + expect(result?.processedCount).toBeGreaterThan(0); + }); + + And('the errors array should contain the failure message', () => { + expect(result?.errors.length).toBeGreaterThan(0); + expect(result?.errors[0]).toContain('listing-bad'); + }); + }, + ); + + Scenario( + 'Handling complete failure during cleanup', + ({ Given, When, Then }) => { + Given('the repository throws an error', () => { + mockListingReadRepo.getByStates.mockRejectedValue( + new Error('Database connection failed'), + ); + }); + + When( + 'the processConversationsForArchivedListings command is executed', + async () => { + try { + result = + await processConversationsForArchivedListings(mockDataSources); + } catch (error) { + thrownError = error as Error; + } + }, + ); + + Then('an error should be thrown', () => { + expect(thrownError).toBeDefined(); + expect(thrownError?.message).toBe('Database connection failed'); + }); + }, + ); +}); diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.ts new file mode 100644 index 000000000..76c245164 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-conversations.ts @@ -0,0 +1,56 @@ +import type { DataSources } from '@sthrift/persistence'; +import { Domain } from '@sthrift/domain'; +import { processArchivedEntities } from './cleanup-shared.ts'; +import type { CleanupResult } from './cleanup.types.ts'; + +const ARCHIVED_LISTING_STATES = [ + Domain.Contexts.Listing.ItemListing.ItemListingValueObjects.ListingStateEnum + .Expired, + Domain.Contexts.Listing.ItemListing.ItemListingValueObjects.ListingStateEnum + .Cancelled, +]; + +export function processConversationsForArchivedListings( + dataSources: DataSources, +): Promise { + return processArchivedEntities({ + spanName: 'conversation.processConversationsForArchivedListings', + entityLabel: 'listing', + fetchEntities: () => + dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getByStates( + ARCHIVED_LISTING_STATES, + ), + processEntity: async (listing) => { + let processed = 0; + let scheduled = 0; + const errors: string[] = []; + + await dataSources.domainDataSource.Conversation.Conversation.ConversationUnitOfWork.withScopedTransaction( + async (repo) => { + const conversations = await repo.getByListingId(listing.id); + processed += conversations.length; + + const conversationsToSchedule = conversations.filter( + (c) => !c.expiresAt, + ); + + // NOTE: Use sharingPeriodEnd as the primary anchor date since it represents + // the definitive end of the sharing period (most semantically correct). + // Fall back to updatedAt for legacy/malformed listings to avoid permanently + // retaining conversations. The updatedAt fallback may drift if the listing + // is updated after archival; consider adding explicit archivedAt timestamp + // in the future for more precise retention tracking. + const anchorDate = listing.sharingPeriodEnd ?? listing.updatedAt; + + for (const conversation of conversationsToSchedule) { + conversation.scheduleForDeletion(anchorDate); + await repo.save(conversation); + scheduled++; + } + }, + ); + + return { processed, scheduled, errors }; + }, + }); +} diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.test.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.test.ts new file mode 100644 index 000000000..49bf4219c --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.test.ts @@ -0,0 +1,398 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { DataSources } from '@sthrift/persistence'; +import { expect, vi } from 'vitest'; +import { + type CleanupResult, + processConversationsForArchivedReservationRequests, +} from './cleanup-archived-reservation-conversations.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature( + path.resolve( + __dirname, + 'features/cleanup-archived-reservation-conversations.feature', + ), +); + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let mockDataSources: DataSources; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockReservationRequestReadRepo: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockConversationReadRepo: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock variable + let mockUnitOfWork: any; + let result: CleanupResult | undefined; + let thrownError: Error | undefined; + + BeforeEachScenario(() => { + mockReservationRequestReadRepo = { + getByStates: vi.fn(), + }; + + mockConversationReadRepo = { + getByReservationRequestId: vi.fn(), + }; + + mockUnitOfWork = { + withScopedTransaction: vi.fn(), + }; + + mockDataSources = { + readonlyDataSource: { + ReservationRequest: { + ReservationRequest: { + ReservationRequestReadRepo: mockReservationRequestReadRepo, + }, + }, + Conversation: { + Conversation: { + ConversationReadRepo: mockConversationReadRepo, + }, + }, + }, + domainDataSource: { + Conversation: { + Conversation: { + ConversationUnitOfWork: mockUnitOfWork, + }, + }, + }, + // biome-ignore lint/suspicious/noExplicitAny: Test mock type assertion + } as any; + + result = undefined; + thrownError = undefined; + }); + + Scenario( + 'Successfully processing conversations for archived reservation requests', + ({ Given, When, Then, And }) => { + let mockConversations: { + id: string; + expiresAt: Date | undefined; + scheduleForDeletion: ReturnType; + }[]; + + Given( + 'archived reservation requests exist with states "Closed", "Rejected", and "Cancelled"', + () => { + mockReservationRequestReadRepo.getByStates.mockResolvedValue([ + { + id: 'reservation-1', + state: 'Closed', + reservationPeriodEnd: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }, + { + id: 'reservation-2', + state: 'Rejected', + reservationPeriodEnd: new Date('2025-01-02'), + updatedAt: new Date('2025-01-02'), + }, + { + id: 'reservation-3', + state: 'Cancelled', + reservationPeriodEnd: new Date('2025-01-03'), + updatedAt: new Date('2025-01-03'), + }, + ]); + }, + ); + + And( + 'each reservation request has conversations without expiration dates', + () => { + mockConversations = [ + { + id: 'conv-1', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + { + id: 'conv-2', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + ]; + + mockConversationReadRepo.getByReservationRequestId.mockResolvedValue([ + mockConversations[0], + ]); + + mockUnitOfWork.withScopedTransaction.mockImplementation( + async ( + callback: (repo: { + getByReservationRequestId: typeof vi.fn; + get: typeof vi.fn; + save: typeof vi.fn; + }) => Promise, + ) => { + const mockRepo = { + getByReservationRequestId: vi.fn(() => + Promise.resolve(mockConversations), + ), + get: vi.fn((id: string) => + mockConversations.find((c) => c.id === id), + ), + save: vi.fn(), + }; + await callback(mockRepo); + }, + ); + }, + ); + + When( + 'the processConversationsForArchivedReservationRequests command is executed', + async () => { + result = + await processConversationsForArchivedReservationRequests( + mockDataSources, + ); + }, + ); + + Then('the result should show the correct processed count', () => { + expect(result).toBeDefined(); + expect(result?.processedCount).toBeGreaterThan(0); + }); + + And( + 'conversations without expiresAt should be scheduled for deletion', + () => { + expect(result?.scheduledCount).toBeGreaterThan(0); + }, + ); + + And('no errors should be reported', () => { + expect(result?.errors).toHaveLength(0); + }); + }, + ); + + Scenario( + 'Processing conversations already scheduled for deletion', + ({ Given, When, Then, And }) => { + Given('archived reservation requests exist', () => { + mockReservationRequestReadRepo.getByStates.mockResolvedValue([ + { + id: 'reservation-1', + state: 'Closed', + reservationPeriodEnd: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }, + ]); + }); + + And('all conversations already have expiresAt set', () => { + const mockConversations = [ + { + id: 'conv-1', + expiresAt: new Date('2025-07-01'), + scheduleForDeletion: vi.fn(), + }, + { + id: 'conv-2', + expiresAt: new Date('2025-07-02'), + scheduleForDeletion: vi.fn(), + }, + ]; + + mockUnitOfWork.withScopedTransaction.mockImplementation( + async ( + callback: (repo: { + getByReservationRequestId: typeof vi.fn; + get: typeof vi.fn; + save: typeof vi.fn; + }) => Promise, + ) => { + const mockRepo = { + getByReservationRequestId: vi.fn(() => + Promise.resolve(mockConversations), + ), + get: vi.fn((id: string) => + mockConversations.find((c) => c.id === id), + ), + save: vi.fn(), + }; + await callback(mockRepo); + }, + ); + }); + + When( + 'the processConversationsForArchivedReservationRequests command is executed', + async () => { + result = + await processConversationsForArchivedReservationRequests( + mockDataSources, + ); + }, + ); + + Then('the processed count should reflect all conversations', () => { + expect(result?.processedCount).toBeGreaterThan(0); + }); + + And('the scheduled count should be zero', () => { + expect(result?.scheduledCount).toBe(0); + }); + + And('no errors should be reported', () => { + expect(result?.errors).toHaveLength(0); + }); + }, + ); + + Scenario( + 'Handling partial failures during cleanup', + ({ Given, When, Then, And }) => { + Given('archived reservation requests exist', () => { + mockReservationRequestReadRepo.getByStates.mockResolvedValue([ + { + id: 'reservation-1', + state: 'Closed', + reservationPeriodEnd: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }, + { + id: 'reservation-2', + state: 'Rejected', + reservationPeriodEnd: new Date('2025-01-02'), + updatedAt: new Date('2025-01-02'), + }, + ]); + }); + + And( + "one reservation request's conversations will fail to process", + () => { + let callCount = 0; + mockUnitOfWork.withScopedTransaction.mockImplementation( + async (callback: (repo: unknown) => Promise) => { + callCount++; + if (callCount === 1) { + // First reservation request succeeds + const mockConversations = [ + { + id: 'conv-1', + expiresAt: undefined, + scheduleForDeletion: vi.fn(), + }, + ]; + const mockRepo = { + getByReservationRequestId: vi.fn(() => + Promise.resolve(mockConversations), + ), + save: vi.fn(), + }; + await callback(mockRepo); + } else { + // Second reservation request fails + throw new Error('Database connection lost'); + } + }, + ); + }, + ); + + When( + 'the processConversationsForArchivedReservationRequests command is executed', + async () => { + result = + await processConversationsForArchivedReservationRequests( + mockDataSources, + ); + }, + ); + + Then( + 'the result should include errors for the failed reservation request', + () => { + expect(result?.errors.length).toBeGreaterThan(0); + expect(result?.errors[0]).toContain('reservation-2'); + }, + ); + + And('successful reservation requests should still be processed', () => { + expect(result?.processedCount).toBeGreaterThan(0); + }); + + And('the error count should match the number of failures', () => { + expect(result?.errors).toHaveLength(1); + }); + }, + ); + + Scenario( + 'Handling complete failure during cleanup', + ({ Given, When, Then, And }) => { + Given( + 'the readonly data source fails when querying reservation requests', + () => { + mockReservationRequestReadRepo.getByStates.mockRejectedValue( + new Error('Database unavailable'), + ); + }, + ); + + When( + 'the processConversationsForArchivedReservationRequests command is executed', + async () => { + try { + await processConversationsForArchivedReservationRequests( + mockDataSources, + ); + } catch (error) { + thrownError = error as Error; + } + }, + ); + + Then('the command should throw a fatal error', () => { + expect(thrownError).toBeDefined(); + expect(thrownError?.message).toContain('Database unavailable'); + }); + + And('the error should be logged', () => { + // Error logging is verified through OpenTelemetry span in production + expect(thrownError).toBeInstanceOf(Error); + }); + }, + ); + + Scenario( + 'Processing when no archived reservation requests exist', + ({ Given, When, Then, And }) => { + Given('no archived reservation requests exist', () => { + mockReservationRequestReadRepo.getByStates.mockResolvedValue([]); + }); + + When( + 'the processConversationsForArchivedReservationRequests command is executed', + async () => { + result = + await processConversationsForArchivedReservationRequests( + mockDataSources, + ); + }, + ); + + Then('the processed count should be zero', () => { + expect(result?.processedCount).toBe(0); + }); + + And('the scheduled count should be zero', () => { + expect(result?.scheduledCount).toBe(0); + }); + + And('no errors should be reported', () => { + expect(result?.errors).toHaveLength(0); + }); + }, + ); +}); diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.ts new file mode 100644 index 000000000..95652b77d --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-archived-reservation-conversations.ts @@ -0,0 +1,66 @@ +import type { DataSources } from '@sthrift/persistence'; +import { Domain } from '@sthrift/domain'; +import { processArchivedEntities } from './cleanup-shared.ts'; +import type { CleanupResult } from './cleanup.types.ts'; + +const ARCHIVED_RESERVATION_REQUEST_STATES = [ + Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestStates + .CLOSED, + Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestStates + .REJECTED, + Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestStates + .CANCELLED, +]; + +export function processConversationsForArchivedReservationRequests( + dataSources: DataSources, +): Promise { + return processArchivedEntities({ + spanName: 'conversation.processConversationsForArchivedReservationRequests', + entityLabel: 'reservation request', + fetchEntities: () => + dataSources.readonlyDataSource.ReservationRequest.ReservationRequest.ReservationRequestReadRepo.getByStates( + ARCHIVED_RESERVATION_REQUEST_STATES, + ), + processEntity: async (reservationRequest) => { + let processed = 0; + let scheduled = 0; + const errors: string[] = []; + + await dataSources.domainDataSource.Conversation.Conversation.ConversationUnitOfWork.withScopedTransaction( + async (repo) => { + const conversations = await repo.getByReservationRequestId( + reservationRequest.id, + ); + processed += conversations.length; + + const conversationsToSchedule = conversations.filter( + (c) => !c.expiresAt, + ); + + // NOTE: For CLOSED (completed) requests, prefer reservationPeriodEnd as the + // most semantically correct anchor (end of the reservation period). + // For REJECTED/CANCELLED or when reservationPeriodEnd is missing, fall back + // to updatedAt to avoid permanently retaining conversations for legacy or + // malformed records. The updatedAt fallback may drift if the request is + // updated after state change. Consider adding explicit completedAt/cancelledAt/ + // rejectedAt timestamps in the future for more precise retention tracking. + const anchorDate = + (reservationRequest.state === + Domain.Contexts.ReservationRequest.ReservationRequest + .ReservationRequestStates.CLOSED + ? reservationRequest.reservationPeriodEnd + : null) ?? reservationRequest.updatedAt; + + for (const conversation of conversationsToSchedule) { + conversation.scheduleForDeletion(anchorDate); + await repo.save(conversation); + scheduled++; + } + }, + ); + + return { processed, scheduled, errors }; + }, + }); +} diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-shared.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-shared.ts new file mode 100644 index 000000000..06f499476 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup-shared.ts @@ -0,0 +1,88 @@ +import { type Span, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { CleanupResult } from './cleanup.types.ts'; + +const tracer = trace.getTracer('conversation:cleanup'); + +export function processArchivedEntities({ + spanName, + fetchEntities, + processEntity, + entityLabel, +}: { + spanName: string; + fetchEntities: () => Promise; + processEntity: ( + entity: T, + span: Span, + ) => Promise<{ + processed: number; + scheduled: number; + errors: string[]; + }>; + entityLabel: string; +}): Promise { + return tracer.startActiveSpan(spanName, async (span) => { + let hadFatalError = false; + const result: CleanupResult = { + processedCount: 0, + scheduledCount: 0, + timestamp: new Date(), + errors: [], + }; + + try { + const entities = await fetchEntities(); + span.setAttribute(`${entityLabel}Count`, entities.length); + + for (const entity of entities) { + try { + const { processed, scheduled, errors } = await processEntity( + entity, + span, + ); + + result.processedCount += processed; + result.scheduledCount += scheduled; + result.errors.push(...errors); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const msg = `Failed to process ${entityLabel} ${entity.id}: ${message}`; + result.errors.push(msg); + console.error('[ConversationCleanup]', msg); + } + } + } catch (error) { + hadFatalError = true; + span.setStatus({ code: SpanStatusCode.ERROR }); + if (error instanceof Error) { + span.recordException(error); + } + console.error( + `[ConversationCleanup] Fatal cleanup error for ${entityLabel}s:`, + error, + ); + throw error; + } finally { + span.setAttribute('processedCount', result.processedCount); + span.setAttribute('scheduledCount', result.scheduledCount); + span.setAttribute('errorsCount', result.errors.length); + + if (!hadFatalError) { + if (result.errors.length > 0) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `${result.errors.length} ${entityLabel}(s) failed during cleanup`, + }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + } + + console.log( + `[ConversationCleanup] ${entityLabel} cleanup complete. Processed: ${result.processedCount}, Scheduled: ${result.scheduledCount}, Errors: ${result.errors.length}`, + ); + } + + return result; + }); +} diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup.types.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup.types.ts new file mode 100644 index 000000000..d6f9c9ffc --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/cleanup.types.ts @@ -0,0 +1,6 @@ +export interface CleanupResult { + processedCount: number; + scheduledCount: number; + timestamp: Date; + errors: string[]; +} diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-conversations.feature b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-conversations.feature new file mode 100644 index 000000000..cafd1ba6f --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-conversations.feature @@ -0,0 +1,33 @@ +Feature: Cleanup Archived Conversations + + Scenario: Successfully processing conversations for archived listings + Given archived listings exist with states "Expired" and "Cancelled" + And each listing has conversations without expiration dates + When the processConversationsForArchivedListings command is executed + Then the result should show the correct processed count + And conversations without expiresAt should be scheduled for deletion + And the timestamp should be set + + Scenario: Processing when no archived listings exist + Given no archived listings exist + When the processConversationsForArchivedListings command is executed + Then the result should show 0 processed + And the result should show 0 scheduled + + Scenario: Skipping conversations that already have expiration dates + Given archived listings exist with conversations + And some conversations already have expiresAt set + When the processConversationsForArchivedListings command is executed + Then only conversations without expiresAt should be scheduled + + Scenario: Handling partial failures during cleanup + Given archived listings exist + And an error occurs while processing one listing + When the processConversationsForArchivedListings command is executed + Then other listings should still be processed + And the errors array should contain the failure message + + Scenario: Handling complete failure during cleanup + Given the repository throws an error + When the processConversationsForArchivedListings command is executed + Then an error should be thrown diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-reservation-conversations.feature b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-reservation-conversations.feature new file mode 100644 index 000000000..b36f3d311 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/cleanup-archived-reservation-conversations.feature @@ -0,0 +1,38 @@ +Feature: Cleanup archived reservation request conversations + +Scenario: Successfully processing conversations for archived reservation requests + Given archived reservation requests exist with states "Closed", "Rejected", and "Cancelled" + And each reservation request has conversations without expiration dates + When the processConversationsForArchivedReservationRequests command is executed + Then the result should show the correct processed count + And conversations without expiresAt should be scheduled for deletion + And no errors should be reported + +Scenario: Processing conversations already scheduled for deletion + Given archived reservation requests exist + And all conversations already have expiresAt set + When the processConversationsForArchivedReservationRequests command is executed + Then the processed count should reflect all conversations + And the scheduled count should be zero + And no errors should be reported + +Scenario: Handling partial failures during cleanup + Given archived reservation requests exist + And one reservation request's conversations will fail to process + When the processConversationsForArchivedReservationRequests command is executed + Then the result should include errors for the failed reservation request + And successful reservation requests should still be processed + And the error count should match the number of failures + +Scenario: Handling complete failure during cleanup + Given the readonly data source fails when querying reservation requests + When the processConversationsForArchivedReservationRequests command is executed + Then the command should throw a fatal error + And the error should be logged + +Scenario: Processing when no archived reservation requests exist + Given no archived reservation requests exist + When the processConversationsForArchivedReservationRequests command is executed + Then the processed count should be zero + And the scheduled count should be zero + And no errors should be reported diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/features/index.feature b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/index.feature index d33ba914c..65cb44aea 100644 --- a/packages/sthrift/application-services/src/contexts/conversation/conversation/features/index.feature +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/features/index.feature @@ -14,3 +14,13 @@ Feature: Conversation Application Service Given a conversation application service When I query for conversations by user "user-1" Then it should delegate to the queryByUser function + + Scenario: Processing conversations for archived listings through the application service + Given a conversation application service + When I process conversations for archived listings + Then it should delegate to the cleanup function + + Scenario: Processing conversations for archived reservation requests through the application service + Given a conversation application service + When I process conversations for archived reservation requests + Then it should delegate to the cleanup function diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/index.test.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/index.test.ts index 78e53cc08..9171802d3 100644 --- a/packages/sthrift/application-services/src/contexts/conversation/conversation/index.test.ts +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/index.test.ts @@ -15,11 +15,19 @@ vi.mock('./query-by-id.ts', () => ({ vi.mock('./query-by-user.ts', () => ({ queryByUser: vi.fn(), })); +vi.mock('./cleanup-archived-conversations.ts', () => ({ + processConversationsForArchivedListings: vi.fn(), +})); +vi.mock('./cleanup-archived-reservation-conversations.ts', () => ({ + processConversationsForArchivedReservationRequests: vi.fn(), +})); import { create } from './create.ts'; import { Conversation } from './index.ts'; import { queryById } from './query-by-id.ts'; import { queryByUser } from './query-by-user.ts'; +import { processConversationsForArchivedListings } from './cleanup-archived-conversations.ts'; +import { processConversationsForArchivedReservationRequests } from './cleanup-archived-reservation-conversations.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -51,6 +59,20 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { vi.mocked(create).mockReturnValue(mockCreateFn); vi.mocked(queryById).mockReturnValue(mockQueryByIdFn); vi.mocked(queryByUser).mockReturnValue(mockQueryByUserFn); + vi.mocked(processConversationsForArchivedListings).mockResolvedValue({ + processedCount: 10, + scheduledCount: 5, + timestamp: new Date(), + errors: [], + }); + vi.mocked( + processConversationsForArchivedReservationRequests, + ).mockResolvedValue({ + processedCount: 8, + scheduledCount: 3, + timestamp: new Date(), + errors: [], + }); mockDataSources = { domainDataSource: { @@ -164,4 +186,72 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { }); }, ); + + Scenario( + 'Processing conversations for archived listings through the application service', + ({ + Given, + When, + Then, + }: { + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + Given: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + When: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + Then: any; + }) => { + let result: { processedCount: number; scheduledCount: number }; + + Given('a conversation application service', () => { + expect(service).toBeDefined(); + }); + + When('I process conversations for archived listings', async () => { + result = await service.processConversationsForArchivedListings(); + }); + + Then('it should delegate to the cleanup function', () => { + expect(result).toBeDefined(); + expect(result.processedCount).toBe(10); + expect(result.scheduledCount).toBe(5); + }); + }, + ); + + Scenario( + 'Processing conversations for archived reservation requests through the application service', + ({ + Given, + When, + Then, + }: { + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + Given: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + When: any; + // biome-ignore lint/suspicious/noExplicitAny: Test mock callback + Then: any; + }) => { + let result: { processedCount: number; scheduledCount: number }; + + Given('a conversation application service', () => { + expect(service).toBeDefined(); + }); + + When( + 'I process conversations for archived reservation requests', + async () => { + result = + await service.processConversationsForArchivedReservationRequests(); + }, + ); + + Then('it should delegate to the cleanup function', () => { + expect(result).toBeDefined(); + expect(result.processedCount).toBe(8); + expect(result.scheduledCount).toBe(3); + }); + }, + ); }); diff --git a/packages/sthrift/application-services/src/contexts/conversation/conversation/index.ts b/packages/sthrift/application-services/src/contexts/conversation/conversation/index.ts index bfa308c36..bf42d2fc9 100644 --- a/packages/sthrift/application-services/src/contexts/conversation/conversation/index.ts +++ b/packages/sthrift/application-services/src/contexts/conversation/conversation/index.ts @@ -6,6 +6,9 @@ import { type ConversationQueryByUserCommand, queryByUser, } from './query-by-user.ts'; +import { processConversationsForArchivedListings } from './cleanup-archived-conversations.ts'; +import { processConversationsForArchivedReservationRequests } from './cleanup-archived-reservation-conversations.ts'; +import type { CleanupResult } from './cleanup.types.ts'; import { type ConversationSendMessageCommand, sendMessage, @@ -23,6 +26,8 @@ export interface ConversationApplicationService { ) => Promise< Domain.Contexts.Conversation.Conversation.ConversationEntityReference[] >; + processConversationsForArchivedListings: () => Promise; + processConversationsForArchivedReservationRequests: () => Promise; sendMessage: ( command: ConversationSendMessageCommand, ) => Promise; @@ -35,6 +40,10 @@ export const Conversation = ( create: create(dataSources), queryById: queryById(dataSources), queryByUser: queryByUser(dataSources), + processConversationsForArchivedListings: () => + processConversationsForArchivedListings(dataSources), + processConversationsForArchivedReservationRequests: () => + processConversationsForArchivedReservationRequests(dataSources), sendMessage: sendMessage(dataSources), }; }; diff --git a/packages/sthrift/application-services/src/index.ts b/packages/sthrift/application-services/src/index.ts index c935a792a..d84f968d5 100644 --- a/packages/sthrift/application-services/src/index.ts +++ b/packages/sthrift/application-services/src/index.ts @@ -60,6 +60,7 @@ export type PrincipalHints = Record; export interface AppServicesHost { forRequest(rawAuthHeader?: string, hints?: PrincipalHints): Promise; + forSystemOperation(): S; } export type ApplicationServicesFactory = AppServicesHost; @@ -67,7 +68,6 @@ export type ApplicationServicesFactory = AppServicesHost; export const buildApplicationServicesFactory = ( infrastructureServicesRegistry: ApiContextSpec, ): ApplicationServicesFactory => { - const forRequest = async ( rawAuthHeader?: string, hints?: PrincipalHints, @@ -108,7 +108,7 @@ export const buildApplicationServicesFactory = ( infrastructureServicesRegistry.dataSourcesFactory.withPassport( passport, infrastructureServicesRegistry.messagingService, - infrastructureServicesRegistry.paymentService, + infrastructureServicesRegistry.paymentService, ); return { @@ -124,8 +124,26 @@ export const buildApplicationServicesFactory = ( }; }; + const forSystemOperation = (): ApplicationServices => { + const dataSources = + infrastructureServicesRegistry.dataSourcesFactory.withSystemPassport(); + + return { + User: User(dataSources), + get verifiedUser(): VerifiedUser | null { + return null; + }, + ReservationRequest: ReservationRequest(dataSources), + Listing: Listing(dataSources), + Conversation: Conversation(dataSources), + AccountPlan: AccountPlan(dataSources), + AppealRequest: AppealRequest(dataSources), + }; + }; + return { forRequest, + forSystemOperation, }; }; diff --git a/packages/sthrift/data-sources-mongoose-models/src/models/conversations/conversation.model.ts b/packages/sthrift/data-sources-mongoose-models/src/models/conversations/conversation.model.ts index fbb6c9726..8ead6f20d 100644 --- a/packages/sthrift/data-sources-mongoose-models/src/models/conversations/conversation.model.ts +++ b/packages/sthrift/data-sources-mongoose-models/src/models/conversations/conversation.model.ts @@ -2,15 +2,21 @@ import { type Model, type ObjectId, Schema, type PopulatedDoc } from 'mongoose'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; import type * as User from '../user/user.model.ts'; import type * as ItemListing from '../listing/item-listing.model.ts'; +import type * as ReservationRequest from '../reservation-request/reservation-request.model.ts'; export interface Conversation extends MongooseSeedwork.Base { sharer: PopulatedDoc | ObjectId; reserver: PopulatedDoc | ObjectId; listing: PopulatedDoc | ObjectId; + reservationRequest?: + | PopulatedDoc + | ObjectId + | undefined; messagingConversationId: string; schemaVersion: string; createdAt: Date; updatedAt: Date; + expiresAt?: Date | undefined; } const ConversationSchema = new Schema< @@ -22,10 +28,16 @@ const ConversationSchema = new Schema< sharer: { type: Schema.Types.ObjectId, ref: 'User', required: true }, reserver: { type: Schema.Types.ObjectId, ref: 'User', required: true }, listing: { type: Schema.Types.ObjectId, ref: 'Listing', required: true }, + reservationRequest: { + type: Schema.Types.ObjectId, + ref: 'ReservationRequest', + required: false, + }, messagingConversationId: { type: String, required: true, unique: true }, schemaVersion: { type: String, required: true, default: '1.0.0' }, createdAt: { type: Date, required: true, default: Date.now }, updatedAt: { type: Date, required: true, default: Date.now }, + expiresAt: { type: Date, required: false, expires: 0 }, }, { timestamps: true }, ); diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.entity.ts b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.entity.ts index ed09cec5a..c7d8e0636 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.entity.ts +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.entity.ts @@ -2,6 +2,7 @@ import type { DomainSeedwork } from '@cellix/domain-seedwork'; import type { ItemListingEntityReference } from '../../listing/item/item-listing.entity.ts'; import type { UserEntityReference } from '../../user/index.ts'; import type { MessageEntityReference } from './message.entity.ts'; +import type { ReservationRequestEntityReference } from '../../reservation-request/reservation-request/reservation-request.entity.ts'; export interface ConversationProps extends DomainSeedwork.DomainEntityProps { sharer: Readonly; @@ -10,9 +11,14 @@ export interface ConversationProps extends DomainSeedwork.DomainEntityProps { loadReserver: () => Promise>; listing: Readonly; loadListing: () => Promise>; + reservationRequest?: Readonly | undefined; + loadReservationRequest?: () => Promise< + Readonly | undefined + >; messagingConversationId: string; messages: Readonly; loadMessages: () => Promise>; + expiresAt?: Date | undefined; get createdAt(): Date; get updatedAt(): Date; @@ -20,8 +26,15 @@ export interface ConversationProps extends DomainSeedwork.DomainEntityProps { } export interface ConversationEntityReference - extends Readonly> { + extends Readonly< + Omit< + ConversationProps, + 'sharer' | 'reserver' | 'listing' | 'reservationRequest' + > + > { readonly sharer: UserEntityReference; readonly reserver: UserEntityReference; readonly listing: ItemListingEntityReference; + readonly reservationRequest?: ReservationRequestEntityReference | undefined; + readonly expiresAt?: Date | undefined; } diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.repository.ts b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.repository.ts index cbea6f743..eebea17a0 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.repository.ts +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.repository.ts @@ -19,4 +19,9 @@ export interface ConversationRepository sharer: string, reserver: string, ): Promise | null>; + getByListingId(listingId: string): Promise[]>; + getByReservationRequestId( + reservationRequestId: string, + ): Promise[]>; + getExpired(limit?: number): Promise[]>; } diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.test.ts b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.test.ts index 901afd6c3..aaab37a0c 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.test.ts +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.test.ts @@ -6,6 +6,7 @@ import { expect, vi } from 'vitest'; import type { ItemListingProps } from '../../listing/item/item-listing.entity.ts'; import { ItemListing } from '../../listing/item/item-listing.ts'; import type { Passport } from '../../passport.ts'; +import type { ReservationRequestEntityReference } from '../../reservation-request/reservation-request/reservation-request.entity.ts'; import type { PersonalUserProps } from '../../user/personal-user/personal-user.entity.ts'; import { PersonalUser } from '../../user/personal-user/personal-user.ts'; import type { UserEntityReference } from '../../user/index.ts'; @@ -916,4 +917,229 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }, ); + + Scenario('Getting expiresAt when not set', ({ Given, When, Then }) => { + let result: Date | undefined; + Given('a Conversation aggregate without an expiration date', () => { + passport = makePassport(true); + conversation = new Conversation(makeBaseProps(), passport); + }); + When('I access the expiresAt property', () => { + result = conversation.expiresAt; + }); + Then('it should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + Scenario('Setting expiresAt with permission', ({ Given, When, Then }) => { + let expirationDate: Date; + Given( + 'a Conversation aggregate with permission to manage conversation', + () => { + passport = makePassport(true); + conversation = new Conversation(makeBaseProps(), passport); + }, + ); + When('I set the expiresAt to a future date', () => { + expirationDate = new Date('2025-07-01T00:00:00.000Z'); + conversation.expiresAt = expirationDate; + }); + Then('the expiresAt should be updated to that date', () => { + expect(conversation.expiresAt).toEqual(expirationDate); + }); + }); + + Scenario('Setting expiresAt without permission', ({ Given, When, Then }) => { + let setExpiresAtWithoutPermission: () => void; + Given( + 'a Conversation aggregate without permission to manage conversation', + () => { + passport = makePassport(false); + conversation = new Conversation(makeBaseProps(), passport); + }, + ); + When('I try to set the expiresAt to a future date', () => { + setExpiresAtWithoutPermission = () => { + conversation.expiresAt = new Date('2025-07-01T00:00:00.000Z'); + }; + }); + Then('a PermissionError should be thrown', () => { + expect(setExpiresAtWithoutPermission).toThrow( + DomainSeedwork.PermissionError, + ); + expect(setExpiresAtWithoutPermission).toThrow( + 'You do not have permission to change the expiration date of this conversation', + ); + }); + }); + + Scenario( + 'Scheduling a conversation for deletion with permission', + ({ Given, When, Then }) => { + let archivalDate: Date; + Given( + 'a Conversation aggregate with permission to manage conversation', + () => { + passport = makePassport(true); + conversation = new Conversation(makeBaseProps(), passport); + }, + ); + When('I call scheduleForDeletion with a retention period', () => { + archivalDate = new Date('2025-01-15T00:00:00.000Z'); + conversation.scheduleForDeletion(archivalDate); + }); + Then( + 'the expiresAt should be set to current date plus retention period', + () => { + expect(conversation.expiresAt).toBeDefined(); + // 6 months from archival date + const expectedTime = + archivalDate.getTime() + Conversation.RETENTION_PERIOD_MS; + const actualTime = conversation.expiresAt?.getTime() ?? 0; + expect(actualTime).toBe(expectedTime); + }, + ); + }, + ); + + Scenario( + 'Scheduling a conversation for deletion without permission', + ({ Given, When, Then }) => { + let scheduleWithoutPermission: () => void; + Given( + 'a Conversation aggregate without permission to manage conversation', + () => { + passport = makePassport(false); + conversation = new Conversation(makeBaseProps(), passport); + }, + ); + When('I try to call scheduleForDeletion', () => { + scheduleWithoutPermission = () => { + conversation.scheduleForDeletion( + new Date('2025-01-15T00:00:00.000Z'), + ); + }; + }); + Then('a PermissionError should be thrown', () => { + expect(scheduleWithoutPermission).toThrow( + DomainSeedwork.PermissionError, + ); + expect(scheduleWithoutPermission).toThrow( + 'You do not have permission to schedule this conversation for deletion', + ); + }); + }, + ); + + Scenario( + 'Getting reservation request when it exists', + ({ Given, When, Then }) => { + let mockReservationRequest: ReservationRequestEntityReference; + let result: ReservationRequestEntityReference | undefined; + + Given('a Conversation aggregate with a reservation request', () => { + passport = makePassport(true); + mockReservationRequest = {} as ReservationRequestEntityReference; + const props = { + ...makeBaseProps(), + reservationRequest: mockReservationRequest, + }; + conversation = new Conversation(props, passport); + }); + + When('I get the reservationRequest property', () => { + result = conversation.reservationRequest; + }); + + Then('it should return the reservation request entity reference', () => { + expect(result).toBeDefined(); + expect(result).toBe(mockReservationRequest); + }); + }, + ); + + Scenario( + "Getting reservation request when it doesn't exist", + ({ Given, When, Then }) => { + let result: ReservationRequestEntityReference | undefined; + + Given('a Conversation aggregate without a reservation request', () => { + passport = makePassport(true); + const props = { + ...makeBaseProps(), + reservationRequest: undefined, + }; + conversation = new Conversation(props, passport); + }); + + When('I get the reservationRequest property', () => { + result = conversation.reservationRequest; + }); + + Then('it should return undefined', () => { + expect(result).toBeUndefined(); + }); + }, + ); + + Scenario( + 'Loading reservation request when loader exists', + ({ Given, When, Then }) => { + let mockReservationRequest: ReservationRequestEntityReference; + let result: ReservationRequestEntityReference | undefined; + + Given( + 'a Conversation aggregate with a reservation request loader', + () => { + passport = makePassport(true); + mockReservationRequest = {} as ReservationRequestEntityReference; + const props = { + ...makeBaseProps(), + loadReservationRequest: async () => mockReservationRequest, + }; + conversation = new Conversation(props, passport); + }, + ); + + When('I call loadReservationRequest', async () => { + result = await conversation.loadReservationRequest(); + }); + + Then( + 'it should return the loaded reservation request entity reference', + () => { + expect(result).toBeDefined(); + expect(result).toBe(mockReservationRequest); + }, + ); + }, + ); + + Scenario( + "Loading reservation request when loader doesn't exist", + ({ Given, When, Then }) => { + let result: ReservationRequestEntityReference | undefined; + + Given( + 'a Conversation aggregate without a reservation request loader', + () => { + passport = makePassport(true); + const props = { + ...makeBaseProps(), + loadReservationRequest: undefined, + }; + conversation = new Conversation(props, passport); + }, + ); + + When('I call loadReservationRequest', async () => { + result = await conversation.loadReservationRequest(); + }); + + Then('it should return undefined', () => { + expect(result).toBeUndefined(); + }); + }, + ); }); diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.ts b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.ts index 00937c369..26d947183 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.ts +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.ts @@ -2,6 +2,7 @@ import { DomainSeedwork } from '@cellix/domain-seedwork'; import type { ItemListingEntityReference } from '../../listing/item/item-listing.entity.ts'; import { ItemListing } from '../../listing/item/item-listing.ts'; import type { Passport } from '../../passport.ts'; +import type { ReservationRequestEntityReference } from '../../reservation-request/reservation-request/reservation-request.entity.ts'; import type { AdminUserProps } from '../../user/admin-user/admin-user.entity.ts'; import { AdminUser } from '../../user/admin-user/admin-user.ts'; import type { UserEntityReference } from '../../user/index.ts'; @@ -19,6 +20,7 @@ export class Conversation implements ConversationEntityReference { private isNew: boolean = false; + public static readonly RETENTION_PERIOD_MS = 180 * 24 * 60 * 60 * 1000; // 6-month (180-day) retention period expressed in milliseconds private readonly visa: ConversationVisa; //#region Constructor @@ -164,6 +166,19 @@ export class Conversation this.props.listing = listing; } + get reservationRequest(): ReservationRequestEntityReference | undefined { + return this.props.reservationRequest; + } + + async loadReservationRequest(): Promise< + ReservationRequestEntityReference | undefined + > { + if (!this.props.loadReservationRequest) { + return undefined; + } + return await this.props.loadReservationRequest(); + } + get messagingConversationId(): string { return this.props.messagingConversationId; } @@ -192,4 +207,37 @@ export class Conversation get schemaVersion(): string { return this.props.schemaVersion; } + + get expiresAt(): Date | undefined { + return this.props.expiresAt; + } + + set expiresAt(value: Date | undefined) { + if ( + !this.isNew && + !this.visa.determineIf( + (domainPermissions) => domainPermissions.canManageConversation, + ) + ) { + throw new DomainSeedwork.PermissionError( + 'You do not have permission to change the expiration date of this conversation', + ); + } + this.props.expiresAt = value; + } + + public scheduleForDeletion(archivalDate: Date): void { + if ( + !this.visa.determineIf( + (domainPermissions) => domainPermissions.canManageConversation, + ) + ) { + throw new DomainSeedwork.PermissionError( + 'You do not have permission to schedule this conversation for deletion', + ); + } + this.props.expiresAt = new Date( + archivalDate.getTime() + Conversation.RETENTION_PERIOD_MS, + ); + } } diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/features/conversation.feature b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/features/conversation.feature index ff078442f..22f90979d 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/features/conversation.feature +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/features/conversation.feature @@ -127,3 +127,47 @@ Feature: Conversation aggregate When I call loadReserver() Then it should return the reserver asynchronously + Scenario: Getting expiresAt when not set + Given a Conversation aggregate without an expiration date + When I access the expiresAt property + Then it should return undefined + + Scenario: Setting expiresAt with permission + Given a Conversation aggregate with permission to manage conversation + When I set the expiresAt to a future date + Then the expiresAt should be updated to that date + + Scenario: Setting expiresAt without permission + Given a Conversation aggregate without permission to manage conversation + When I try to set the expiresAt to a future date + Then a PermissionError should be thrown + + Scenario: Scheduling a conversation for deletion with permission + Given a Conversation aggregate with permission to manage conversation + When I call scheduleForDeletion with a retention period + Then the expiresAt should be set to current date plus retention period + + Scenario: Scheduling a conversation for deletion without permission + Given a Conversation aggregate without permission to manage conversation + When I try to call scheduleForDeletion + Then a PermissionError should be thrown + + Scenario: Getting reservation request when it exists + Given a Conversation aggregate with a reservation request + When I get the reservationRequest property + Then it should return the reservation request entity reference + + Scenario: Getting reservation request when it doesn't exist + Given a Conversation aggregate without a reservation request + When I get the reservationRequest property + Then it should return undefined + + Scenario: Loading reservation request when loader exists + Given a Conversation aggregate with a reservation request loader + When I call loadReservationRequest + Then it should return the loaded reservation request entity reference + + Scenario: Loading reservation request when loader doesn't exist + Given a Conversation aggregate without a reservation request loader + When I call loadReservationRequest + Then it should return undefined diff --git a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/index.ts b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/index.ts index 03568b053..60d4a7380 100644 --- a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/index.ts +++ b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/index.ts @@ -5,3 +5,4 @@ export type { } from './reservation-request.entity.ts'; export type { ReservationRequestRepository } from './reservation-request.repository.ts'; export type { ReservationRequestUnitOfWork } from './reservation-request.uow.ts'; +export { ReservationRequestStates } from './reservation-request.value-objects.ts'; diff --git a/packages/sthrift/mock-mongodb-memory-server/src/seed/conversations.ts b/packages/sthrift/mock-mongodb-memory-server/src/seed/conversations.ts index 9973513d7..9781a96ca 100644 --- a/packages/sthrift/mock-mongodb-memory-server/src/seed/conversations.ts +++ b/packages/sthrift/mock-mongodb-memory-server/src/seed/conversations.ts @@ -8,6 +8,7 @@ const createConversation = ( listingId: string, messagingId: string, createdDate: string, + reservationRequestId?: string, ) => ({ _id: id, sharer: new ObjectId(sharerId), @@ -19,6 +20,9 @@ const createConversation = ( discriminatorKey: 'conversation', createdAt: new Date(createdDate), updatedAt: new Date(createdDate), + ...(reservationRequestId && { + reservationRequest: new ObjectId(reservationRequestId), + }), }); export const conversations = [ @@ -62,4 +66,55 @@ export const conversations = [ 'CH127', '2024-10-01T10:00:00Z', ), + // ======================================== + // CONVERSATION CLEANUP TEST DATA + // ======================================== + // These conversations are linked to archived listings/reservations and should be scheduled for deletion + // when the cleanup process runs. + + // Conversation linked to EXPIRED listing (Vintage Record Player - 707f1f77bcf86cd799439041) + // Expected behavior: Should be scheduled for deletion with expiresAt = sharingPeriodEnd + 6 months + createConversation( + '807f1f77bcf86cd799439046', + '507f1f77bcf86cd799439011', // Alice (sharer of expired listing) + '507f1f77bcf86cd799439012', // Bob (was interested in expired listing) + '707f1f77bcf86cd799439041', // Vintage Record Player (Expired) + 'CH128', + '2023-01-15T10:00:00Z', + ), + + // Conversation linked to CANCELLED listing (Electric Scooter - 707f1f77bcf86cd799439042) + // Expected behavior: Should be scheduled for deletion with expiresAt = sharingPeriodEnd + 6 months + createConversation( + '807f1f77bcf86cd799439047', + '507f1f77bcf86cd799439012', // Bob (sharer of cancelled listing) + '507f1f77bcf86cd799439013', // Charlie (was interested in cancelled listing) + '707f1f77bcf86cd799439042', // Electric Scooter (Cancelled) + 'CH129', + '2023-06-05T11:00:00Z', + ), + + // Conversation linked to CLOSED reservation request (907f1f77bcf86cd799439053) + // Expected behavior: Should be scheduled for deletion with expiresAt = reservationPeriodEnd + 6 months + createConversation( + '807f1f77bcf86cd799439048', + '507f1f77bcf86cd799439011', // Alice (sharer of Lawn Mower) + '507f1f77bcf86cd799439012', // Bob (reserver of closed reservation) + '707f1f77bcf86cd799439031', // Lawn Mower + 'CH130', + '2023-02-26T10:00:00Z', + '907f1f77bcf86cd799439053', // Closed reservation request + ), + + // Conversation linked to REJECTED reservation request (907f1f77bcf86cd799439056) + // Expected behavior: Should be scheduled for deletion with expiresAt = updatedAt + 6 months + createConversation( + '807f1f77bcf86cd799439049', + '507f1f77bcf86cd799439012', // Bob (sharer of Mountain Bike) + '507f1f77bcf86cd799439013', // Charlie (reserver of rejected reservation) + '707f1f77bcf86cd799439032', // Mountain Bike + 'CH131', + '2023-03-25T12:00:00Z', + '907f1f77bcf86cd799439056', // Rejected reservation request + ), ] as unknown as Models.Conversation.Conversation[]; diff --git a/packages/sthrift/mock-mongodb-memory-server/src/seed/item-listings.ts b/packages/sthrift/mock-mongodb-memory-server/src/seed/item-listings.ts index 8b24ccb00..48cfc91a0 100644 --- a/packages/sthrift/mock-mongodb-memory-server/src/seed/item-listings.ts +++ b/packages/sthrift/mock-mongodb-memory-server/src/seed/item-listings.ts @@ -210,4 +210,37 @@ export const itemListings = [ 'https://traceaudio.com/cdn/shop/products/NewSM7BwithAnserModcopy_1200x1200.jpg?v=1662083374', ], }), + // Expired listing for conversation cleanup testing + // This listing has ended and any associated conversations should be scheduled for deletion + createListing({ + _id: '707f1f77bcf86cd799439041', + sharer: COMMON_USERS.alice, + title: 'Vintage Record Player', + description: + 'Classic turntable for vinyl enthusiasts. Sharing period has ended.', + category: 'Electronics', + location: COMMON_LOCATIONS.springfield, + sharingPeriodStart: new Date('2023-01-01T08:00:00Z'), + sharingPeriodEnd: new Date('2023-02-28T20:00:00Z'), + state: 'Expired', + createdAt: new Date('2022-12-15T09:00:00Z'), + updatedAt: new Date('2023-03-01T00:00:00Z'), + images: [], + }), + // Cancelled listing for conversation cleanup testing + // This listing was cancelled by the sharer and any associated conversations should be scheduled for deletion + createListing({ + _id: '707f1f77bcf86cd799439042', + sharer: COMMON_USERS.bob, + title: 'Electric Scooter', + description: 'Electric scooter - listing cancelled by owner.', + category: 'Vehicles', + location: COMMON_LOCATIONS.philadelphia, + sharingPeriodStart: new Date('2023-06-01T08:00:00Z'), + sharingPeriodEnd: new Date('2023-08-31T20:00:00Z'), + state: 'Cancelled', + createdAt: new Date('2023-05-15T10:00:00Z'), + updatedAt: new Date('2023-06-10T10:00:00Z'), + images: [], + }), ]; diff --git a/packages/sthrift/mock-mongodb-memory-server/src/seed/reservation-requests.ts b/packages/sthrift/mock-mongodb-memory-server/src/seed/reservation-requests.ts index c895e17a4..8f06de76d 100644 --- a/packages/sthrift/mock-mongodb-memory-server/src/seed/reservation-requests.ts +++ b/packages/sthrift/mock-mongodb-memory-server/src/seed/reservation-requests.ts @@ -77,4 +77,21 @@ export const reservationRequests = [ createdAt: new Date('2025-08-11T10:00:00Z'), updatedAt: new Date('2025-08-11T10:00:00Z'), }, + // Rejected reservation request for conversation cleanup testing + // Conversations linked to this should be scheduled for deletion + { + _id: '907f1f77bcf86cd799439056', + state: 'Rejected', + reservationPeriodStart: new Date('2023-04-01T08:00:00Z'), + reservationPeriodEnd: new Date('2023-04-05T20:00:00Z'), + schemaVersion: '1.0.0', + listing: new ObjectId('707f1f77bcf86cd799439032'), // Mountain Bike (Bob's listing) + reserver: new ObjectId('507f1f77bcf86cd799439013'), // Charlie (reserver) + closeRequestedBySharer: false, + closeRequestedByReserver: false, + version: 1, + discriminatorKey: 'reservation-request', + createdAt: new Date('2023-03-25T10:00:00Z'), + updatedAt: new Date('2023-03-26T10:00:00Z'), + }, ] as unknown as Models.ReservationRequest.ReservationRequest[]; diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.test.ts b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.test.ts index 1d71424a8..5a357dbca 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.test.ts +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.test.ts @@ -4,6 +4,7 @@ import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect, vi } from 'vitest'; import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; import type { Models } from '@sthrift/data-sources-mongoose-models'; +import { Domain } from '@sthrift/domain'; import { ConversationDomainAdapter } from './conversation.domain-adapter.ts'; import { PersonalUserDomainAdapter } from '../../user/personal-user/personal-user.domain-adapter.ts'; @@ -48,14 +49,17 @@ function makeListingDoc(overrides: Partial = {}) { function makeConversationDoc( overrides: Partial = {}, ) { + const setSpy = vi.fn( + (key: keyof Models.Conversation.Conversation, value: unknown) => { + (base as Models.Conversation.Conversation)[key] = value as never; + }, + ); const base = { sharer: overrides.sharer ?? undefined, reserver: overrides.reserver ?? undefined, listing: overrides.listing ?? undefined, messagingConversationId: 'twilio-123', - set(key: keyof Models.Conversation.Conversation, value: unknown) { - (this as Models.Conversation.Conversation)[key] = value as never; - }, + set: overrides.set ?? setSpy, ...overrides, } as Models.Conversation.Conversation; return vi.mocked(base); @@ -135,18 +139,17 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { ({ When, Then }) => { When('I get the sharer property on a doc with sharer as ObjectId', () => { const sharerId = new MongooseSeedwork.ObjectId(); - doc = makeConversationDoc({ sharer: sharerId as unknown as Models.User.PersonalUser }); + doc = makeConversationDoc({ + sharer: sharerId as unknown as Models.User.PersonalUser, + }); adapter = new ConversationDomainAdapter(doc); result = adapter.sharer; }); - Then( - 'it should return a UserEntityReference with id', - () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('id'); - expect((result as { id: string }).id).toMatch(/^[a-f0-9]{24}$/); - }, - ); + Then('it should return a UserEntityReference with id', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('id'); + expect((result as { id: string }).id).toMatch(/^[a-f0-9]{24}$/); + }); }, ); @@ -242,18 +245,23 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario( 'Getting the reserver property when it is an ObjectId', ({ When, Then }) => { - When('I get the reserver property on a doc with reserver as ObjectId', () => { - const reserverId = new MongooseSeedwork.ObjectId(); - doc = makeConversationDoc({ - sharer: sharerDoc, - reserver: reserverId as unknown as Models.User.PersonalUser - }); - adapter = new ConversationDomainAdapter(doc); - }); + When( + 'I get the reserver property on a doc with reserver as ObjectId', + () => { + const reserverId = new MongooseSeedwork.ObjectId(); + doc = makeConversationDoc({ + sharer: sharerDoc, + reserver: reserverId as unknown as Models.User.PersonalUser, + }); + adapter = new ConversationDomainAdapter(doc); + }, + ); Then( 'an error should be thrown indicating reserver is not populated or is not of the correct type', () => { - expect(() => adapter.reserver).toThrow(/reserver is not populated or is not of the correct type/); + expect(() => adapter.reserver).toThrow( + /reserver is not populated or is not of the correct type/, + ); }, ); }, @@ -263,10 +271,10 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { 'Getting the listing property when not populated', ({ When, Then }) => { When('I get the listing property on a doc with no listing', () => { - doc = makeConversationDoc({ + doc = makeConversationDoc({ sharer: sharerDoc, reserver: reserverDoc, - listing: undefined + listing: undefined, }); adapter = new ConversationDomainAdapter(doc); }); @@ -302,21 +310,27 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); - Scenario('Getting the listing property when it is an ObjectId', ({ When, Then }) => { - When('I get the listing property on a doc with listing as ObjectId', () => { - const listingId = new MongooseSeedwork.ObjectId(); - doc = makeConversationDoc({ - listing: listingId as unknown as Models.Listing.ItemListing, + Scenario( + 'Getting the listing property when it is an ObjectId', + ({ When, Then }) => { + When( + 'I get the listing property on a doc with listing as ObjectId', + () => { + const listingId = new MongooseSeedwork.ObjectId(); + doc = makeConversationDoc({ + listing: listingId as unknown as Models.Listing.ItemListing, + }); + adapter = new ConversationDomainAdapter(doc); + result = adapter.listing; + }, + ); + Then('it should return an ItemListingEntityReference with id', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('id'); + expect((result as { id: string }).id).toMatch(/^[a-f0-9]{24}$/); }); - adapter = new ConversationDomainAdapter(doc); - result = adapter.listing; - }); - Then('it should return an ItemListingEntityReference with id', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('id'); - expect((result as { id: string }).id).toMatch(/^[a-f0-9]{24}$/); - }); - }); + }, + ); Scenario('Loading listing when already populated', ({ When, Then }) => { When( @@ -352,81 +366,117 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { When('I set the sharer property to a reference with id', () => { const setSpy = vi.spyOn(doc, 'set'); adapter.sharer = { id: '507f1f77bcf86cd799439011' } as never; - expect(setSpy).toHaveBeenCalledWith('sharer', expect.any(MongooseSeedwork.ObjectId)); + expect(setSpy).toHaveBeenCalledWith( + 'sharer', + expect.any(MongooseSeedwork.ObjectId), + ); }); Then("the document's sharer should be set correctly", () => { // Verified in When block }); }); - Scenario('Setting sharer property with missing id throws error', ({ When, Then }) => { - When('I set the sharer property to a reference missing id', () => { - // Test happens in Then block - }); - Then('an error should be thrown indicating sharer reference is missing id', () => { - expect(() => { - adapter.sharer = {} as never; - }).toThrow('sharer reference is missing id'); - }); - }); + Scenario( + 'Setting sharer property with missing id throws error', + ({ When, Then }) => { + When('I set the sharer property to a reference missing id', () => { + // Test happens in Then block + }); + Then( + 'an error should be thrown indicating sharer reference is missing id', + () => { + expect(() => { + adapter.sharer = {} as never; + }).toThrow('sharer reference is missing id'); + }, + ); + }, + ); - Scenario('Setting reserver property with valid reference', ({ When, Then }) => { - When('I set the reserver property to a reference with id', () => { - const setSpy = vi.spyOn(doc, 'set'); - adapter.reserver = { id: '507f1f77bcf86cd799439012' } as never; - expect(setSpy).toHaveBeenCalledWith('reserver', expect.any(MongooseSeedwork.ObjectId)); - }); - Then("the document's reserver should be set correctly", () => { - // Verified in When block - }); - }); + Scenario( + 'Setting reserver property with valid reference', + ({ When, Then }) => { + When('I set the reserver property to a reference with id', () => { + const setSpy = vi.spyOn(doc, 'set'); + adapter.reserver = { id: '507f1f77bcf86cd799439012' } as never; + expect(setSpy).toHaveBeenCalledWith( + 'reserver', + expect.any(MongooseSeedwork.ObjectId), + ); + }); + Then("the document's reserver should be set correctly", () => { + // Verified in When block + }); + }, + ); - Scenario('Setting reserver property with missing id throws error', ({ When, Then }) => { - When('I set the reserver property to a reference missing id', () => { - // Test happens in Then block - }); - Then('an error should be thrown indicating reserver reference is missing id', () => { - expect(() => { - adapter.reserver = {} as never; - }).toThrow('reserver reference is missing id'); - }); - }); + Scenario( + 'Setting reserver property with missing id throws error', + ({ When, Then }) => { + When('I set the reserver property to a reference missing id', () => { + // Test happens in Then block + }); + Then( + 'an error should be thrown indicating reserver reference is missing id', + () => { + expect(() => { + adapter.reserver = {} as never; + }).toThrow('reserver reference is missing id'); + }, + ); + }, + ); - Scenario('Setting listing property with valid reference', ({ When, Then }) => { - When('I set the listing property to a reference with id', () => { - const setSpy = vi.spyOn(doc, 'set'); - adapter.listing = { id: '507f1f77bcf86cd799439013' } as never; - expect(setSpy).toHaveBeenCalledWith('listing', expect.any(MongooseSeedwork.ObjectId)); - }); - Then("the document's listing should be set correctly", () => { - // Verified in When block - }); - }); + Scenario( + 'Setting listing property with valid reference', + ({ When, Then }) => { + When('I set the listing property to a reference with id', () => { + const setSpy = vi.spyOn(doc, 'set'); + adapter.listing = { id: '507f1f77bcf86cd799439013' } as never; + expect(setSpy).toHaveBeenCalledWith( + 'listing', + expect.any(MongooseSeedwork.ObjectId), + ); + }); + Then("the document's listing should be set correctly", () => { + // Verified in When block + }); + }, + ); - Scenario('Setting listing property with missing id throws error', ({ When, Then }) => { - When('I set the listing property to a reference missing id', () => { - // Test happens in Then block - }); - Then('an error should be thrown indicating listing reference is missing id', () => { - expect(() => { - adapter.listing = {} as never; - }).toThrow('listing reference is missing id'); - }); - }); + Scenario( + 'Setting listing property with missing id throws error', + ({ When, Then }) => { + When('I set the listing property to a reference missing id', () => { + // Test happens in Then block + }); + Then( + 'an error should be thrown indicating listing reference is missing id', + () => { + expect(() => { + adapter.listing = {} as never; + }).toThrow('listing reference is missing id'); + }, + ); + }, + ); Scenario('Loading reserver when it is an ObjectId', ({ When, Then }) => { - When('I call loadReserver on an adapter with reserver as ObjectId', async () => { - const oid = new MongooseSeedwork.ObjectId(); - doc = makeConversationDoc({ - reserver: oid, - }); - doc.populate = vi.fn().mockImplementation(() => { - doc.reserver = reserverDoc as never; - return Promise.resolve(doc); - }); - adapter = new ConversationDomainAdapter(doc); - result = await adapter.loadReserver(); - }); + When( + 'I call loadReserver on an adapter with reserver as ObjectId', + async () => { + const oid = new MongooseSeedwork.ObjectId(); + doc = makeConversationDoc({ + reserver: oid, + }); + doc.populate = vi.fn().mockImplementation(() => { + doc.reserver = reserverDoc as never; + return Promise.resolve(doc); + }); + adapter = new ConversationDomainAdapter(doc); + result = await adapter.loadReserver(); + }, + ); Then('it should populate and return a PersonalUserDomainAdapter', () => { expect(doc.populate).toHaveBeenCalledWith('reserver'); // loadReserver returns an entity reference object, not a domain adapter instance @@ -436,152 +486,291 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Loading listing when it is an ObjectId', ({ When, Then }) => { - When('I call loadListing on an adapter with listing as ObjectId', async () => { - const oid = new MongooseSeedwork.ObjectId(); - doc = makeConversationDoc({ - listing: oid as unknown as Models.Listing.ItemListing, - populate: vi.fn().mockResolvedValue({ - ...doc, - listing: listingDoc, - }), - }); - adapter = new ConversationDomainAdapter(doc); - result = await adapter.loadListing(); - }); + When( + 'I call loadListing on an adapter with listing as ObjectId', + async () => { + const oid = new MongooseSeedwork.ObjectId(); + doc = makeConversationDoc({ + listing: oid as unknown as Models.Listing.ItemListing, + populate: vi.fn().mockResolvedValue({ + ...doc, + listing: listingDoc, + }), + }); + adapter = new ConversationDomainAdapter(doc); + result = await adapter.loadListing(); + }, + ); Then('it should populate and return an ItemListingDomainAdapter', () => { expect(doc.populate).toHaveBeenCalledWith('listing'); expect(result).toBeDefined(); }); }); - Scenario('Getting sharer when it is an admin user', ({ Given, When, Then }) => { - Given('a conversation with an admin user as sharer', () => { - const adminUserDoc = { - ...makeUserDoc(), - userType: 'admin-user', - } as never; - doc = makeConversationDoc({ sharer: adminUserDoc }); - adapter = new ConversationDomainAdapter(doc); - }); + Scenario( + 'Getting sharer when it is an admin user', + ({ Given, When, Then }) => { + Given('a conversation with an admin user as sharer', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + doc = makeConversationDoc({ sharer: adminUserDoc }); + adapter = new ConversationDomainAdapter(doc); + }); - When('I access the sharer property', async () => { - result = await adapter.loadSharer(); - }); + When('I access the sharer property', async () => { + result = await adapter.loadSharer(); + }); - Then('it should return an AdminUserDomainAdapter', () => { - expect(result).toBeDefined(); - }); - }); + Then('it should return an AdminUserDomainAdapter', () => { + expect(result).toBeDefined(); + }); + }, + ); - Scenario('Setting sharer with PersonalUser domain entity', ({ Given, When, Then }) => { - let personalUser: never; + Scenario( + 'Setting sharer with PersonalUser domain entity', + ({ Given, When, Then }) => { + let personalUser: never; + + Given('a PersonalUser domain entity', () => { + const userDoc = makeUserDoc(); + const setSpy = vi.fn(); + personalUser = { props: { doc: userDoc }, id: userDoc.id } as never; + doc = makeConversationDoc({ set: setSpy }); + adapter = new ConversationDomainAdapter(doc); + }); - Given('a PersonalUser domain entity', () => { - const userDoc = makeUserDoc(); - const setSpy = vi.fn(); - personalUser = { props: { doc: userDoc }, id: userDoc.id } as never; - doc = makeConversationDoc({ set: setSpy }); - adapter = new ConversationDomainAdapter(doc); - }); + When('I set the sharer property with the domain entity', () => { + adapter.sharer = personalUser; + }); - When('I set the sharer property with the domain entity', () => { - adapter.sharer = personalUser; - }); + Then('the sharer should be set correctly', () => { + expect(doc.set).toHaveBeenCalledWith('sharer', expect.anything()); + }); + }, + ); - Then('the sharer should be set correctly', () => { - expect(doc.set).toHaveBeenCalledWith('sharer', expect.anything()); - }); - }); + Scenario( + 'Setting listing with ItemListing domain entity', + ({ Given, When, Then }) => { + let itemListing: never; + + Given('an ItemListing domain entity', () => { + const listingId = new MongooseSeedwork.ObjectId(); + const listing = makeListingDoc({ id: listingId as never }); + const setSpy = vi.fn(); + itemListing = { + props: { doc: listing }, + id: listingId.toString(), + } as never; + doc = makeConversationDoc({ set: setSpy }); + adapter = new ConversationDomainAdapter(doc); + }); - Scenario('Setting listing with ItemListing domain entity', ({ Given, When, Then }) => { - let itemListing: never; + When('I set the listing property with the domain entity', () => { + adapter.listing = itemListing; + }); - Given('an ItemListing domain entity', () => { - const listingId = new MongooseSeedwork.ObjectId(); - const listing = makeListingDoc({ id: listingId as never }); - const setSpy = vi.fn(); - itemListing = { props: { doc: listing }, id: listingId.toString() } as never; - doc = makeConversationDoc({ set: setSpy }); - adapter = new ConversationDomainAdapter(doc); - }); + Then('the listing should be set correctly', () => { + expect(doc.set).toHaveBeenCalledWith('listing', expect.anything()); + }); + }, + ); - When('I set the listing property with the domain entity', () => { - adapter.listing = itemListing; - }); + Scenario( + 'Setting sharer with AdminUser domain entity', + ({ Given, When, Then }) => { + let adminUser: never; + + Given('an AdminUser domain entity', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + // Create an AdminUser domain entity instance + adminUser = { + props: { doc: adminUserDoc }, + id: adminUserDoc.id, + constructor: { name: 'AdminUser' }, + } as never; + // Mock instanceof check by ensuring it's recognized as AdminUser + Object.setPrototypeOf( + adminUser, + Domain.Contexts.User.AdminUser.AdminUser.prototype, + ); + doc = makeConversationDoc(); + adapter = new ConversationDomainAdapter(doc); + }); - Then('the listing should be set correctly', () => { - expect(doc.set).toHaveBeenCalledWith('listing', expect.anything()); - }); - }); + When('I set the sharer property with the AdminUser domain entity', () => { + adapter.sharer = adminUser; + }); - Scenario('Getting reserver when it is an admin user', ({ Given, When, Then }) => { - Given('a conversation with an admin user as reserver', () => { - const adminUserDoc = { - ...makeUserDoc(), - userType: 'admin-user', - } as never; - doc = makeConversationDoc({ reserver: adminUserDoc, sharer: sharerDoc }); - adapter = new ConversationDomainAdapter(doc); - }); + Then('the sharer should be set to the admin user doc', () => { + expect(doc.set).toHaveBeenCalledWith('sharer', expect.anything()); + }); + }, + ); - When('I access the reserver property', () => { - result = adapter.reserver; - }); + Scenario( + 'Setting reserver with PersonalUser domain entity', + ({ Given, When, Then }) => { + let personalUser: never; + + Given('a PersonalUser domain entity', () => { + const userDoc = makeUserDoc(); + // Create a PersonalUser domain entity instance + personalUser = { + props: { doc: userDoc }, + id: userDoc.id, + constructor: { name: 'PersonalUser' }, + } as never; + // Mock instanceof check + Object.setPrototypeOf( + personalUser, + Domain.Contexts.User.PersonalUser.PersonalUser.prototype, + ); + doc = makeConversationDoc(); + adapter = new ConversationDomainAdapter(doc); + }); - Then('it should return an AdminUserDomainAdapter for reserver', () => { - expect(result).toBeDefined(); - }); - }); + When('I set the reserver property with the domain entity', () => { + adapter.reserver = personalUser; + }); - Scenario('Loading sharer when it is an admin user', ({ Given, When, Then }) => { - Given('a conversation with an admin user as sharer', () => { - const adminUserDoc = { - ...makeUserDoc(), - userType: 'admin-user', - } as never; - doc = makeConversationDoc({ sharer: adminUserDoc }); - adapter = new ConversationDomainAdapter(doc); - }); + Then('the reserver should be set correctly', () => { + expect(doc.set).toHaveBeenCalledWith('reserver', expect.anything()); + }); + }, + ); - When('I call loadSharer on the adapter', async () => { - result = await adapter.loadSharer(); - }); + Scenario( + 'Setting reserver with AdminUser domain entity', + ({ Given, When, Then }) => { + let adminUser: never; + + Given('an AdminUser domain entity', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + // Create an AdminUser domain entity instance + adminUser = { + props: { doc: adminUserDoc }, + id: adminUserDoc.id, + constructor: { name: 'AdminUser' }, + } as never; + // Mock instanceof check + Object.setPrototypeOf( + adminUser, + Domain.Contexts.User.AdminUser.AdminUser.prototype, + ); + doc = makeConversationDoc(); + adapter = new ConversationDomainAdapter(doc); + }); - Then('it should return an AdminUserDomainAdapter for sharer', () => { - expect(result).toBeDefined(); - }); - }); + When( + 'I set the reserver property with the AdminUser domain entity', + () => { + adapter.reserver = adminUser; + }, + ); - Scenario('Loading reserver when it is an admin user', ({ Given, When, Then }) => { - Given('a conversation with an admin user as reserver', () => { - const adminUserDoc = { - ...makeUserDoc(), - userType: 'admin-user', - } as never; - doc = makeConversationDoc({ reserver: adminUserDoc, sharer: sharerDoc }); - adapter = new ConversationDomainAdapter(doc); - }); + Then('the reserver should be set to the admin user doc', () => { + expect(doc.set).toHaveBeenCalledWith('reserver', expect.anything()); + }); + }, + ); - When('I call loadReserver on the adapter', async () => { - result = await adapter.loadReserver(); - }); + Scenario( + 'Getting reserver when it is an admin user', + ({ Given, When, Then }) => { + Given('a conversation with an admin user as reserver', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + doc = makeConversationDoc({ + reserver: adminUserDoc, + sharer: sharerDoc, + }); + adapter = new ConversationDomainAdapter(doc); + }); - Then('it should return an AdminUserDomainAdapter for reserver', () => { - expect(result).toBeDefined(); - }); - }); + When('I access the reserver property', () => { + result = adapter.reserver; + }); - Scenario('Getting the sharer property when populated as personal user', ({ When, Then }) => { - When('I get the sharer property', () => { - result = adapter.sharer; - }); + Then('it should return an AdminUserDomainAdapter for reserver', () => { + expect(result).toBeDefined(); + }); + }, + ); - Then('it should return a PersonalUserDomainAdapter entityReference', () => { - expect(result).toBeDefined(); - }); - }); + Scenario( + 'Loading sharer when it is an admin user', + ({ Given, When, Then }) => { + Given('a conversation with an admin user as sharer', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + doc = makeConversationDoc({ sharer: adminUserDoc }); + adapter = new ConversationDomainAdapter(doc); + }); + + When('I call loadSharer on the adapter', async () => { + result = await adapter.loadSharer(); + }); + + Then('it should return an AdminUserDomainAdapter for sharer', () => { + expect(result).toBeDefined(); + }); + }, + ); + Scenario( + 'Loading reserver when it is an admin user', + ({ Given, When, Then }) => { + Given('a conversation with an admin user as reserver', () => { + const adminUserDoc = { + ...makeUserDoc(), + userType: 'admin-user', + } as never; + doc = makeConversationDoc({ + reserver: adminUserDoc, + sharer: sharerDoc, + }); + adapter = new ConversationDomainAdapter(doc); + }); + + When('I call loadReserver on the adapter', async () => { + result = await adapter.loadReserver(); + }); + Then('it should return an AdminUserDomainAdapter for reserver', () => { + expect(result).toBeDefined(); + }); + }, + ); + + Scenario( + 'Getting the sharer property when populated as personal user', + ({ When, Then }) => { + When('I get the sharer property', () => { + result = adapter.sharer; + }); + + Then( + 'it should return a PersonalUserDomainAdapter entityReference', + () => { + expect(result).toBeDefined(); + }, + ); + }, + ); Scenario('Setting messages property', ({ When, Then }) => { When('I set the messages property to a list', () => { @@ -606,9 +795,62 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { } }); - Then('an error should be thrown indicating listing is not populated in load', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('listing is not populated'); + Then( + 'an error should be thrown indicating listing is not populated in load', + () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('listing is not populated'); + }, + ); + }); + + Scenario('Getting expiresAt when not set', ({ When, Then }) => { + When('I get the expiresAt property when it is undefined', () => { + const doc = makeConversationDoc({ expiresAt: undefined }); + adapter = new ConversationDomainAdapter(doc); + result = adapter.expiresAt; + }); + + Then('it should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + Scenario('Getting expiresAt when set', ({ When, Then }) => { + let testDate: Date; + + When('I get the expiresAt property when it is set', () => { + testDate = new Date('2026-07-01T00:00:00.000Z'); + const doc = makeConversationDoc({ expiresAt: testDate }); + adapter = new ConversationDomainAdapter(doc); + result = adapter.expiresAt; + }); + + Then('it should return the correct date', () => { + expect(result).toBe(testDate); + }); + }); + + Scenario('Setting expiresAt property', ({ When, Then }) => { + let testDate: Date; + let doc: ConversationDoc; + + When('I set the expiresAt property to a date', () => { + testDate = new Date('2026-07-01T00:00:00.000Z'); + doc = makeConversationDoc(); + adapter = new ConversationDomainAdapter(doc); + + // Spy on Mongoose set API to ensure it's being used + const setSpy = vi.spyOn(doc, 'set'); + + adapter.expiresAt = testDate; + + // Verify Mongoose set API was called correctly + expect(setSpy).toHaveBeenCalledWith('expiresAt', testDate); + }); + + Then("the document's expiresAt should be set correctly", () => { + expect(adapter.expiresAt).toBe(testDate); }); }); }); diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.ts b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.ts index 865881c16..5fb41cf79 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.ts +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.domain-adapter.ts @@ -30,8 +30,8 @@ export class ConversationDomainAdapter } if (this.doc.sharer instanceof MongooseSeedwork.ObjectId) { return { - id: this.doc.sharer.toString(), - } as Domain.Contexts.User.UserEntityReference; + id: this.doc.sharer.toString(), + } as Domain.Contexts.User.UserEntityReference; } // Check userType discriminator to determine which adapter to use const sharerDoc = this.doc.sharer as @@ -165,8 +165,8 @@ export class ConversationDomainAdapter } if (this.doc.listing instanceof MongooseSeedwork.ObjectId) { return { - id: this.doc.listing.toString(), - } as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; + id: this.doc.listing.toString(), + } as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; } return new ItemListingDomainAdapter( this.doc.listing as Models.Listing.ItemListing, @@ -199,6 +199,63 @@ export class ConversationDomainAdapter this.doc.set('listing', new MongooseSeedwork.ObjectId(listing.id)); } + get reservationRequest(): + | Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference + | undefined { + if (!this.doc.reservationRequest) { + return undefined; + } + if (this.doc.reservationRequest instanceof MongooseSeedwork.ObjectId) { + return { + id: this.doc.reservationRequest.toString(), + } as Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference; + } + + return { + id: ( + this.doc.reservationRequest as unknown as { + _id: { toString: () => string }; + } + )._id.toString(), + } as Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference; + } + + async loadReservationRequest(): Promise< + | Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference + | undefined + > { + if (!this.doc.reservationRequest) { + return undefined; + } + if (this.doc.reservationRequest instanceof MongooseSeedwork.ObjectId) { + await this.doc.populate('reservationRequest'); + } + + return { + id: ( + this.doc.reservationRequest as unknown as { + _id: { toString: () => string }; + } + )._id.toString(), + } as Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference; + } + + set reservationRequest(reservationRequest: + | Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference + | undefined) { + if (!reservationRequest) { + this.doc.set('reservationRequest', undefined); + return; + } + if (!reservationRequest?.id) { + throw new Error('reservationRequest reference is missing id'); + } + this.doc.set( + 'reservationRequest', + new MongooseSeedwork.ObjectId(reservationRequest.id), + ); + } + get messagingConversationId(): string { return this.doc.messagingConversationId; } @@ -226,4 +283,12 @@ export class ConversationDomainAdapter // TODO: Implement proper message loading from separate collection or populate from subdocuments return Promise.resolve(this._messages); } + + get expiresAt(): Date | undefined { + return this.doc.expiresAt; + } + + set expiresAt(value: Date | undefined) { + this.doc.set('expiresAt', value); + } } diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.test.ts b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.test.ts index 9927b438b..fadb68c1c 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.test.ts @@ -30,14 +30,19 @@ function createValidObjectId(id: string): string { function makePassport(): Domain.Passport { return vi.mocked({ - conversation: { forConversation: vi.fn(() => ({ determineIf: () => true })) }, + conversation: { + forConversation: vi.fn(() => ({ determineIf: () => true })), + }, user: { forPersonalUser: vi.fn(() => ({ determineIf: () => true })) }, listing: { forItemListing: vi.fn(() => ({ determineIf: () => true })) }, } as unknown as Domain.Passport); } function makeEventBus(): DomainSeedwork.EventBus { - return vi.mocked({ dispatch: vi.fn(), register: vi.fn() } as DomainSeedwork.EventBus); + return vi.mocked({ + dispatch: vi.fn(), + register: vi.fn(), + } as DomainSeedwork.EventBus); } // use shared makeNewableMock from test-utils @@ -87,26 +92,41 @@ function makeConversationDoc(id = 'conv-1'): Models.Conversation.Conversation { } function createChainableQuery(result: T) { - const query = { populate: vi.fn(), exec: vi.fn().mockResolvedValue(result) }; + const query = { + populate: vi.fn(), + limit: vi.fn(), + sort: vi.fn(), + exec: vi.fn().mockResolvedValue(result), + }; query.populate.mockReturnValue(query); + query.limit.mockReturnValue(query); + query.sort.mockReturnValue(query); return query; } function setupConversationRepo( mockDoc: Models.Conversation.Conversation, - overrides?: { findById?: () => unknown, findOne?: () => unknown, modelCtor?: Models.Conversation.ConversationModelType } + overrides?: { + findById?: () => unknown; + findOne?: () => unknown; + find?: () => unknown; + modelCtor?: Models.Conversation.ConversationModelType; + }, ): ConversationRepository { - const modelType = overrides?.modelCtor ?? ({ - findById: overrides?.findById ?? (() => createChainableQuery(mockDoc)), - findOne: overrides?.findOne ?? (() => createChainableQuery(mockDoc)) - } as unknown as Models.Conversation.ConversationModelType); - + const modelType = + overrides?.modelCtor ?? + ({ + findById: overrides?.findById ?? (() => createChainableQuery(mockDoc)), + findOne: overrides?.findOne ?? (() => createChainableQuery(mockDoc)), + find: overrides?.find ?? (() => createChainableQuery([mockDoc])), + } as unknown as Models.Conversation.ConversationModelType); + return new ConversationRepository( - makePassport(), - modelType, - new ConversationConverter(), - makeEventBus(), - vi.mocked({} as mongoose.ClientSession) + makePassport(), + modelType, + new ConversationConverter(), + makeEventBus(), + vi.mocked({} as mongoose.ClientSession), ); } @@ -152,54 +172,45 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); And("the domain object's sharer should be populated", () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.sharer.id).toBeDefined(); }); And("the domain object's reserver should be populated", () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.reserver.id).toBeDefined(); }); And("the domain object's listing should be populated", () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.listing.id).toBeDefined(); }); }, ); - Scenario( - 'Getting a conversation by nonexistent ID', - ({ When, Then }) => { - When('I call getByIdWithReferences with "nonexistent-id"', async () => { - // Setup repository with null result for this scenario - repository = setupConversationRepo(mockDoc, { - findById: () => createChainableQuery(null) - }); - - try { - result = await repository.getByIdWithReferences('nonexistent-id'); - } catch (error) { - result = error; - } + Scenario('Getting a conversation by nonexistent ID', ({ When, Then }) => { + When('I call getByIdWithReferences with "nonexistent-id"', async () => { + // Setup repository with null result for this scenario + repository = setupConversationRepo(mockDoc, { + findById: () => createChainableQuery(null), }); - Then( - 'an error should be thrown indicating "Conversation with id nonexistent-id not found"', - () => { - expect(result).toBeInstanceOf(Error); - expect((result as Error).message).toContain( - 'Conversation with id nonexistent-id not found', - ); - }, - ); - }, - ); + + try { + result = await repository.getByIdWithReferences('nonexistent-id'); + } catch (error) { + result = error; + } + }); + Then( + 'an error should be thrown indicating "Conversation with id nonexistent-id not found"', + () => { + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toContain( + 'Conversation with id nonexistent-id not found', + ); + }, + ); + }); Scenario( 'Getting a conversation by messaging ID', @@ -222,9 +233,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { 'the domain object\'s messagingConversationId should be "twilio-123"', () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.messagingConversationId).toBe('twilio-123'); }, ); @@ -237,7 +246,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { When('I call getByMessagingId with "nonexistent-twilio-id"', async () => { // Setup repository with null result for this scenario repository = setupConversationRepo(mockDoc, { - findOne: () => createChainableQuery(null) + findOne: () => createChainableQuery(null), }); result = await repository.getByMessagingId('nonexistent-twilio-id'); @@ -273,16 +282,12 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); And('the domain object\'s sharer id should be "user-1"', () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.sharer.id).toBe('user-1'); }); And('the domain object\'s reserver id should be "user-2"', () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.reserver.id).toBe('user-2'); }); }, @@ -296,7 +301,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { async () => { // Setup repository with null result for this scenario repository = setupConversationRepo(mockDoc, { - findOne: () => createChainableQuery(null) + findOne: () => createChainableQuery(null), }); result = await repository.getByIdWithSharerReserver( @@ -345,20 +350,20 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { schemaVersion: '1.0.0', set: vi.fn(), }; - + // Allow dynamic property assignment (like a real Mongoose document) Object.defineProperty(mockNewDoc, 'messagingConversationId', { writable: true, configurable: true, enumerable: true, - value: '' + value: '', }); - + // Setup repository with constructor mock repository = setupConversationRepo(mockDoc, { - modelCtor: makeNewableMock(() => mockNewDoc) as unknown as Models.Conversation.ConversationModelType, + modelCtor: makeNewableMock(() => mockNewDoc) as unknown as Models.Conversation.ConversationModelType, }); - + result = await repository.getNewInstance(sharer, reserver, listing); }, ); @@ -369,18 +374,192 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); And('the domain object should have a messagingConversationId', () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.messagingConversationId).toBeDefined(); }); And("the domain object's messages should be empty", () => { const conversation = - result as Domain.Contexts.Conversation.Conversation.Conversation< - Domain.Contexts.Conversation.Conversation.ConversationProps - >; + result as Domain.Contexts.Conversation.Conversation.Conversation; expect(conversation.messages).toEqual([]); }); }, ); + + Scenario( + 'Getting conversations by listing ID', + ({ Given, When, Then, And }) => { + Given('Conversation documents exist with listing "listing-1"', () => { + // Set up mock with listing + mockDoc = { + ...makeConversationDoc('conv-1'), + listing: { id: 'listing-1' }, + } as unknown as Models.Conversation.Conversation; + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([mockDoc]), + }); + }); + When('I call getByListingId with "listing-1"', async () => { + result = await repository.getByListingId( + createValidObjectId('listing-1'), + ); + }); + Then('I should receive an array of Conversation domain objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); + And('each domain object should have the listing id "listing-1"', () => { + const conversations = + result as Domain.Contexts.Conversation.Conversation.Conversation[]; + for (const conversation of conversations) { + expect(conversation.listing.id).toBe('listing-1'); + } + }); + }, + ); + + Scenario( + 'Getting conversations by nonexistent listing ID', + ({ When, Then }) => { + When('I call getByListingId with "nonexistent-listing"', async () => { + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([]), + }); + result = await repository.getByListingId( + createValidObjectId('nonexistent-listing'), + ); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); + + Scenario('Getting expired conversations', ({ Given, When, Then }) => { + Given('Conversation documents exist with expiresAt in the past', () => { + const expiredDoc = { + ...makeConversationDoc('expired-conv'), + expiresAt: new Date('2020-01-01'), + } as unknown as Models.Conversation.Conversation; + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([expiredDoc]), + }); + }); + When('I call getExpired', async () => { + result = await repository.getExpired(); + }); + Then( + 'I should receive an array of expired Conversation domain objects', + () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + }, + ); + }); + + Scenario( + 'Getting expired conversations when none exist', + ({ Given, When, Then }) => { + Given('no Conversation documents have expiresAt in the past', () => { + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([]), + }); + }); + When('I call getExpired', async () => { + result = await repository.getExpired(); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); + + Scenario( + 'Getting expired conversations with limit', + ({ Given, When, Then }) => { + Given( + 'multiple Conversation documents exist with expiresAt in the past', + () => { + const expiredDocs = [ + { + ...makeConversationDoc('expired-1'), + expiresAt: new Date('2020-01-01'), + }, + { + ...makeConversationDoc('expired-2'), + expiresAt: new Date('2020-01-02'), + }, + ] as unknown as Models.Conversation.Conversation[]; + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery(expiredDocs), + }); + }, + ); + When('I call getExpired with limit 2', async () => { + result = await repository.getExpired(2); + }); + Then('I should receive at most 2 Conversation domain objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeLessThanOrEqual(2); + }); + }, + ); + + Scenario( + 'Getting conversations by reservation request ID', + ({ Given, When, Then, And }) => { + const validRequestId = createValidObjectId('request-1'); + Given( + 'Conversation documents exist with reservationRequest "request-1"', + () => { + mockDoc = { + ...makeConversationDoc('conv-1'), + reservationRequest: new MongooseSeedwork.ObjectId(validRequestId), + } as unknown as Models.Conversation.Conversation; + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([mockDoc]), + }); + }, + ); + When('I call getByReservationRequestId with "request-1"', async () => { + result = await repository.getByReservationRequestId(validRequestId); + }); + Then('I should receive an array of Conversation domain objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); + And( + 'each domain object should have the reservationRequest id "request-1"', + () => { + const conversations = + result as Domain.Contexts.Conversation.Conversation.Conversation[]; + for (const conversation of conversations) { + expect(conversation.reservationRequest?.id).toBe(validRequestId); + } + }, + ); + }, + ); + + Scenario( + 'Getting conversations by nonexistent reservation request ID', + ({ When, Then }) => { + When( + 'I call getByReservationRequestId with "nonexistent-request"', + async () => { + repository = setupConversationRepo(mockDoc, { + find: () => createChainableQuery([]), + }); + result = await repository.getByReservationRequestId( + createValidObjectId('nonexistent-request'), + ); + }, + ); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); }); diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.ts b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.ts index c7d2003aa..06219a5eb 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.ts +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.ts @@ -65,6 +65,61 @@ export class ConversationRepository return this.typeConverter.toDomain(mongoConversation, this.passport); } + async getByListingId( + listingId: string, + ): Promise< + Domain.Contexts.Conversation.Conversation.Conversation[] + > { + const mongoConversations = await this.model + .find({ listing: new MongooseSeedwork.ObjectId(listingId) }) + .populate('sharer') + .populate('reserver') + .populate('listing') + .exec(); + return mongoConversations.map((doc) => + this.typeConverter.toDomain(doc, this.passport), + ); + } + + async getByReservationRequestId( + reservationRequestId: string, + ): Promise< + Domain.Contexts.Conversation.Conversation.Conversation[] + > { + const mongoConversations = await this.model + .find({ + reservationRequest: new MongooseSeedwork.ObjectId(reservationRequestId), + }) + .populate('sharer') + .populate('reserver') + .populate('listing') + .populate('reservationRequest') + .exec(); + return mongoConversations.map((doc) => + this.typeConverter.toDomain(doc, this.passport), + ); + } + + async getExpired( + limit = 100, + ): Promise< + Domain.Contexts.Conversation.Conversation.Conversation[] + > { + const mongoConversations = await this.model + .find({ + expiresAt: { $lte: new Date() }, + }) + .sort({ expiresAt: 1 }) + .limit(limit) + .populate('sharer') + .populate('reserver') + .populate('listing') + .exec(); + return mongoConversations.map((doc) => + this.typeConverter.toDomain(doc, this.passport), + ); + } + // biome-ignore lint:noRequireAwait async getNewInstance( sharer: Domain.Contexts.User.UserEntityReference, diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.domain-adapter.feature b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.domain-adapter.feature index fefd8485c..e4458e002 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.domain-adapter.feature +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.domain-adapter.feature @@ -121,6 +121,21 @@ Feature: ConversationDomainAdapter When I set the listing property with the domain entity Then the listing should be set correctly + Scenario: Setting sharer with AdminUser domain entity + Given an AdminUser domain entity + When I set the sharer property with the AdminUser domain entity + Then the sharer should be set to the admin user doc + + Scenario: Setting reserver with PersonalUser domain entity + Given a PersonalUser domain entity + When I set the reserver property with the domain entity + Then the reserver should be set correctly + + Scenario: Setting reserver with AdminUser domain entity + Given an AdminUser domain entity + When I set the reserver property with the AdminUser domain entity + Then the reserver should be set to the admin user doc + Scenario: Getting reserver when it is an admin user Given a conversation with an admin user as reserver When I access the reserver property @@ -147,3 +162,15 @@ Feature: ConversationDomainAdapter Scenario: Loading listing when not populated When I call loadListing on an adapter with no listing Then an error should be thrown indicating listing is not populated in load + + Scenario: Getting expiresAt when not set + When I get the expiresAt property when it is undefined + Then it should return undefined + + Scenario: Getting expiresAt when set + When I get the expiresAt property when it is set + Then it should return the correct date + + Scenario: Setting expiresAt property + When I set the expiresAt property to a date + Then the document's expiresAt should be set correctly diff --git a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.repository.feature b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.repository.feature index be882546a..ed417434a 100644 --- a/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.repository.feature +++ b/packages/sthrift/persistence/src/datasources/domain/conversation/conversation/features/conversation.repository.feature @@ -45,3 +45,38 @@ And valid Conversation documents exist in the database Then I should receive a new Conversation domain object And the domain object should have a messagingConversationId And the domain object's messages should be empty + + Scenario: Getting conversations by listing ID + Given Conversation documents exist with listing "listing-1" + When I call getByListingId with "listing-1" + Then I should receive an array of Conversation domain objects + And each domain object should have the listing id "listing-1" + + Scenario: Getting conversations by nonexistent listing ID + When I call getByListingId with "nonexistent-listing" + Then I should receive an empty array + + Scenario: Getting expired conversations + Given Conversation documents exist with expiresAt in the past + When I call getExpired + Then I should receive an array of expired Conversation domain objects + + Scenario: Getting expired conversations when none exist + Given no Conversation documents have expiresAt in the past + When I call getExpired + Then I should receive an empty array + + Scenario: Getting expired conversations with limit + Given multiple Conversation documents exist with expiresAt in the past + When I call getExpired with limit 2 + Then I should receive at most 2 Conversation domain objects + + Scenario: Getting conversations by reservation request ID + Given Conversation documents exist with reservationRequest "request-1" + When I call getByReservationRequestId with "request-1" + Then I should receive an array of Conversation domain objects + And each domain object should have the reservationRequest id "request-1" + + Scenario: Getting conversations by nonexistent reservation request ID + When I call getByReservationRequestId with "nonexistent-request" + Then I should receive an empty array diff --git a/packages/sthrift/persistence/src/datasources/index.ts b/packages/sthrift/persistence/src/datasources/index.ts index 43c8669c5..36f3d0ca4 100644 --- a/packages/sthrift/persistence/src/datasources/index.ts +++ b/packages/sthrift/persistence/src/datasources/index.ts @@ -10,7 +10,10 @@ import { type MessagingDataSource, MessagingDataSourceImplementation, } from './messaging/index.ts'; -import { type PaymentDataSource,PaymentDataSourceImplementation } from './payment/index.ts'; +import { + type PaymentDataSource, + PaymentDataSourceImplementation, +} from './payment/index.ts'; import type { PaymentService } from '@cellix/payment-service'; export type DataSources = { @@ -21,31 +24,48 @@ export type DataSources = { }; export type DataSourcesFactory = { - withPassport: (passport: Domain.Passport, messagingService: MessagingService, paymentService: PaymentService) => DataSources; + withPassport: ( + passport: Domain.Passport, + messagingService: MessagingService, + paymentService: PaymentService, + ) => DataSources; withSystemPassport: () => DataSources; }; export const DataSourcesFactoryImpl = ( models: ModelsContext, ): DataSourcesFactory => { - const withPassport = (passport: Domain.Passport, messagingService: MessagingService, paymentService: PaymentService): DataSources => { + const withPassport = ( + passport: Domain.Passport, + messagingService: MessagingService, + paymentService: PaymentService, + ): DataSources => { return { domainDataSource: DomainDataSourceImplementation(models, passport), readonlyDataSource: ReadonlyDataSourceImplementation(models, passport), messagingDataSource: MessagingDataSourceImplementation(messagingService), - paymentDataSource: PaymentDataSourceImplementation(paymentService, passport), + paymentDataSource: PaymentDataSourceImplementation( + paymentService, + passport, + ), }; }; const withSystemPassport = (): DataSources => { const systemPassport = Domain.PassportFactory.forSystem({ + // User permissions // canManageMembers: true, // canManageEndUserRolesAndPermissions: true, + // Conversation permissions - required for scheduled cleanup operations + canManageConversation: true, }); return { domainDataSource: DomainDataSourceImplementation(models, systemPassport), - readonlyDataSource: ReadonlyDataSourceImplementation(models, systemPassport), + readonlyDataSource: ReadonlyDataSourceImplementation( + models, + systemPassport, + ), }; }; diff --git a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts index fe77579dd..d4c76a90e 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts @@ -106,7 +106,9 @@ function makeMockConversation( ): Models.Conversation.Conversation { const conversationId = overrides.id || 'conv-1'; const defaultConv = { - _id: new MongooseSeedwork.ObjectId(createValidObjectId(conversationId as string)), + _id: new MongooseSeedwork.ObjectId( + createValidObjectId(conversationId as string), + ), id: conversationId, sharer: makeMockUser('user-1'), reserver: makeMockUser('user-2'), @@ -130,6 +132,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { let result: unknown; BeforeEachScenario(() => { + // Restore all mocks before each scenario to prevent state leakage + vi.restoreAllMocks(); + passport = makePassport(); mockConversations = [makeMockConversation()]; @@ -144,7 +149,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { // Configure methods to return the query object for chaining mockQuery.lean.mockReturnValue(mockQuery); mockQuery.populate.mockReturnValue(mockQuery); - + // SONARQUBE SUPPRESSION: S7739 - Intentional thenable mock // This object intentionally implements the 'then' property to mock Mongoose // query behavior. Mongoose queries are thenable and can be awaited. @@ -187,10 +192,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting all conversations', ({ Given, When, Then, And }) => { Given('multiple Conversation documents in the database', () => { - mockConversations = [ - makeMockConversation(), - makeMockConversation(), - ]; + mockConversations = [makeMockConversation(), makeMockConversation()]; }); When('I call getAll', async () => { result = await repository.getAll(); @@ -228,7 +230,9 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting a conversation by nonexistent ID', ({ When, Then }) => { When('I call getById with "nonexistent-id"', async () => { - mockModel.findById = vi.fn(() => createNullPopulateChain(null)) as unknown as typeof mockModel.findById; + mockModel.findById = vi.fn(() => + createNullPopulateChain(null), + ) as unknown as typeof mockModel.findById; result = await repository.getById('nonexistent-id'); }); @@ -277,11 +281,14 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Then('I should receive an array of Conversation entities', () => { expect(Array.isArray(result)).toBe(true); }); - And('the array should contain conversations where user is reserver', () => { - const conversations = - result as Domain.Contexts.Conversation.Conversation.ConversationEntityReference[]; - expect(conversations.length).toBeGreaterThan(0); - }); + And( + 'the array should contain conversations where user is reserver', + () => { + const conversations = + result as Domain.Contexts.Conversation.Conversation.ConversationEntityReference[]; + expect(conversations.length).toBeGreaterThan(0); + }, + ); }, ); @@ -289,9 +296,13 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { 'Getting conversations by user ID with no conversations', ({ When, Then }) => { When('I call getByUser with "user-without-conversations"', async () => { - mockModel.find = vi.fn(() => createNullPopulateChain([])) as unknown as typeof mockModel.find; + mockModel.find = vi.fn(() => + createNullPopulateChain([]), + ) as unknown as typeof mockModel.find; - result = await repository.getByUser(createValidObjectId('user-without-conversations')); + result = await repository.getByUser( + createValidObjectId('user-without-conversations'), + ); }); Then('I should receive an empty array', () => { expect(Array.isArray(result)).toBe(true); @@ -313,110 +324,239 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }, ); - Scenario('Getting conversation by sharer, reserver, and listing', ({ Given, When, Then }) => { - let sharerId: string; - let reserverId: string; - let listingId: string; + Scenario( + 'Getting conversation by sharer, reserver, and listing', + ({ Given, When, Then }) => { + let sharerId: string; + let reserverId: string; + let listingId: string; + + Given('valid sharer, reserver, and listing IDs', () => { + sharerId = createValidObjectId('sharer-1'); + reserverId = createValidObjectId('reserver-1'); + listingId = createValidObjectId('listing-1'); + + mockModel.findOne = vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(makeMockConversation()), + }) as never; + }); - Given('valid sharer, reserver, and listing IDs', () => { - sharerId = createValidObjectId('sharer-1'); - reserverId = createValidObjectId('reserver-1'); - listingId = createValidObjectId('listing-1'); + When('I call getBySharerReserverListing', async () => { + result = await repository.getBySharerReserverListing( + sharerId, + reserverId, + listingId, + ); + }); - mockModel.findOne = vi.fn().mockReturnValue({ - lean: vi.fn().mockResolvedValue(makeMockConversation()), - }) as never; - }); + Then('I should receive a Conversation entity or null', () => { + expect(result).toBeDefined(); + }); + }, + ); - When('I call getBySharerReserverListing', async () => { - result = await repository.getBySharerReserverListing(sharerId, reserverId, listingId); - }); + Scenario( + 'Getting conversation with missing sharer ID', + ({ Given, When, Then }) => { + Given('empty sharer ID', () => { + // Empty string setup + }); - Then('I should receive a Conversation entity or null', () => { - expect(result).toBeDefined(); - }); - }); + When('I call getBySharerReserverListing with empty sharer', async () => { + result = await repository.getBySharerReserverListing( + '', + createValidObjectId('reserver'), + createValidObjectId('listing'), + ); + }); - Scenario('Getting conversation with missing sharer ID', ({ Given, When, Then }) => { - Given('empty sharer ID', () => { - // Empty string setup - }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }, + ); - When('I call getBySharerReserverListing with empty sharer', async () => { - result = await repository.getBySharerReserverListing('', createValidObjectId('reserver'), createValidObjectId('listing')); - }); + Scenario( + 'Getting conversation with missing reserver ID', + ({ Given, When, Then }) => { + Given('empty reserver ID', () => { + // Empty string setup + }); - Then('it should return null', () => { - expect(result).toBeNull(); - }); - }); + When( + 'I call getBySharerReserverListing with empty reserver', + async () => { + result = await repository.getBySharerReserverListing( + createValidObjectId('sharer'), + '', + createValidObjectId('listing'), + ); + }, + ); - Scenario('Getting conversation with missing reserver ID', ({ Given, When, Then }) => { - Given('empty reserver ID', () => { - // Empty string setup - }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }, + ); - When('I call getBySharerReserverListing with empty reserver', async () => { - result = await repository.getBySharerReserverListing(createValidObjectId('sharer'), '', createValidObjectId('listing')); - }); + Scenario( + 'Getting conversation with missing listing ID', + ({ Given, When, Then }) => { + Given('empty listing ID', () => { + // Empty string setup + }); - Then('it should return null', () => { - expect(result).toBeNull(); - }); - }); + When('I call getBySharerReserverListing with empty listing', async () => { + result = await repository.getBySharerReserverListing( + createValidObjectId('sharer'), + createValidObjectId('reserver'), + '', + ); + }); - Scenario('Getting conversation with missing listing ID', ({ Given, When, Then }) => { - Given('empty listing ID', () => { - // Empty string setup - }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }, + ); - When('I call getBySharerReserverListing with empty listing', async () => { - result = await repository.getBySharerReserverListing(createValidObjectId('sharer'), createValidObjectId('reserver'), ''); - }); + Scenario( + 'Getting conversation with error in database query', + ({ Given, When, Then }) => { + Given('an error will occur during the query', () => { + mockModel.findOne = vi.fn().mockImplementation(() => { + throw new Error('Database error'); + }); + }); - Then('it should return null', () => { - expect(result).toBeNull(); - }); - }); + When('I call getBySharerReserverListing', async () => { + result = await repository.getBySharerReserverListing( + createValidObjectId('sharer'), + createValidObjectId('reserver'), + createValidObjectId('listing'), + ); + }); - Scenario('Getting conversation with error in database query', ({ Given, When, Then }) => { - Given('an error will occur during the query', () => { - mockModel.findOne = vi.fn().mockImplementation(() => { - throw new Error('Database error'); + Then('it should return null due to error', () => { + expect(result).toBeNull(); }); - }); + }, + ); - When('I call getBySharerReserverListing', async () => { - result = await repository.getBySharerReserverListing( - createValidObjectId('sharer'), - createValidObjectId('reserver'), - createValidObjectId('listing') - ); - }); + Scenario( + 'Getting conversation with invalid ObjectId format', + ({ Given, When, Then }) => { + Given('an invalid ObjectId will cause an error', () => { + // Mock MongooseSeedwork.ObjectId constructor to throw + vi.spyOn(MongooseSeedwork, 'ObjectId').mockImplementationOnce(() => { + throw new Error('Invalid ObjectId'); + }); + }); - Then('it should return null due to error', () => { - expect(result).toBeNull(); - }); - }); + When('I call getBySharerReserverListing with invalid ID', async () => { + result = await repository.getBySharerReserverListing( + 'invalid-id', + createValidObjectId('reserver'), + createValidObjectId('listing'), + ); + }); - Scenario('Getting conversation with invalid ObjectId format', ({ Given, When, Then }) => { - Given('an invalid ObjectId will cause an error', () => { - // Mock MongooseSeedwork.ObjectId constructor to throw - vi.spyOn(MongooseSeedwork, 'ObjectId').mockImplementationOnce(() => { - throw new Error('Invalid ObjectId'); + Then('it should return null due to ObjectId error', () => { + expect(result).toBeNull(); }); - }); + }, + ); - When('I call getBySharerReserverListing with invalid ID', async () => { - result = await repository.getBySharerReserverListing( - 'invalid-id', - createValidObjectId('reserver'), - createValidObjectId('listing') - ); - }); + Scenario( + 'Getting conversations by listing ID', + ({ Given, When, Then, And }) => { + Given('Conversation documents exist with listing "listing-1"', () => { + mockConversations = [ + makeMockConversation({ + listing: makeMockListing('listing-1'), + }), + ]; + }); + When('I call getByListingId with "listing-1"', async () => { + result = await repository.getByListingId( + createValidObjectId('listing-1'), + ); + }); + Then('I should receive an array of Conversation entities', () => { + expect(Array.isArray(result)).toBe(true); + }); + And('each entity should have the listing id "listing-1"', () => { + const conversations = + result as Domain.Contexts.Conversation.Conversation.ConversationEntityReference[]; + expect(conversations.length).toBeGreaterThan(0); + }); + }, + ); - Then('it should return null due to ObjectId error', () => { - expect(result).toBeNull(); + Scenario( + 'Getting conversations by nonexistent listing ID', + ({ When, Then }) => { + When('I call getByListingId with "nonexistent-listing"', async () => { + // Override mockConversations with empty array for this scenario + mockConversations = []; + const mockQuery = { + lean: vi.fn(), + populate: vi.fn(), + exec: vi.fn().mockResolvedValue([]), + catch: vi.fn((onReject) => Promise.resolve([]).catch(onReject)), + }; + mockQuery.lean.mockReturnValue(mockQuery); + mockQuery.populate.mockReturnValue(mockQuery); + // biome-ignore lint/suspicious/noThenProperty: Intentional thenable mock for Mongoose queries + Object.defineProperty(mockQuery, 'then', { + value: vi.fn((onResolve) => Promise.resolve([]).then(onResolve)), + enumerable: false, + configurable: true, + }); + mockModel.find = vi.fn( + () => mockQuery, + ) as unknown as typeof mockModel.find; + + result = await repository.getByListingId( + createValidObjectId('nonexistent-listing'), + ); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); + + Scenario('Getting conversations by empty listing ID', ({ When, Then }) => { + When('I call getByListingId with an empty string', async () => { + result = await repository.getByListingId(''); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); }); }); + + Scenario( + 'Getting conversations by listing ID with database error', + ({ Given, When, Then }) => { + Given('an error will occur during the listing query', () => { + // Mock the find method to throw an error + mockModel.find = vi.fn(() => { + throw new Error('Database error'); + }) as unknown as typeof mockModel.find; + }); + When('I call getByListingId with "listing-1"', async () => { + result = await repository.getByListingId( + createValidObjectId('listing-1'), + ); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); }); diff --git a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts index ac862981f..9e5ee9524 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts @@ -34,6 +34,13 @@ export interface ConversationReadRepository { listingId: string, options?: FindOneOptions, ) => Promise; + + getByListingId: ( + listingId: string, + options?: FindOptions, + ) => Promise< + Domain.Contexts.Conversation.Conversation.ConversationEntityReference[] + >; } export class ConversationReadRepositoryImpl @@ -138,6 +145,33 @@ export class ConversationReadRepositoryImpl return null; } } + + async getByListingId( + listingId: string, + options?: FindOptions, + ): Promise< + Domain.Contexts.Conversation.Conversation.ConversationEntityReference[] + > { + if (!listingId || listingId.trim() === '') { + return []; + } + + try { + const result = await this.mongoDataSource.find( + { + listing: new MongooseSeedwork.ObjectId(listingId), + }, + { + ...options, + populateFields: populateFields, + }, + ); + return result.map((doc) => this.converter.toDomain(doc, this.passport)); + } catch (error) { + console.warn('Error with ObjectId in getByListingId:', error); + return []; + } + } } export const getConversationReadRepository = ( diff --git a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/features/conversation.read-repository.feature b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/features/conversation.read-repository.feature index e026a9dd1..48e04f9a7 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/features/conversation.read-repository.feature +++ b/packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/features/conversation.read-repository.feature @@ -69,3 +69,22 @@ And valid Conversation documents exist in the database Given an invalid ObjectId will cause an error When I call getBySharerReserverListing with invalid ID Then it should return null due to ObjectId error + + Scenario: Getting conversations by listing ID + Given Conversation documents exist with listing "listing-1" + When I call getByListingId with "listing-1" + Then I should receive an array of Conversation entities + And each entity should have the listing id "listing-1" + + Scenario: Getting conversations by nonexistent listing ID + When I call getByListingId with "nonexistent-listing" + Then I should receive an empty array + + Scenario: Getting conversations by empty listing ID + When I call getByListingId with an empty string + Then I should receive an empty array + + Scenario: Getting conversations by listing ID with database error + Given an error will occur during the listing query + When I call getByListingId with "listing-1" + Then I should receive an empty array diff --git a/packages/sthrift/persistence/src/datasources/readonly/listing/item/features/item-listing.read-repository.feature b/packages/sthrift/persistence/src/datasources/readonly/listing/item/features/item-listing.read-repository.feature index abe25422f..101948f96 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/listing/item/features/item-listing.read-repository.feature +++ b/packages/sthrift/persistence/src/datasources/readonly/listing/item/features/item-listing.read-repository.feature @@ -73,6 +73,19 @@ Given an ItemListingReadRepository instance with models and passport When I call getBySharer with invalid ObjectId Then I should receive empty array due to error + Scenario: Getting listings by states + Given ItemListing documents with different states + When I call getByStates with specific states + Then I should receive listings filtered by states + + Scenario: Getting listings by empty states array + When I call getByStates with empty array + Then I should receive empty array + + Scenario: Getting listings by states with error + When I call getByStates and database error occurs + Then the error should be thrown + Scenario: Getting paged listings when count query returns null When I call getPaged and count query returns null Then I should receive result with total 0 diff --git a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.test.ts b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.test.ts index 4b7bc04e2..96a5af8fd 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.test.ts @@ -515,6 +515,84 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + Scenario('Getting listings by states', ({ Given, When, Then }) => { + let result: unknown; + + Given('ItemListing documents with different states', () => { + // Mock data set up in When + }); + + When('I call getByStates with specific states', async () => { + const mockListings = [ + { id: '1', title: 'Item 1', state: 'active' }, + { id: '2', title: 'Item 2', state: 'archived' }, + ]; + + const mockModel = { + find: vi.fn(() => createQueryChain(mockListings)), + }; + + const mockModels = createMockModelsContext(mockModel); + const mockPassport = createMockPassport(); + + repository = getItemListingReadRepository(mockModels, mockPassport); + result = await repository.getByStates(['active', 'archived']); + }); + + Then('I should receive listings filtered by states', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); + }); + + Scenario('Getting listings by empty states array', ({ When, Then }) => { + let result: unknown; + + When('I call getByStates with empty array', async () => { + const mockModel = { + find: vi.fn(() => createQueryChain([])), + }; + + const mockModels = createMockModelsContext(mockModel); + const mockPassport = createMockPassport(); + + repository = getItemListingReadRepository(mockModels, mockPassport); + result = await repository.getByStates([]); + }); + + Then('I should receive empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }); + + Scenario('Getting listings by states with error', ({ When, Then }) => { + let thrownError: Error | undefined; + + When('I call getByStates and database error occurs', async () => { + const mockModel = { + find: vi.fn(() => { + throw new Error('Database error'); + }), + }; + + const mockModels = createMockModelsContext(mockModel); + const mockPassport = createMockPassport(); + + repository = getItemListingReadRepository(mockModels, mockPassport); + try { + await repository.getByStates(['active']); + } catch (error) { + thrownError = error as Error; + } + }); + + Then('the error should be thrown', () => { + expect(thrownError).toBeDefined(); + expect(thrownError?.message).toBe('Database error'); + }); + }); + Scenario( 'Getting paged listings when count query returns null', ({ When, Then }) => { diff --git a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts index 0633d697e..8c8c1cce7 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts @@ -40,11 +40,16 @@ export interface ItemListingReadRepository { ) => Promise< Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] >; + + getByStates: ( + states: string[], + options?: FindOptions, + ) => Promise< + Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[] + >; } -class ItemListingReadRepositoryImpl - implements ItemListingReadRepository -{ +class ItemListingReadRepositoryImpl implements ItemListingReadRepository { private readonly mongoDataSource: ItemListingDataSource; private readonly converter: ItemListingConverter; private readonly passport: Domain.Passport; @@ -189,6 +194,24 @@ class ItemListingReadRepositoryImpl return []; } } + + async getByStates( + states: string[], + options?: FindOptions, + ): Promise { + if (!states || states.length === 0) return []; + + const result = await this.mongoDataSource.find( + { state: { $in: states } }, + { + ...options, + }, + ); + + if (!result || result.length === 0) return []; + + return result.map((doc) => this.converter.toDomain(doc, this.passport)); + } } export function getItemListingReadRepository( diff --git a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/features/reservation-request.read-repository.feature b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/features/reservation-request.read-repository.feature index 430178b4f..4a9ed3704 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/features/reservation-request.read-repository.feature +++ b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/features/reservation-request.read-repository.feature @@ -63,3 +63,18 @@ And valid ReservationRequest documents exist in the database Then I should receive an array of ReservationRequest entities And the array should contain active reservation requests for the listing + Scenario: Getting reservation requests by multiple states + Given ReservationRequest documents with states "Closed", "Rejected", and "Cancelled" + When I call getByStates with ["Closed", "Rejected", "Cancelled"] + Then I should receive an array of ReservationRequest entities + And the array should only contain reservation requests with the specified states + + Scenario: Getting reservation requests by states with no matches + Given ReservationRequest documents exist in the database + When I call getByStates with ["NonexistentState"] + Then I should receive an empty array + + Scenario: Getting reservation requests by states with empty array + Given ReservationRequest documents exist in the database + When I call getByStates with an empty array + Then I should receive an empty array diff --git a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts index 9b334ca78..8e2a64b69 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts @@ -168,7 +168,16 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; mockModel = { - find: vi.fn(() => createMockQuery(mockReservationRequests)), + find: vi.fn((filter) => { + // Filter mockReservationRequests based on query filter + let filteredResults = [...mockReservationRequests]; + if (filter?.state?.$in) { + filteredResults = mockReservationRequests.filter((req) => + filter.state.$in.includes(req.state), + ); + } + return createMockQuery(filteredResults); + }), findById: vi.fn(() => createMockQuery(mockReservationRequests[0])), findOne: vi.fn(() => createMockQuery(mockReservationRequests[0] || null)), aggregate: vi.fn(() => ({ @@ -515,4 +524,84 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { ); }, ); + + Scenario( + 'Getting reservation requests by multiple states', + ({ Given, When, Then, And }) => { + Given( + 'ReservationRequest documents with states "Closed", "Rejected", and "Cancelled"', + () => { + mockReservationRequests = [ + makeMockReservationRequest({ state: 'Closed' }), + makeMockReservationRequest({ state: 'Rejected' }), + makeMockReservationRequest({ state: 'Cancelled' }), + makeMockReservationRequest({ state: 'Accepted' }), // Should be filtered out + ]; + }, + ); + When( + 'I call getByStates with ["Closed", "Rejected", "Cancelled"]', + async () => { + result = await repository.getByStates([ + 'Closed', + 'Rejected', + 'Cancelled', + ]); + }, + ); + Then('I should receive an array of ReservationRequest entities', () => { + expect(Array.isArray(result)).toBe(true); + }); + And( + 'the array should only contain reservation requests with the specified states', + () => { + const reservations = + result as Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[]; + expect(reservations.length).toBe(3); + for (const reservation of reservations) { + expect(['Closed', 'Rejected', 'Cancelled']).toContain( + reservation.state, + ); + } + }, + ); + }, + ); + + Scenario( + 'Getting reservation requests by states with no matches', + ({ Given, When, Then }) => { + Given('ReservationRequest documents exist in the database', () => { + mockReservationRequests = [ + makeMockReservationRequest({ state: 'Accepted' }), + makeMockReservationRequest({ state: 'Requested' }), + ]; + }); + When('I call getByStates with ["NonexistentState"]', async () => { + result = await repository.getByStates(['NonexistentState']); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }, + ); + Scenario( + 'Getting reservation requests by states with empty array', + ({ Given, When, Then }) => { + Given('ReservationRequest documents exist in the database', () => { + mockReservationRequests = [ + makeMockReservationRequest({ state: 'Accepted' }), + makeMockReservationRequest({ state: 'Requested' }), + ]; + }); + When('I call getByStates with an empty array', async () => { + result = await repository.getByStates([]); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + }, + ); }); diff --git a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.ts b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.ts index 61fcfc6a8..95cb2db4c 100644 --- a/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.ts +++ b/packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.ts @@ -70,6 +70,12 @@ export interface ReservationRequestReadRepository { ) => Promise< Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[] >; + getByStates: ( + states: string[], + options?: FindOptions, + ) => Promise< + Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[] + >; } /** @@ -284,6 +290,24 @@ export class ReservationRequestReadRepositoryImpl }; return await this.queryMany(filter, options); } + + async getByStates( + states: string[], + options?: FindOptions, + ): Promise< + Domain.Contexts.ReservationRequest.ReservationRequest.ReservationRequestEntityReference[] + > { + if (!states || states.length === 0) { + return []; + } + const filter: FilterQuery = { + state: { $in: states }, + }; + return await this.queryMany(filter, { + ...options, + populateFields: PopulatedFields, + }); + } } export const getReservationRequestReadRepository = ( diff --git a/packages/sthrift/ui-components/package.json b/packages/sthrift/ui-components/package.json index 56f05e48d..ce89e896f 100644 --- a/packages/sthrift/ui-components/package.json +++ b/packages/sthrift/ui-components/package.json @@ -53,7 +53,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.8.2", + "react-router-dom": "^7.12.0", "rxjs": "^7.8.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a455eece..9617eaa2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/browser-playwright@4.0.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) apps/docs: dependencies: @@ -293,8 +296,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.3.0)(react@19.2.0) react-router-dom: - specifier: ^7.8.0 - version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.12.0 + version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -645,8 +648,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.3.0)(react@19.2.0) react-router-dom: - specifier: ^7.9.3 - version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.12.0 + version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -684,6 +687,9 @@ importers: '@cellix/payment-service': specifier: workspace:* version: link:../../cellix/payment-service + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@sthrift/context-spec': specifier: workspace:* version: link:../context-spec @@ -1304,8 +1310,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.3.0)(react@19.2.0) react-router-dom: - specifier: ^7.8.2 - version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.12.0 + version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rxjs: specifier: ^7.8.2 version: 7.8.2 @@ -10196,8 +10202,8 @@ packages: peerDependencies: react: '>=15' - react-router-dom@7.9.5: - resolution: {integrity: sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==} + react-router-dom@7.12.0: + resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -10208,8 +10214,8 @@ packages: peerDependencies: react: '>=15' - react-router@7.9.5: - resolution: {integrity: sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==} + react-router@7.12.0: + resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -23164,11 +23170,11 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - react-router-dom@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router-dom@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-router: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router@5.3.4(react@19.2.0): dependencies: @@ -23183,7 +23189,7 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: cookie: 1.0.2 react: 19.2.0