add broker
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-07 20:24:31 +03:00
parent b270345e77
commit 4d80480d0f
26 changed files with 694 additions and 37 deletions

View File

@ -11,3 +11,6 @@ PORT=4001
# Keycloak
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
KEYCLOAK_CLIENT_ID=team-planner-frontend
# NATS
NATS_URL=nats://10.10.10.100:30422

View File

@ -37,6 +37,7 @@
"class-validator": "^0.14.3",
"dotenv": "^16.4.7",
"jwks-rsa": "^3.2.0",
"nats": "^2.29.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",

View File

@ -9,6 +9,8 @@ import { CommentsModule } from './comments/comments.module';
import { TeamModule } from './team/team.module';
import { AuthModule, JwtAuthGuard } from './auth';
import { AiModule } from './ai/ai.module';
import { SettingsModule } from './settings/settings.module';
import { NatsModule } from './nats/nats.module';
@Module({
imports: [
@ -36,6 +38,8 @@ import { AiModule } from './ai/ai.module';
CommentsModule,
TeamModule,
AiModule,
SettingsModule,
NatsModule,
],
controllers: [AppController],
providers: [

View File

@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { IdeasService } from './ideas.service';
import { IdeaStatus } from './entities/idea.entity';
import {
HypothesisLinkedEvent,
HypothesisCompletedEvent,
} from '../nats/events';
@Injectable()
export class IdeaEventsHandler {
private readonly logger = new Logger(IdeaEventsHandler.name);
constructor(private ideasService: IdeasService) {}
async handleHypothesisLinked(payload: HypothesisLinkedEvent) {
try {
const idea = await this.ideasService.findOne(payload.hypothesisId);
if (
idea.status === IdeaStatus.BACKLOG ||
idea.status === IdeaStatus.TODO
) {
await this.ideasService.update(idea.id, {
status: IdeaStatus.IN_PROGRESS,
});
this.logger.log(
`Idea ${idea.id} status changed to in_progress (was ${idea.status})`,
);
} else {
this.logger.log(
`Idea ${idea.id} already in status ${idea.status}, skipping`,
);
}
} catch (error) {
this.logger.error(
`Failed to handle hypothesis.linked for ${payload.hypothesisId}`,
(error as Error).stack,
);
}
}
async handleHypothesisCompleted(payload: HypothesisCompletedEvent) {
try {
const idea = await this.ideasService.findOne(payload.hypothesisId);
if (
idea.status !== IdeaStatus.DONE &&
idea.status !== IdeaStatus.CANCELLED
) {
await this.ideasService.update(idea.id, {
status: IdeaStatus.DONE,
});
this.logger.log(
`Idea ${idea.id} status changed to done (was ${idea.status})`,
);
} else {
this.logger.log(
`Idea ${idea.id} already in status ${idea.status}, skipping`,
);
}
} catch (error) {
this.logger.error(
`Failed to handle hypothesis.completed for ${payload.hypothesisId}`,
(error as Error).stack,
);
}
}
}

View File

@ -4,11 +4,12 @@ import { IdeasService } from './ideas.service';
import { IdeasController } from './ideas.controller';
import { Idea } from './entities/idea.entity';
import { SpecificationHistory } from './entities/specification-history.entity';
import { IdeaEventsHandler } from './idea-events.handler';
@Module({
imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService, TypeOrmModule],
providers: [IdeasService, IdeaEventsHandler],
exports: [IdeasService, IdeaEventsHandler, TypeOrmModule],
})
export class IdeasModule {}

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UserSettings1770500000000 implements MigrationInterface {
name = 'UserSettings1770500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "user_settings" (
"user_id" VARCHAR(255) NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_user_settings" PRIMARY KEY ("user_id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "user_settings"`);
}
}

View File

@ -0,0 +1,18 @@
export const HYPOTHESIS_STREAM = 'HYPOTHESIS_EVENTS';
export const HypothesisSubjects = {
LINKED: 'hypothesis.linked',
COMPLETED: 'hypothesis.completed',
} as const;
export interface HypothesisLinkedEvent {
hypothesisId: string;
targetType: 'goal' | 'track';
targetId: string;
userId: string;
}
export interface HypothesisCompletedEvent {
hypothesisId: string;
userId: string;
}

View File

@ -0,0 +1,165 @@
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
connect,
NatsConnection,
JetStreamClient,
JetStreamManager,
JetStreamPullSubscription,
StringCodec,
AckPolicy,
DeliverPolicy,
RetentionPolicy,
StorageType,
consumerOpts,
} from 'nats';
import {
HYPOTHESIS_STREAM,
HypothesisSubjects,
HypothesisLinkedEvent,
HypothesisCompletedEvent,
} from './events';
import { IdeaEventsHandler } from '../ideas/idea-events.handler';
const CONSUMER_NAME = 'team-planner';
const PULL_BATCH = 10;
const PULL_INTERVAL_MS = 1000;
@Injectable()
export class NatsConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(NatsConsumerService.name);
private nc: NatsConnection | null = null;
private sc = StringCodec();
private sub: JetStreamPullSubscription | null = null;
private pullTimer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(
private configService: ConfigService,
private ideaEventsHandler: IdeaEventsHandler,
) {}
async onModuleInit() {
const url = this.configService.get<string>('NATS_URL');
if (!url) {
this.logger.warn('NATS_URL not configured, NATS consuming disabled');
return;
}
try {
this.nc = await connect({ servers: url });
this.logger.log(`Connected to NATS at ${url}`);
const jsm: JetStreamManager = await this.nc.jetstreamManager();
await this.ensureStream(jsm);
await this.ensureConsumer(jsm);
const js: JetStreamClient = this.nc.jetstream();
await this.startConsuming(js);
} catch (error) {
this.logger.error('Failed to connect to NATS', (error as Error).stack);
}
}
async onModuleDestroy() {
this.running = false;
if (this.pullTimer) {
clearInterval(this.pullTimer);
}
if (this.sub) {
this.sub.unsubscribe();
}
if (this.nc) {
await this.nc.drain();
this.logger.log('NATS connection drained');
}
}
private async ensureStream(jsm: JetStreamManager) {
try {
await jsm.streams.info(HYPOTHESIS_STREAM);
this.logger.log(`Stream ${HYPOTHESIS_STREAM} already exists`);
} catch {
await jsm.streams.add({
name: HYPOTHESIS_STREAM,
subjects: ['hypothesis.>'],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_age: 7 * 24 * 60 * 60 * 1_000_000_000,
});
this.logger.log(`Stream ${HYPOTHESIS_STREAM} created`);
}
}
private async ensureConsumer(jsm: JetStreamManager) {
try {
await jsm.consumers.info(HYPOTHESIS_STREAM, CONSUMER_NAME);
this.logger.log(`Consumer ${CONSUMER_NAME} already exists`);
} catch {
await jsm.consumers.add(HYPOTHESIS_STREAM, {
durable_name: CONSUMER_NAME,
ack_policy: AckPolicy.Explicit,
deliver_policy: DeliverPolicy.All,
});
this.logger.log(`Consumer ${CONSUMER_NAME} created`);
}
}
private async startConsuming(js: JetStreamClient) {
const opts = consumerOpts();
opts.bind(HYPOTHESIS_STREAM, CONSUMER_NAME);
this.sub = await js.pullSubscribe('hypothesis.>', opts);
this.running = true;
void this.processMessages();
this.pullTimer = setInterval(() => {
if (this.running && this.sub) {
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
}
}, PULL_INTERVAL_MS);
// initial pull
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
this.logger.log('Started consuming from HYPOTHESIS_EVENTS stream');
}
private async processMessages() {
if (!this.sub) return;
for await (const msg of this.sub) {
if (!this.running) break;
try {
const raw = this.sc.decode(msg.data);
const subject = msg.subject;
this.logger.log(`Received ${subject}: ${raw}`);
if (subject === HypothesisSubjects.LINKED) {
const data: HypothesisLinkedEvent = JSON.parse(
raw,
) as HypothesisLinkedEvent;
await this.ideaEventsHandler.handleHypothesisLinked(data);
} else if (subject === HypothesisSubjects.COMPLETED) {
const data: HypothesisCompletedEvent = JSON.parse(
raw,
) as HypothesisCompletedEvent;
await this.ideaEventsHandler.handleHypothesisCompleted(data);
}
msg.ack();
} catch (error) {
this.logger.error('Error processing message', (error as Error).stack);
msg.nak();
}
}
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { IdeasModule } from '../ideas/ideas.module';
import { NatsConsumerService } from './nats-consumer.service';
@Module({
imports: [IdeasModule],
providers: [NatsConsumerService],
})
export class NatsModule {}

View File

@ -0,0 +1,24 @@
import { Controller, Get, Put, Body, Req } from '@nestjs/common';
import { SettingsService } from './settings.service';
interface RequestWithUser {
user: { userId: string };
}
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
async get(@Req() req: RequestWithUser) {
return this.settingsService.get(req.user.userId);
}
@Put()
async update(
@Req() req: RequestWithUser,
@Body() body: Record<string, unknown>,
) {
return this.settingsService.update(req.user.userId, body);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSettings } from './user-settings.entity';
import { SettingsService } from './settings.service';
import { SettingsController } from './settings.controller';
@Module({
imports: [TypeOrmModule.forFeature([UserSettings])],
controllers: [SettingsController],
providers: [SettingsService],
})
export class SettingsModule {}

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserSettings } from './user-settings.entity';
@Injectable()
export class SettingsService {
constructor(
@InjectRepository(UserSettings)
private readonly repo: Repository<UserSettings>,
) {}
async get(userId: string): Promise<Record<string, unknown>> {
const row = await this.repo.findOne({ where: { userId } });
return row?.settings ?? {};
}
async update(
userId: string,
patch: Record<string, unknown>,
): Promise<Record<string, unknown>> {
let row = await this.repo.findOne({ where: { userId } });
if (!row) {
row = this.repo.create({ userId, settings: {} });
}
row.settings = { ...row.settings, ...patch };
await this.repo.save(row);
return row.settings;
}
}

View File

@ -0,0 +1,22 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_settings')
export class UserSettings {
@PrimaryColumn({ name: 'user_id', type: 'varchar', length: 255 })
userId: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}