Files
task-stream/libs/api/tracker/application/src/lib/transfer.service.ts
Josep Piguillem 1550332d41 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>
2026-10-14 19:56:59 +02:00

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;
}
}