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 { 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 { (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(); const ids = new Map(); 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; } }