- Mongo read models are @Schema/@Prop classes (SchemaFactory), still loose: the props declared are the indexed or shaped ones. - CommandBus and QueryBus are used by the application services alone. Duplicating a ticket, bulk updates, CSV imports, proposals and emptying the trash chain several commands, so they are service methods now and their composite commands are gone; every command handler is atomic. No forwardRef is needed. - The due-date and trash timers are entry points: they live in the presenters and call the services. The automations event handler has its own file and calls AutomationsService. - The tracker's user directory asks IdentityService; the composition root hands the identity application module to the tracker infrastructure (withIdentity), with no global module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
|
import { Module } from '@nestjs/common';
|
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
import { GraphQLModule } from '@nestjs/graphql';
|
|
import { MongooseModule } from '@nestjs/mongoose';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { IdentityApplicationModule } from '@task-stream/api/identity/application';
|
|
import { IdentityInfrastructureModule } from '@task-stream/api/identity/infrastructure';
|
|
import {
|
|
AuthRequest,
|
|
IdentityPresentersModule,
|
|
WsAuthenticator,
|
|
} from '@task-stream/api/identity/presenters';
|
|
import { SharedModule } from '@task-stream/api/shared/application';
|
|
import { SharedInfrastructureModule } from '@task-stream/api/shared/infrastructure';
|
|
import { TrackerApplicationModule } from '@task-stream/api/tracker/application';
|
|
import { TrackerInfrastructureModule } from '@task-stream/api/tracker/infrastructure';
|
|
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
|
|
import { join } from 'node:path';
|
|
import { configSchema } from './config.schema';
|
|
import { HealthController } from './health.controller';
|
|
|
|
/*
|
|
* Composition root. Each bounded context is assembled outside-in:
|
|
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
|
|
*/
|
|
const identityApplication = IdentityApplicationModule.withInfrastructure(
|
|
IdentityInfrastructureModule,
|
|
);
|
|
const identity = IdentityPresentersModule.withApplication(identityApplication);
|
|
// The tracker's user directory asks the identity context (IdentityService):
|
|
// the same module, so one identity context serves both.
|
|
const tracker = TrackerPresentersModule.withApplication(
|
|
TrackerApplicationModule.withInfrastructure(
|
|
TrackerInfrastructureModule.withIdentity(identityApplication),
|
|
),
|
|
);
|
|
|
|
@Module({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
validationSchema: configSchema,
|
|
cache: true,
|
|
}),
|
|
|
|
// Write model: PostgreSQL (event store + lookup tables)
|
|
TypeOrmModule.forRootAsync({
|
|
inject: [ConfigService],
|
|
useFactory: (config: ConfigService) => ({
|
|
type: 'postgres',
|
|
url: config.getOrThrow<string>('POSTGRES_URL'),
|
|
autoLoadEntities: true,
|
|
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
|
|
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
|
|
uuidExtension: 'pgcrypto',
|
|
}),
|
|
}),
|
|
|
|
// Read model: MongoDB
|
|
MongooseModule.forRootAsync({
|
|
inject: [ConfigService],
|
|
useFactory: (config: ConfigService) => ({
|
|
uri: config.getOrThrow<string>('MONGO_URL'),
|
|
}),
|
|
}),
|
|
|
|
// Event store (PostgreSQL) → Redis stream → events bridge, AggregateRehydrator
|
|
SharedModule.withInfrastructure(
|
|
SharedInfrastructureModule.forRootAsync({
|
|
inject: [ConfigService],
|
|
useFactory: (config: ConfigService) => ({
|
|
redisUrl: config.getOrThrow<string>('REDIS_URL'),
|
|
streamKey: config.getOrThrow<string>('EVENT_STREAM_KEY'),
|
|
maxLength: config.getOrThrow<number>('EVENT_STREAM_MAX_LENGTH'),
|
|
eventsBridgeEnabled: config.getOrThrow<boolean>(
|
|
'EVENTS_BRIDGE_ENABLED',
|
|
),
|
|
}),
|
|
}),
|
|
),
|
|
|
|
// GraphQL + realtime subscriptions over graphql-ws
|
|
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
|
driver: ApolloDriver,
|
|
imports: [identity],
|
|
inject: [WsAuthenticator, ConfigService],
|
|
useFactory: (ws: WsAuthenticator, config: ConfigService) => ({
|
|
// Writes the SDL for frontend codegen; empty = keep the schema in memory.
|
|
autoSchemaFile: config.get<string>('GRAPHQL_SCHEMA_FILE')
|
|
? join(
|
|
process.cwd(),
|
|
config.get<string>('GRAPHQL_SCHEMA_FILE') as string,
|
|
)
|
|
: true,
|
|
sortSchema: true,
|
|
// GraphiQL on GET /graphql (send the Pireon token in the Headers tab).
|
|
graphiql: config.get<boolean>('GRAPHQL_PLAYGROUND')
|
|
? { url: '/graphql' }
|
|
: false,
|
|
playground: false,
|
|
includeStacktraceInErrorResponses:
|
|
config.get('NODE_ENV') === 'development',
|
|
introspection: config.get<boolean>('GRAPHQL_PLAYGROUND'),
|
|
subscriptions: {
|
|
'graphql-ws': {
|
|
path: '/graphql',
|
|
// connection_init payload: { "authorization": "Bearer <pireon token>" }
|
|
onConnect: async (ctx) => {
|
|
try {
|
|
(ctx.extra as { auth?: AuthRequest }).auth =
|
|
await ws.authenticate(ctx.connectionParams);
|
|
return true;
|
|
} catch {
|
|
return false; // closes the socket with 4403 Forbidden
|
|
}
|
|
},
|
|
},
|
|
},
|
|
context: ({
|
|
req,
|
|
extra,
|
|
}: {
|
|
req?: AuthRequest;
|
|
extra?: { auth?: AuthRequest };
|
|
}) => ({
|
|
req: extra?.auth ?? req,
|
|
}),
|
|
}),
|
|
}),
|
|
|
|
identity,
|
|
tracker,
|
|
],
|
|
controllers: [HealthController],
|
|
})
|
|
export class AppModule {}
|