This commit is contained in:
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: [
|
||||
|
||||
68
backend/src/ideas/idea-events.handler.ts
Normal file
68
backend/src/ideas/idea-events.handler.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal file
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
18
backend/src/nats/events.ts
Normal file
18
backend/src/nats/events.ts
Normal 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;
|
||||
}
|
||||
165
backend/src/nats/nats-consumer.service.ts
Normal file
165
backend/src/nats/nats-consumer.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/nats/nats.module.ts
Normal file
9
backend/src/nats/nats.module.ts
Normal 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 {}
|
||||
24
backend/src/settings/settings.controller.ts
Normal file
24
backend/src/settings/settings.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/settings/settings.module.ts
Normal file
12
backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
30
backend/src/settings/settings.service.ts
Normal file
30
backend/src/settings/settings.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
backend/src/settings/user-settings.entity.ts
Normal file
22
backend/src/settings/user-settings.entity.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user