first commit
This commit is contained in:
130
apps/api/src/app/app.module.ts
Normal file
130
apps/api/src/app/app.module.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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 identity = IdentityPresentersModule.withApplication(
|
||||
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule),
|
||||
);
|
||||
const tracker = TrackerPresentersModule.withApplication(
|
||||
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
|
||||
);
|
||||
|
||||
@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'),
|
||||
}),
|
||||
}),
|
||||
|
||||
// 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 {}
|
||||
36
apps/api/src/app/config.schema.ts
Normal file
36
apps/api/src/app/config.schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export const configSchema = Joi.object({
|
||||
PORT: Joi.number().default(3000),
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test')
|
||||
.default('development'),
|
||||
|
||||
// Write model
|
||||
POSTGRES_URL: Joi.string().uri().required(),
|
||||
POSTGRES_SYNCHRONIZE: Joi.boolean().default(false),
|
||||
|
||||
// Read model
|
||||
MONGO_URL: Joi.string().uri().required(),
|
||||
|
||||
// Event stream + GraphQL pub/sub + session cache
|
||||
REDIS_URL: Joi.string().uri().required(),
|
||||
EVENT_STREAM_KEY: Joi.string().default('task-stream:events'),
|
||||
EVENT_STREAM_MAX_LENGTH: Joi.number().default(1_000_000),
|
||||
// Consume the Redis stream and run @EventsHandler projections on this instance.
|
||||
EVENTS_BRIDGE_ENABLED: Joi.boolean().default(true),
|
||||
|
||||
// Pireon identity server
|
||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||
// OAuth client of task-stream in Pireon, used by the web app to sign in.
|
||||
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
||||
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||
PIREON_SCOPES: Joi.string().default('users:read'),
|
||||
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
||||
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
||||
AUTH_SESSION_TTL_SECONDS: Joi.number().min(0).default(60),
|
||||
|
||||
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
||||
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
||||
});
|
||||
39
apps/api/src/app/health.controller.ts
Normal file
39
apps/api/src/app/health.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import { Public } from '@task-stream/api/identity/presenters';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import Redis from 'ioredis';
|
||||
import { Connection } from 'mongoose';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly postgres: DataSource,
|
||||
@InjectConnection() private readonly mongo: Connection,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async check() {
|
||||
const checks = await Promise.allSettled([
|
||||
this.postgres.query('SELECT 1'),
|
||||
this.mongo.db?.admin().ping(),
|
||||
this.redis.ping(),
|
||||
]);
|
||||
const [postgres, mongo, redis] = checks.map((c) =>
|
||||
c.status === 'fulfilled' ? 'up' : 'down',
|
||||
);
|
||||
const result = { status: 'ok', postgres, mongo, redis };
|
||||
if (checks.some((c) => c.status === 'rejected')) {
|
||||
throw new ServiceUnavailableException({ ...result, status: 'degraded' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user