Gitea Actions Demo / Deploy (push) Successful in 32s
Issue changes are recorded per field in the issue_change table, in the same transaction as the issue itself. The new history module exposes: - GET /api/issues/:issueId/history — change timeline grouped by save - GET /api/issues/stats/daily — created, status transitions and status snapshot per day, with time zone support The migration backfills history for existing issues. Setting a field to its current value no longer bumps updatedAt or emits issue.updated.
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { ISSUE_STATUSES, type IssueStatusValue } from '../../../../issue/domain/value-objects/issue-status.vo.js';
|
|
import {
|
|
ISSUE_HISTORY_REPOSITORY,
|
|
type IIssueHistoryRepository,
|
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
|
import { StatsPeriod } from '../../../domain/value-objects/stats-period.vo.js';
|
|
import { DailyIssueStatsDayDto, DailyIssueStatsDto, IssueStatusCounts } from '../../dto/daily-issue-stats.dto.js';
|
|
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
|
|
|
|
/**
|
|
* Статистика задач по дням, считается по истории изменений (issue_change).
|
|
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee.
|
|
*/
|
|
@Injectable()
|
|
export class GetDailyIssueStatsUseCase {
|
|
constructor(
|
|
@Inject(ISSUE_HISTORY_REPOSITORY)
|
|
private readonly historyRepository: IIssueHistoryRepository,
|
|
) {}
|
|
|
|
async execute(query: GetDailyIssueStatsQuery): Promise<DailyIssueStatsDto> {
|
|
const period = StatsPeriod.create({ from: query.from, to: query.to, timeZone: query.timeZone });
|
|
const range = {
|
|
userId: query.userId,
|
|
projectId: query.projectId,
|
|
from: period.from,
|
|
to: period.to,
|
|
timeZone: period.timeZone,
|
|
};
|
|
|
|
const [events, statuses] = await Promise.all([
|
|
this.historyRepository.countDailyEvents(range),
|
|
this.historyRepository.countDailyStatuses(range),
|
|
]);
|
|
|
|
// Каждый день периода попадает в ответ, даже если в нём ничего не было
|
|
const days = new Map<string, DailyIssueStatsDayDto>(
|
|
period.days.map((date) => [
|
|
date,
|
|
{ date, created: 0, transitions: emptyStatusCounts(), byStatus: emptyStatusCounts() },
|
|
]),
|
|
);
|
|
|
|
for (const event of events) {
|
|
const day = days.get(event.date);
|
|
if (!day) continue;
|
|
|
|
if (event.kind === 'created') {
|
|
day.created += event.count;
|
|
} else if (isKnownStatus(event.status)) {
|
|
day.transitions[event.status] += event.count;
|
|
}
|
|
}
|
|
|
|
for (const entry of statuses) {
|
|
const day = days.get(entry.date);
|
|
if (day && isKnownStatus(entry.status)) {
|
|
day.byStatus[entry.status] += entry.count;
|
|
}
|
|
}
|
|
|
|
return { from: period.from, to: period.to, timeZone: period.timeZone, days: [...days.values()] };
|
|
}
|
|
}
|
|
|
|
function emptyStatusCounts(): IssueStatusCounts {
|
|
return Object.fromEntries(ISSUE_STATUSES.map((status) => [status.alias, 0])) as IssueStatusCounts;
|
|
}
|
|
|
|
function isKnownStatus(status: string): status is IssueStatusValue {
|
|
return ISSUE_STATUSES.some((known) => known.alias === status);
|
|
}
|