This commit is contained in:
Alex Ant
2025-03-17 19:27:54 +05:00
commit c20a1ffb1f
27 changed files with 4655 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
pnpm-debug.log
.env
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
.env
node_modules
+1
View File
@@ -0,0 +1 @@
@hcengineering:registry=https://npm.pkg.github.com/
+26
View File
@@ -0,0 +1,26 @@
# Используем Node.js 20
FROM node:20-alpine
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Копируем package.json и pnpm-lock.yaml
COPY package.json pnpm-lock.yaml ./
# Устанавливаем pnpm
RUN npm install -g pnpm
# Устанавливаем зависимости
RUN pnpm install --frozen-lockfile
# Копируем весь проект
COPY . .
# Собираем приложение
RUN pnpm run build
# Указываем переменные окружения (можно переопределять при запуске)
ENV NODE_ENV=production
# Запускаем приложение
CMD ["pnpm", "run", "start"]
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
postgres:
image: postgres:15
container_name: tracker_db
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build: .
container_name: tracker_app
restart: unless-stopped
depends_on:
- postgres
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
NODE_ENV: production
ports:
- "3000:3000"
volumes:
postgres_data:
+39
View File
@@ -0,0 +1,39 @@
{
"name": "tracker",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "npx nodemon --exec esno src/main.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hcengineering/api-client": "^0.6.466",
"@hcengineering/client": "^0.6.466",
"@hcengineering/contact": "^0.6.466",
"@hcengineering/core": "^0.6.466",
"@hcengineering/document": "^0.6.466",
"@hcengineering/rank": "^0.6.466",
"@hcengineering/tracker": "^0.6.466",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^11.0.11",
"@nestjs/platform-express": "^11.0.11",
"@nestjs/schedule": "^5.0.1",
"@nestjs/typeorm": "^11.0.0",
"dotenv": "^16.4.7",
"esno": "^4.8.0",
"pg": "^8.14.0",
"typeorm": "^0.3.21",
"ws": "^8.18.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/ws": "^8.18.0",
"nodemon": "^3.1.9"
}
}
+3565
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SyncModule } from './sync/sync.module';
import { ReportsModule } from './reports/reports.module';
import { TasksModule } from './tasks/tasks.module';
// Импорт ваших Entities
import { Person } from './entities/person.entity';
import { TaskStatus } from './entities/task-status.entity';
import { Task } from './entities/task.entity';
import { TrackerTransaction } from './entities/transaction-tracker.entity';
import { TimeSpentReport } from './entities/type-spent-report.entity';
import { config } from './config/config';
import { TaskClosure } from './entities/task-closure.entity';
import { SyncState } from './entities/sync-state.entity';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
ReportsModule,
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'postgres',
host: config.db.hostname,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: config.db.username,
password: config.db.password,
database: config.db.database,
entities: [Person, TaskStatus, Task, TrackerTransaction, TimeSpentReport, TaskClosure, SyncState],
synchronize: true, // В продакшене лучше выключить и использовать миграции
}),
SyncModule,
TasksModule
],
})
export class AppModule {}
+41
View File
@@ -0,0 +1,41 @@
import dotenv from 'dotenv';
import { z } from 'zod';
import path from 'path';
dotenv.config({ path: path.join(__dirname, '../../.env') });
const envVarsSchema = z.object({
DB_HOST: z.string().default('localhost'),
DB_PORT: z.string().default('5432').transform(Number),
DB_USER: z.string().default('postgres'),
DB_PASS: z.string().default('postgres'),
DB_NAME: z.string().default('tracker_db'),
HULY_URL: z.string().url(),
HULY_EMAIL: z.string().email(),
HULY_PASSWORD: z.string(),
HULY_WORKSPACE: z.string().default('-'),
});
const envVars = envVarsSchema.safeParse(process.env);
if (!envVars.success) {
console.error('❌ Ошибка конфигурации:\n', envVars.error.format());
process.exit(1);
}
export const config = {
db: {
hostname: envVars.data.DB_HOST,
port: envVars.data.DB_PORT,
username: envVars.data.DB_USER,
password: envVars.data.DB_PASS,
database: envVars.data.DB_NAME,
},
huly: {
url: envVars.data.HULY_URL,
email: envVars.data.HULY_EMAIL,
password: envVars.data.HULY_PASSWORD,
workspace: envVars.data.HULY_WORKSPACE,
},
};
+17
View File
@@ -0,0 +1,17 @@
import type { ClientSocket, ClientSocketFactory } from "@hcengineering/client";
import { config } from "./config";
import { WebSocket } from 'ws'
// Настраиваем ws
const socketFactory: ClientSocketFactory = (url: string) => {
const ws = new WebSocket(url);
return ws as ClientSocket;
};
export const options = {
email: config.huly.email,
password: config.huly.password,
workspace: config.huly.workspace,
socketFactory,
connectionTimeout: 60000,
};
+26
View File
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('persons')
export class Person {
@PrimaryGeneratedColumn()
id!: number;
// externalId — поле, где храним _id из трекера
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalId!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
city!: string;
@Column({ type: 'timestamp' })
createdOn!: Date;
@Column({ type: 'timestamp' })
modifiedOn!: Date;
// Могут быть и другие поля — github-url и т.д.
}
+18
View File
@@ -0,0 +1,18 @@
// src/entities/sync-state.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('sync_state')
export class SyncState {
@PrimaryGeneratedColumn()
id!: number;
// Тип транзакции, например "TxCreateDoc" или "TxUpdateDoc"
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
txType!: string;
// Последняя учтённая дата (timestamp)
@Column({ type: 'timestamp', nullable: true })
lastSynced!: Date | null;
}
+35
View File
@@ -0,0 +1,35 @@
// src/entities/task-closure.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('task_closures')
export class TaskClosure {
@PrimaryGeneratedColumn()
id!: number;
// Уникальный ID задачи в трекере
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalTaskId!: string;
// Когда задача была закрыта (из транзакции createdOn)
@Column({ type: 'timestamp' })
closedOn!: Date;
// Если задача "переоткрыта" – ставим false
@Column({ default: true, type: 'bool' })
active!: boolean;
// --- новые поля для "заголовка" и т.д.
@Column({ type: 'varchar', length: 1024, nullable: true })
title!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
identifier!: string | null; // TSK-123, к примеру
@Column({ type: 'int', nullable: true })
number!: number | null; // если нужно
}
+21
View File
@@ -0,0 +1,21 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('task_statuses')
export class TaskStatus {
@PrimaryGeneratedColumn()
id!: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalId!: string; // _id из трекера
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
category!: string | null; // task:statusCategory:Won и т.п.
@Column({ type: 'text', nullable: true })
description!: string | null;
}
+52
View File
@@ -0,0 +1,52 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Person } from './person.entity';
import { TaskStatus } from './task-status.entity';
@Entity('tasks')
export class Task {
@PrimaryGeneratedColumn()
id!: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalId!: string; // objectId из трекера
@Column({ type: 'text' })
title!: string;
@Column({ type: 'text', nullable: true })
description?: string | null;
// Прогнозное время (estimation)
@Column({ type: 'float', default: 0 })
estimation!: number;
@ManyToOne(() => TaskStatus, { eager: true })
@JoinColumn()
status!: TaskStatus; // связь с таблицей статусов
@ManyToOne(() => Person, { nullable: true, eager: true })
@JoinColumn()
assignee!: Person | null; // текущий исполнитель
// Флаг, показывающий, что задача закрыта
@Column({ type: 'boolean', default: false })
isDone!: boolean;
// Точное время закрытия задачи (по транзакции), если есть
@Column({ type: 'timestamp', nullable: true })
closedOn!: Date | null;
@Column({ type: 'timestamp', nullable: true })
createdOn!: Date; // из транзакции или атрибутов
@Column({ type: 'timestamp' })
modifiedOn!: Date; // последнее изменение в задаче
}
@@ -0,0 +1,28 @@
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
@Entity('tracker_transactions')
export class TrackerTransaction {
@PrimaryGeneratedColumn()
id!: number;
// Уникальный ID транзакции из трекера
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalId!: string; // _id
@Column({ type: 'varchar', length: 255 })
type!: string; // TxCreateDoc / TxUpdateDoc
@Column({ type: 'varchar', length: 255 })
objectId!: string; // для задач — objectId = externalId задачи
// payload будет хранить либо attributes (для create), либо operations (для update)
@Column({ type: 'jsonb', nullable: true })
payload!: Record<string, any> | null;
@Column({ type: 'timestamp', nullable: true })
createdOn!: Date; // время создания транзакции (из трекера)
@Column({ type: 'timestamp' })
modifiedOn!: Date; // время изменения транзакции (из трекера)
}
+44
View File
@@ -0,0 +1,44 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
Index,
ManyToOne,
JoinColumn
} from 'typeorm';
import { Task } from './task.entity';
import { Person } from './person.entity';
import type { DataTypeDefaults } from 'typeorm/driver/types/DataTypeDefaults';
@Entity('time_spent_reports')
export class TimeSpentReport {
@PrimaryGeneratedColumn()
id!: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
externalId!: string; // _id из трекера
@ManyToOne(() => Task, { eager: true })
@JoinColumn()
task!: Task;
// исполнитель, если указан
@ManyToOne(() => Person, { nullable: true, eager: true })
@JoinColumn()
employee!: Person | null;
// фактически потраченное время
@Column({ type: 'float' })
value!: number; // часы/условные единицы
@Column({ type: 'timestamp' })
date!: Date;
@Column({ type: 'timestamp' })
createdOn!: Date;
@Column({ type: 'timestamp' })
modifiedOn!: Date;
}
+17
View File
@@ -0,0 +1,17 @@
import "reflect-metadata";
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TrackerSyncService } from './sync/sync.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log(`App listening on port 3000`);
// Получаем экземпляр TrackerSyncService и вызываем syncAllData
const trackerSyncService = app.get(TrackerSyncService);
await trackerSyncService.syncAllData();
console.log(`Initial data sync completed.`);
}
bootstrap();
+35
View File
@@ -0,0 +1,35 @@
// src/reports/reports.controller.ts
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { ReportsService } from './reports.service';
@Controller('reports')
export class ReportsController {
constructor(@Inject(ReportsService) private readonly reportsService: ReportsService) {
}
/**
* Пример:
* GET /reports/completed?start=1741900000000&end=1741959999999
*/
@Get('completed')
async getCompleted(
@Query('start') start: string,
@Query('end') end: string
) {
if (!start || !end) {
return {
error: 'Invalid start/end timestamps',
};
}
const data = await this.reportsService.getClosedReport(start, end);
return {
start,
end,
data,
};
}
}
+22
View File
@@ -0,0 +1,22 @@
// src/reports/reports.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
import { Task } from '../entities/task.entity';
import { TimeSpentReport } from '../entities/type-spent-report.entity';
import { Person } from '../entities/person.entity';
import { TaskClosure } from '../entities/task-closure.entity';
import { SyncModule } from '../sync/sync.module';
@Module({
imports: [
TypeOrmModule.forFeature([Task, TimeSpentReport, Person, TaskClosure]),
SyncModule
],
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}
+214
View File
@@ -0,0 +1,214 @@
// src/reports/reports.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, Repository } from 'typeorm';
import { TaskClosure } from '../entities/task-closure.entity';
import { connect } from '@hcengineering/api-client';
import tracker from '@hcengineering/tracker';
import contact from '@hcengineering/contact';
import { options } from '../config/options';
import { config } from '../config/config';
@Injectable()
export class ReportsService {
private client: any;
constructor(
@InjectRepository(TaskClosure)
private readonly closureRepo: Repository<TaskClosure>,
) {}
/**
* Инициализация клиента
*/
async initClient() {
if (!this.client) {
this.client = await connect(config.huly.url, options);
}
}
/**
* Формируем отчёт по закрытым задачам (TaskClosure) в [startIso, endIso].
* Для каждой задачи (Issue) добавляем title, description (markdown), estimation, identifier.
* Сгруппированные по сотрудникам (employee) записи TimeSpendReport дают spentTime и descriptions.
*
* Итоговая структура (упрощённо):
* [
* {
* person: { id, name, email },
* tasks: [
* {
* taskId,
* identifier,
* title,
* taskDescription,
* estimation,
* spentTime,
* descriptions: [...]
* }
* ]
* }
* ]
*/
async getClosedReport(startIso: string, endIso: string) {
await this.initClient();
const startDate = new Date(startIso);
const endDate = new Date(endIso);
// 1) Из локальной БД берем только те задачи, которые были закрыты (active) в заданном интервале
const closures = await this.closureRepo.find({
where: {
active: true,
closedOn: Between(startDate, endDate),
},
});
if (!closures.length) {
return [];
}
// 2) Собираем уникальные ID задач
const taskIds = closures.map((c) => c.externalTaskId);
// 3) Загружаем задачи (Issue) из трекера
const issues = await this.client.findAll(
tracker.class.Issue,
{ _id: { $in: taskIds } },
{ limit: 2000 },
);
// Пример: подгружаем markdown-разметку описания
for (const issue of issues) {
if (issue.description) {
issue.description = await this.client.fetchMarkup(
issue._class,
issue._id,
'description',
issue.description,
'markdown'
);
}
}
// Создаем карту для быстрого доступа: taskId => issueData
const issueMap = new Map<string, any>();
for (const iss of issues) {
issueMap.set(iss._id, iss);
}
// 4) Загружаем TimeSpendReport для этих же задач
const timeReports = await this.client.findAll(
tracker.class.TimeSpendReport,
{ attachedTo: { $in: taskIds } },
{ limit: 9999 },
);
// 5) Группируем timeReports по сотрудникам
const byPerson = new Map<string, any[]>();
for (const rep of timeReports) {
if (!rep.employee) continue; // если нет employee, пропустим
const empId = rep.employee;
if (!byPerson.has(empId)) {
byPerson.set(empId, []);
}
byPerson.get(empId)!.push(rep);
}
// 6) Формируем итог, итерируясь по сотрудникам
const result = [];
for (const [employeeId, reportsOfPerson] of byPerson.entries()) {
// Сведения о сотруднике
const personInfo = await this.loadPersonInfo(employeeId);
// Группируем отчёты сотрудника по задачам
const byTask = new Map<string, any[]>();
for (const r of reportsOfPerson) {
const taskId = r.attachedTo;
// используем только закрытые задачи, которые мы собрали в closures
if (!taskIds.includes(taskId)) continue;
if (!byTask.has(taskId)) {
byTask.set(taskId, []);
}
byTask.get(taskId)!.push(r);
}
// Собираем список задач
const tasksList = [];
for (const [taskId, list] of byTask.entries()) {
// Сумма фактического времени
const spentTime = list.reduce((acc, x) => acc + (x.value || 0), 0);
// descriptions из всех timeReports
const descriptions = list
.map((x) => x.description || '')
.filter((d) => d.length > 0);
// Берем данные задачи (title, description, estimation, identifier)
const issueData = issueMap.get(taskId);
const title = issueData?.title || 'Untitled';
const taskDescription = issueData?.description || '';
const estimation = issueData?.estimation || 0;
const identifier = issueData?.identifier || '';
tasksList.push({
taskId,
identifier,
title,
taskDescription,
estimation,
spentTime,
descriptions,
});
}
if (!tasksList.length) continue;
// Добавляем в общий результат объект по сотруднику
result.push({
person: {
id: employeeId,
name: personInfo.name,
email: personInfo.email,
},
tasks: tasksList,
});
}
return result;
}
/**
* Загрузить имя, email сотрудника
*/
private async loadPersonInfo(employeeId: string): Promise<{ name: string; email: string }> {
let name = 'Unknown Person';
let email = '';
// 1) findOne(contact.class.Person)
const person = await this.client.findOne(
contact.class.Person,
{ _id: employeeId },
{}
);
if (person) {
name = person.name || `Person-${employeeId}`;
}
// 2) findAll(contact.class.Channel, provider: 'contact:channelProvider:Email', attachedTo=employeeId)
const channels = await this.client.findAll(
contact.class.Channel,
{
provider: 'contact:channelProvider:Email',
attachedTo: employeeId,
},
{}
);
if (channels?.length) {
email = channels[0].value || '';
}
return { name, email };
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrackerSyncService } from './sync.service';
import { TaskClosure } from '../entities/task-closure.entity';
import { SyncState } from '../entities/sync-state.entity';
import { TaskStatus } from '../entities/task-status.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
TaskClosure,
SyncState,
TaskStatus,
]),
],
providers: [TrackerSyncService], // Регистрируем сервис
exports: [TrackerSyncService], // Экспортируем, чтобы использовать в AppModule
})
export class SyncModule {}
+298
View File
@@ -0,0 +1,298 @@
// src/sync/tracker-sync.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TaskClosure } from '../entities/task-closure.entity';
import { TaskStatus } from '../entities/task-status.entity';
import { SyncState } from '../entities/sync-state.entity';
import { connect, type ConnectOptions } from '@hcengineering/api-client';
import { ClientSocket, ClientSocketFactory } from '@hcengineering/client';
import { WebSocket } from 'ws';
import tracker from '@hcengineering/tracker';
import tx from '@hcengineering/core';
import { Cron, CronExpression } from '@nestjs/schedule';
import { config } from '../config/config';
import { options } from '../config/options';
@Injectable()
export class TrackerSyncService {
private readonly logger = new Logger(TrackerSyncService.name);
private client: any;
private readonly options: ConnectOptions;
private readonly url: string;
// размер батча
private readonly BATCH_SIZE = 1;
/** Флаг, указывающий, что синхронизация идёт прямо сейчас */
private isSyncing = false;
constructor(
@InjectRepository(TaskClosure)
private readonly closureRepo: Repository<TaskClosure>,
@InjectRepository(TaskStatus)
private readonly statusRepo: Repository<TaskStatus>,
@InjectRepository(SyncState)
private readonly syncStateRepo: Repository<SyncState>,
) {
this.url = config.huly.url;
this.options = options;
this.logger.log(
`TrackerSyncService inited with workspace: ${this.options.workspace}`
);
}
/**
* Запускаем cron каждые 10 секунд (пример).
* Если sync идёт — пропускаем запуск.
*/
@Cron(CronExpression.EVERY_10_SECONDS)
async scheduledSync() {
// проверяем, не идёт ли синхронизация
if (this.isSyncing) {
this.logger.log('scheduledSync called but a sync is already in progress - skipping.');
return;
}
try {
await this.syncAllData();
} catch (err: any) {
this.logger.error(`Sync error: ${err.message}`, err.stack);
}
}
/**
* Запуск полной синхронизации (можно вызвать вручную при старте приложения).
*/
async syncAllData() {
// Проверяем флаг
if (this.isSyncing) {
this.logger.log('syncAllData called but a sync is already in progress - skipping.');
return;
}
this.isSyncing = true;
try {
await this.initClient();
this.logger.log('Starting statuses sync...');
await this.syncStatuses(); // 1) сначала стягиваем статусы
this.logger.log('Starting tasks sync...');
// 2) транзакции TxCreateDoc
await this.syncTasksBatches('TxCreateDoc');
// 3) транзакции TxUpdateDoc
await this.syncTasksBatches('TxUpdateDoc');
this.logger.log('Sync finished!');
} finally {
// В любом случае, даже если ошибка — сбросим флаг
this.isSyncing = false;
}
}
/**
* Инициализация клиента
*/
async initClient() {
if (!this.client) {
this.client = await connect(this.url, this.options);
this.logger.log(`Client connected to ${this.url}`);
}
}
/**
* Синхронизация статусов. Сохраняем/обновляем их в локальной таблице "task_statuses".
*/
private async syncStatuses() {
// Грузим статусы
const allStatuses = await this.client.findAll(
tracker.class.IssueStatus,
{},
{ limit: 2000 },
);
for (const s of allStatuses) {
let existing = await this.statusRepo.findOne({ where: { externalId: s._id } });
if (!existing) {
existing = this.statusRepo.create({
externalId: s._id,
name: s.name,
category: s.category,
description: s.description ?? '',
});
} else {
existing.name = s.name;
existing.category = s.category;
existing.description = s.description ?? '';
}
await this.statusRepo.save(existing);
}
this.logger.log(`Statuses synced, count=${allStatuses.length}`);
}
/**
* Основной метод для TxCreateDoc/TxUpdateDoc
*/
private async syncTasksBatches(type: 'TxCreateDoc' | 'TxUpdateDoc') {
let syncState = await this.syncStateRepo.findOne({ where: { txType: type } });
if (!syncState) {
syncState = this.syncStateRepo.create({
txType: type,
lastSynced: new Date(0),
});
await this.syncStateRepo.save(syncState);
}
let lastSyncedMs = syncState.lastSynced ? syncState.lastSynced.getTime() : 0;
let page = 0;
let hasMore = true;
while (hasMore) {
page++;
this.logger.log(`Loading ${type} batch #${page} from modifiedOn > ${new Date(lastSyncedMs).toISOString()}`);
const chunk = await this.client.findAll(
type === 'TxCreateDoc' ? tx.class.TxCreateDoc : tx.class.TxUpdateDoc,
{ objectClass: 'tracker:class:Issue', modifiedOn: { $gt: lastSyncedMs } },
{ sort: { modifiedOn: 1 }, limit: this.BATCH_SIZE },
);
if (!chunk.length) {
hasMore = false;
break;
}
let maxModifiedOn = lastSyncedMs;
for (const t of chunk) {
if (t.modifiedOn > maxModifiedOn) {
maxModifiedOn = t.modifiedOn;
}
// Смотрим, новый статус => done?
const newStatusId = this.getNewStatusId(t);
if (newStatusId) {
// Находим в локальном репозитории статусов
const status = await this.statusRepo.findOne({ where: { externalId: newStatusId } });
if (status) {
if (this.isDoneStatus(status)) {
// => done
await this.activateTaskClosure(t.objectId, t.modifiedOn);
} else {
// => reopen
await this.deactivateTaskClosure(t.objectId);
}
} else {
this.logger.warn(`Status ${newStatusId} not found in local db, skip`);
}
}
}
// Обновляем syncState
syncState.lastSynced = new Date(maxModifiedOn);
await this.syncStateRepo.save(syncState);
if (chunk.length < this.BATCH_SIZE) {
hasMore = false;
}
lastSyncedMs = maxModifiedOn;
}
}
private getNewStatusId(txObj: any): string | null {
if (txObj._class === 'core:class:TxCreateDoc') {
return txObj.attributes?.status || null;
} else if (txObj._class === 'core:class:TxUpdateDoc') {
return txObj.operations?.status || null;
}
return null;
}
private isDoneStatus(status: TaskStatus): boolean {
if (!status) return false;
if (status.category === 'task:statusCategory:Won') return true;
if (status.name?.toLowerCase().includes('выполн')) return true;
return false;
}
/**
* Активируем (или создаём) запись в task_closures.
* + дополнительно тянем поля задачи (title/description/...) из трекера,
* чтобы сразу сохранить их локально.
*/
private async activateTaskClosure(externalTaskId: string, txModifiedOn: number) {
// 1) Запросим у трекера поля задачи (title, description, ...)
const taskInfo = await this.client.findOne(
tracker.class.Issue,
{ _id: externalTaskId },
{}
);
// taskInfo может быть undefined, если задачи нет
// 2) Ищем локальную запись
let existing = await this.closureRepo.findOne({
where: { externalTaskId },
});
// 3) Заполняем поля
const closedOn = new Date(txModifiedOn);
const title = taskInfo?.title ?? null;
const description = taskInfo?.description
? await this.client.fetchMarkup(
taskInfo._class,
taskInfo._id,
'description',
taskInfo.description,
'markdown'
)
: '';
const identifier = taskInfo?.identifier ?? null;
const number = taskInfo?.number ?? null;
if (!existing) {
// создаём новую
existing = this.closureRepo.create({
externalTaskId,
closedOn,
active: true,
title,
description,
identifier,
number,
});
await this.closureRepo.save(existing);
this.logger.log(`Task ${externalTaskId} => DONE at ${txModifiedOn}. Title="${title}"`);
} else {
// если запись была, возможно, active=false
existing.active = true;
existing.closedOn = closedOn;
existing.title = title;
existing.description = description;
existing.identifier = identifier;
existing.number = number;
await this.closureRepo.save(existing);
this.logger.log(`Task ${externalTaskId} => re-DONE at ${txModifiedOn}. Title="${title}"`);
}
}
private async deactivateTaskClosure(externalTaskId: string) {
const allActive = await this.closureRepo.find({
where: { externalTaskId, active: true },
});
for (const c of allActive) {
c.active = false;
await this.closureRepo.save(c);
this.logger.log(`Task ${externalTaskId} => reopened => closure deactivated`);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// src/tasks/tasks.controller.ts
import { Controller, Get } from '@nestjs/common';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
async getAll() {
return this.tasksService.findAll();
}
}
+13
View File
@@ -0,0 +1,13 @@
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { Task } from '../entities/task.entity';
@Module({
imports: [TypeOrmModule.forFeature([Task])],
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
+17
View File
@@ -0,0 +1,17 @@
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task } from '../entities/task.entity';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>
) {}
findAll() {
return this.taskRepo.find({ relations: ['assignee', 'status'] });
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
}
}