refactor(api): buses only in services, atomic commands, @Prop schemas
- Mongo read models are @Schema/@Prop classes (SchemaFactory), still loose: the props declared are the indexed or shaped ones. - CommandBus and QueryBus are used by the application services alone. Duplicating a ticket, bulk updates, CSV imports, proposals and emptying the trash chain several commands, so they are service methods now and their composite commands are gone; every command handler is atomic. No forwardRef is needed. - The due-date and trash timers are entry points: they live in the presenters and call the services. The automations event handler has its own file and calls AutomationsService. - The tracker's user directory asks IdentityService; the composition root hands the identity application module to the tracker infrastructure (withIdentity), with no global module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,115 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { WriteAccess } from './commands/write-access';
|
||||
import { IssuesService } from './issues.service';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
import {
|
||||
ExportIssuesQuery,
|
||||
ImportIssuesCommand,
|
||||
ImportReport,
|
||||
MAX_IMPORT_MB,
|
||||
} from './transfer/import-export';
|
||||
import { planImport } from './transfer/transfer';
|
||||
|
||||
/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */
|
||||
@Injectable()
|
||||
export class TransferService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly issues: IssuesService,
|
||||
) {}
|
||||
|
||||
exportCsv(viewerId: string | null, projectId: string): Promise<string> {
|
||||
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
importCsv(
|
||||
/**
|
||||
* Creates the tickets of a CSV one by one (each a normal ticket creation,
|
||||
* with its own events), then places them under their parents. Owners and
|
||||
* admins. With `dryRun`, only tells what would happen.
|
||||
*/
|
||||
async importCsv(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
csv: string,
|
||||
dryRun: boolean,
|
||||
): Promise<ImportReport> {
|
||||
return this.commandBus.execute(
|
||||
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
|
||||
);
|
||||
(await this.access.project(projectId)).assertCanManage(actorId);
|
||||
if (Buffer.byteLength(csv) > MAX_IMPORT_MB * 1024 * 1024)
|
||||
throw new BusinessRuleError(
|
||||
`The file is larger than ${MAX_IMPORT_MB} MB`,
|
||||
);
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
if (!project) throw new BusinessRuleError('The project is not ready yet');
|
||||
const plan = planImport(csv, project);
|
||||
const report: ImportReport = {
|
||||
format: plan.format,
|
||||
dryRun: dryRun,
|
||||
rows: plan.rows.length,
|
||||
created: 0,
|
||||
keys: [],
|
||||
skipped: [...plan.skipped],
|
||||
warnings: [...plan.warnings],
|
||||
};
|
||||
if (dryRun) return report;
|
||||
|
||||
const created = new Map<string, string>();
|
||||
const ids = new Map<number, string>();
|
||||
for (const row of plan.rows) {
|
||||
try {
|
||||
const result: CommandResult = await this.issues.create(actorId, {
|
||||
projectId: project.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
priority: row.priority,
|
||||
labels: row.labels,
|
||||
storyPoints: row.storyPoints,
|
||||
startDate: row.startDate,
|
||||
dueDate: row.dueDate,
|
||||
assigneeId: row.assigneeId,
|
||||
initialStatus: row.status,
|
||||
// The file brings the content, not the type's template.
|
||||
useTemplate: false,
|
||||
});
|
||||
report.created++;
|
||||
report.keys.push(result.key ?? result.id);
|
||||
ids.set(row.line, result.id);
|
||||
for (const key of row.sourceKeys) created.set(key, result.id);
|
||||
if (result.key) created.set(result.key, result.id);
|
||||
} catch (error) {
|
||||
report.skipped.push({
|
||||
line: row.line,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Parents last: they may come after their children in the file.
|
||||
for (const row of plan.rows) {
|
||||
const id = ids.get(row.line);
|
||||
const parent = row.parentSource
|
||||
? created.get(row.parentSource)
|
||||
: undefined;
|
||||
if (!id || !row.parentSource) continue;
|
||||
if (!parent) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `Su padre (${row.parentSource}) no está en el archivo`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.issues.setParent(actorId, id, parent);
|
||||
} catch (error) {
|
||||
report.warnings.push({
|
||||
line: row.line,
|
||||
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user