- 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>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
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,
|
|
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 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));
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
(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;
|
|
}
|
|
}
|