Files
task-stream/apps/api/src/app/app.module.ts
Josep Piguillem 1550332d41 refactor(api): buses only in services, atomic commands, @Prop schemas
- 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>
2026-10-14 19:56:59 +02:00

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 {}