feat: import and export tickets as CSV

Every ticket of a project can be downloaded as CSV, and a CSV exported
from TaskStream or from Jira can be imported: types, statuses, priorities
and people are matched by id or name, the rest falls back with a warning,
and parents are rebuilt. The settings page previews an import before it
creates anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:43:02 +02:00
parent d43fc620e9
commit 1076205acc
24 changed files with 1666 additions and 31 deletions

View File

@@ -13,6 +13,8 @@ export * from './lib/saved-filters.service';
export * from './lib/sprints.service';
export * from './lib/git.service';
export * from './lib/webhooks.service';
export * from './lib/transfer.service';
export * from './lib/transfer/import-export';
export * from './lib/webhooks/webhooks';
export * from './lib/webhooks/webhook-events';
export * from './lib/git/git';

View File

@@ -49,6 +49,7 @@ export class CreateIssueCommandHandler
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
status: command.initialStatus,
},
workflow,
);

View File

@@ -14,6 +14,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
public readonly releaseId?: string | null,
/** Epic or ticket the new one breaks down. */
public readonly parentId?: string | null,
/** Imports only: start in this (active) status instead of the first one. */
public readonly initialStatus?: string | null,
) {
super();
}

View File

@@ -36,6 +36,8 @@ import { GitService } from './git.service';
import { GIT_HANDLERS } from './git/git';
import { WebhooksService } from './webhooks.service';
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -59,6 +61,7 @@ const SERVICES = [
SprintsService,
GitService,
WebhooksService,
TransferService,
];
/**
@@ -83,6 +86,7 @@ const SERVICES = [
...SAVED_FILTERS,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,
],
exports: SERVICES,
})

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ExportIssuesQuery,
ImportIssuesCommand,
ImportReport,
} from './transfer/import-export';
/** 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,
) {}
exportCsv(viewerId: string | null, projectId: string): Promise<string> {
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
}
importCsv(
actorId: string,
projectId: string,
csv: string,
dryRun: boolean,
): Promise<ImportReport> {
return this.commandBus.execute(
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
);
}
}

View File

@@ -0,0 +1,52 @@
/** RFC 4180 CSV: quotes, doubled quotes, line breaks inside fields, CRLF, BOM. */
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
const input = text.replace(/^\uFEFF/, '');
for (let i = 0; i < input.length; i++) {
const char = input[i];
if (quoted) {
if (char === '"' && input[i + 1] === '"') {
field += '"';
i++;
} else if (char === '"') quoted = false;
else field += char;
continue;
}
if (char === '"') quoted = true;
else if (char === ',') {
row.push(field);
field = '';
} else if (char === '\n' || char === '\r') {
if (char === '\r' && input[i + 1] === '\n') i++;
row.push(field);
rows.push(row);
row = [];
field = '';
} else field += char;
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((cell) => cell.trim() !== ''));
}
/**
* Text that a spreadsheet would run as a formula starts with a quote mark
* instead: an export must not become a way to run code on someone's machine.
*/
function cell(value: string | number | null | undefined): string {
let text = value === null || value === undefined ? '' : String(value);
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/** CSV with a BOM, so spreadsheets read it as UTF-8. */
export function toCsv(rows: (string | number | null | undefined)[][]): string {
return (
'\uFEFF' + rows.map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n'
);
}

View File

@@ -0,0 +1,187 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { SetIssueParentCommand } from '../commands/set-issue-parent.command';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { ImportFormat, exportIssuesCsv, planImport } from './transfer';
/** A file is at most this many megabytes. */
export const MAX_IMPORT_MB = 5;
export interface ImportReport {
format: ImportFormat;
dryRun: boolean;
/** Rows that become (or would become) tickets. */
rows: number;
created: number;
/** Created tickets, in file order. */
keys: string[];
skipped: { line: number; reason: string }[];
warnings: { line: number; message: string }[];
}
export class ImportIssuesCommand extends Command<ImportReport> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly csv: string,
/** Plan only: tell what would happen and create nothing. */
public readonly dryRun: boolean,
) {
super();
}
}
export class ExportIssuesQuery extends Query<string> {
constructor(
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}
/**
* 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.
*/
@CommandHandler(ImportIssuesCommand)
export class ImportIssuesCommandHandler
implements ICommandHandler<ImportIssuesCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: ImportIssuesCommand): Promise<ImportReport> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
if (Buffer.byteLength(command.csv) > MAX_IMPORT_MB * 1024 * 1024)
throw new BusinessRuleError(
`The file is larger than ${MAX_IMPORT_MB} MB`,
);
const project = await this.findProjects.findById(command.projectId);
if (!project) throw new BusinessRuleError('The project is not ready yet');
const plan = planImport(command.csv, project);
const report: ImportReport = {
format: plan.format,
dryRun: command.dryRun,
rows: plan.rows.length,
created: 0,
keys: [],
skipped: [...plan.skipped],
warnings: [...plan.warnings],
};
if (command.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.commandBus.execute(
new CreateIssueCommand(
command.actorId,
project.id,
row.type,
row.title,
{
description: row.description,
priority: row.priority,
labels: row.labels,
storyPoints: row.storyPoints,
startDate: row.startDate,
dueDate: row.dueDate,
},
row.assigneeId,
null,
null,
row.status,
),
);
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.commandBus.execute(
new SetIssueParentCommand(command.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;
}
}
@QueryHandler(ExportIssuesQuery)
export class ExportIssuesQueryHandler
implements IQueryHandler<ExportIssuesQuery, string>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(query: ExportIssuesQuery): Promise<string> {
await this.access.assertCanView(query.projectId, query.viewerId);
const [project, issues] = await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
]);
return exportIssuesCsv(
issues,
new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS),
);
}
}
export const TRANSFER_HANDLERS = [
ImportIssuesCommandHandler,
ExportIssuesQueryHandler,
];

View File

@@ -0,0 +1,85 @@
import {
defaultProjectSettings,
IssuePriority,
ProjectReadModel,
} from '@task-stream/api/tracker/domain';
import { parseCsv, toCsv } from './csv';
import { dayFrom, planImport } from './transfer';
const project = {
id: 'p1',
key: 'WEB',
settings: defaultProjectSettings(),
members: [{ user: { id: 'u-ana', name: 'Ana García' }, role: 'MEMBER' }],
} as unknown as ProjectReadModel;
describe('CSV', () => {
it('round-trips quotes, commas and line breaks, and defuses formulas', () => {
const csv = toCsv([
['a', 'b'],
['say "hi", ok', 'two\nlines'],
['=HYPERLINK("x")', 3],
]);
expect(parseCsv(csv)).toEqual([
['a', 'b'],
['say "hi", ok', 'two\nlines'],
['\'=HYPERLINK("x")', '3'],
]);
});
it('reads Jira dates', () => {
expect(dayFrom('14/Nov/26 12:00 AM')).toBe('2026-11-14');
expect(dayFrom('2026-11-14T10:00:00Z')).toBe('2026-11-14');
expect(dayFrom('mañana')).toBeNull();
});
});
describe('import plan', () => {
it('maps a Jira export: types, statuses, priority, people, repeated labels', () => {
const csv = [
'Summary,Issue key,Issue id,Issue Type,Status,Status Category,Priority,Assignee,Labels,Labels,Custom field (Story Points),Parent,Due date',
'Pay by card,PAY-1,10001,Story,Review,In Progress,Highest,Ana García,ui,checkout,5,,14/Nov/26 12:00 AM',
'Card form,PAY-2,10002,Sub-task,Whatever,,Low,Nobody,,,x,10001,',
',PAY-3,10003,Bug,To Do,To Do,,,,,,,',
].join('\n');
const plan = planImport(csv, project);
expect(plan.format).toBe('jira');
expect(plan.rows).toHaveLength(2);
expect(plan.rows[0]).toMatchObject({
type: 'FEATURE',
status: 'IN_PROGRESS',
priority: IssuePriority.CRITICAL,
assigneeId: 'u-ana',
labels: ['ui', 'checkout'],
storyPoints: 5,
dueDate: '2026-11-14',
sourceKeys: ['PAY-1', '10001'],
});
expect(plan.rows[1]).toMatchObject({
type: 'TASK',
status: 'BACKLOG',
assigneeId: null,
storyPoints: null,
parentSource: '10001',
});
expect(plan.skipped).toEqual([{ line: 4, reason: 'Sin título' }]);
expect(plan.warnings.map((w) => w.line)).toEqual([3, 3, 3]);
});
it('reads its own export back', () => {
const plan = planImport(
'key,type,status,priority,title,assigneeId,labels,parent\nWEB-1,BUG,DONE,HIGH,Crash,u-ana,"a, b",WEB-9\n',
project,
);
expect(plan.format).toBe('taskstream');
expect(plan.rows[0]).toMatchObject({
type: 'BUG',
status: 'DONE',
priority: IssuePriority.HIGH,
assigneeId: 'u-ana',
labels: ['a', 'b'],
parentSource: 'WEB-9',
});
});
});

View File

@@ -0,0 +1,312 @@
import {
IssuePriority,
IssueReadModel,
ProjectReadModel,
ProjectWorkflow,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { parseCsv, toCsv } from './csv';
/** Columns of TaskStream's own export, also what an import of it reads. */
export const EXPORT_COLUMNS = [
'key',
'type',
'typeName',
'status',
'statusName',
'priority',
'title',
'description',
'assignee',
'assigneeId',
'reporter',
'labels',
'storyPoints',
'sprint',
'release',
'parent',
'startDate',
'dueDate',
'createdAt',
'updatedAt',
] as const;
/** Every ticket of a project as CSV (by key). */
export function exportIssuesCsv(
issues: IssueReadModel[],
workflow: ProjectWorkflow,
): string {
const rows = [...issues]
.sort((a, b) => a.number - b.number)
.map((i) => [
i.key,
i.type,
workflow.typeName(i.type),
i.status,
workflow.statusName(i.status),
i.priority,
i.title,
i.description,
i.assignee?.name ?? '',
i.assignee?.id ?? '',
i.reporter?.name ?? '',
i.labels.join(', '),
i.storyPoints,
i.sprint?.name ?? '',
i.release?.name ?? '',
i.parent?.key ?? '',
i.startDate,
i.dueDate,
i.createdAt,
i.updatedAt,
]);
return toCsv([[...EXPORT_COLUMNS], ...rows]);
}
export type ImportFormat = 'taskstream' | 'jira';
/** A row ready to become a ticket. */
export interface ImportRow {
/** Number in the file (header is 1). */
line: number;
/** Its key (or Jira id) in the source, to rebuild parents. */
sourceKeys: string[];
parentSource: string | null;
title: string;
description: string | null;
type: string;
status: string;
priority: IssuePriority;
assigneeId: string | null;
labels: string[];
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
}
export interface ImportPlan {
format: ImportFormat;
rows: ImportRow[];
/** Rows left out, and why. */
skipped: { line: number; reason: string }[];
/** Guesses made (a type or a person not found…). */
warnings: { line: number; message: string }[];
}
export const MAX_IMPORT_ROWS = 2000;
const JIRA_PRIORITY: Record<string, IssuePriority> = {
highest: IssuePriority.CRITICAL,
blocker: IssuePriority.CRITICAL,
critical: IssuePriority.CRITICAL,
high: IssuePriority.HIGH,
major: IssuePriority.HIGH,
medium: IssuePriority.MEDIUM,
low: IssuePriority.LOW,
minor: IssuePriority.LOW,
lowest: IssuePriority.LOWEST,
trivial: IssuePriority.LOWEST,
};
/** Jira's types, by the default type they mean here. */
const JIRA_TYPE: Record<string, string> = {
bug: 'BUG',
defect: 'BUG',
story: 'FEATURE',
feature: 'FEATURE',
'new feature': 'FEATURE',
improvement: 'FEATURE',
task: 'TASK',
'sub-task': 'TASK',
subtask: 'TASK',
epic: 'TASK',
test: 'QA',
};
const JIRA_CATEGORY: Record<string, StatusCategory> = {
'to do': StatusCategory.TODO,
'in progress': StatusCategory.IN_PROGRESS,
done: StatusCategory.DONE,
};
const MONTHS = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];
/** `YYYY-MM-DD` from ISO or Jira's `14/Nov/26 12:00 AM`; null if neither. */
export function dayFrom(text: string): string | null {
const value = text.trim();
if (!value) return null;
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value);
if (iso && !Number.isNaN(Date.parse(iso[1]))) return iso[1];
const jira = /^(\d{1,2})\/([A-Za-z]{3})\/(\d{2,4})/.exec(value);
if (!jira) return null;
const month = MONTHS.indexOf(jira[2].toLowerCase());
if (month === -1) return null;
const year = jira[3].length === 2 ? 2000 + Number(jira[3]) : Number(jira[3]);
return `${year}-${String(month + 1).padStart(2, '0')}-${jira[1].padStart(2, '0')}`;
}
/**
* Reads a CSV exported from TaskStream or from Jira and plans the tickets it
* would create in `project`: types, statuses and people are matched by id or
* by name, and whatever cannot be matched falls back with a warning.
*/
export function planImport(
text: string,
project: ProjectReadModel,
): ImportPlan {
const workflow = new ProjectWorkflow(project.settings);
const [header = [], ...body] = parseCsv(text);
const names = header.map((h) => h.trim().toLowerCase());
const format: ImportFormat =
names.includes('summary') && names.includes('issue key')
? 'jira'
: 'taskstream';
/** Values of every column called `name` (Jira repeats Labels, Sprint…). */
const all = (row: string[], ...wanted: string[]) =>
names
.map((n, i) => (wanted.includes(n) ? (row[i] ?? '').trim() : ''))
.filter(Boolean);
const one = (row: string[], ...wanted: string[]) =>
all(row, ...wanted)[0] ?? '';
const types = workflow.settings.issueTypes.filter((t) => !t.archived);
const statuses = workflow.settings.statuses.filter((s) => !s.archived);
const byName = <T extends { id: string; name: string }>(
list: T[],
value: string,
) =>
list.find(
(item) =>
item.id.toLowerCase() === value.toLowerCase() ||
item.name.toLowerCase() === value.toLowerCase(),
);
const members = project.members.map((m) => m.user);
const plan: ImportPlan = { format, rows: [], skipped: [], warnings: [] };
body.slice(0, MAX_IMPORT_ROWS).forEach((row, index) => {
const line = index + 2;
const warn = (message: string) => plan.warnings.push({ line, message });
const title = one(row, 'title', 'summary');
if (!title) {
plan.skipped.push({ line, reason: 'Sin título' });
return;
}
const typeText = one(row, 'type', 'typename', 'issue type');
let type =
byName(types, typeText)?.id ??
byName(types, one(row, 'typename'))?.id ??
(format === 'jira'
? types.find((t) => t.id === JIRA_TYPE[typeText.toLowerCase()])?.id
: undefined);
if (!type) {
type = types[0].id;
if (typeText)
warn(`Tipo «${typeText}» desconocido: queda como ${types[0].name}`);
}
const statusText = one(row, 'status', 'statusname');
let status =
byName(statuses, statusText)?.id ??
byName(statuses, one(row, 'statusname'))?.id;
if (!status && format === 'jira') {
const category = JIRA_CATEGORY[one(row, 'status category').toLowerCase()];
status = statuses.find((s) => s.category === category)?.id;
}
if (!status) {
status = workflow.initialStatus();
if (statusText)
warn(
`Estado «${statusText}» desconocido: empieza en ${workflow.statusName(status)}`,
);
}
const priorityText = one(row, 'priority').toLowerCase();
const priority = (Object.values(IssuePriority) as string[]).includes(
priorityText.toUpperCase(),
)
? (priorityText.toUpperCase() as IssuePriority)
: (JIRA_PRIORITY[priorityText] ?? IssuePriority.MEDIUM);
const assigneeText = one(row, 'assigneeid', 'assignee', 'assignee id');
const assignee = assigneeText
? members.find(
(u) =>
u.id === assigneeText ||
u.name.toLowerCase() === one(row, 'assignee').toLowerCase(),
)
: undefined;
if (assigneeText && !assignee)
warn(
`«${one(row, 'assignee') || assigneeText}» no es miembro del proyecto: queda sin asignar`,
);
const pointsText = one(
row,
'storypoints',
'story points',
'custom field (story points)',
'custom field (story point estimate)',
);
let storyPoints: number | null = null;
if (pointsText) {
const points = Number(pointsText.replace(',', '.'));
if (Number.isInteger(points) && points >= 0 && points <= 100)
storyPoints = points;
else warn(`Estimación «${pointsText}» no válida: queda sin estimar`);
}
const labels = [
...new Set(
all(row, 'labels')
.flatMap((l) => l.split(/[,;\s]+/))
.map((l) => l.trim().toLowerCase())
.filter(Boolean),
),
].slice(0, 20);
const dueText = one(row, 'duedate', 'due date');
const dueDate = dayFrom(dueText);
if (dueText && !dueDate) warn(`Fecha límite «${dueText}» no reconocida`);
const startDate = dayFrom(
one(row, 'startdate', 'start date', 'custom field (start date)'),
);
plan.rows.push({
line,
sourceKeys: [one(row, 'key', 'issue key'), one(row, 'issue id')].filter(
Boolean,
),
parentSource: one(row, 'parent', 'parent id', 'parent key') || null,
title: title.slice(0, 255),
description: one(row, 'description') || null,
type,
status,
priority,
assigneeId: assignee?.id ?? null,
labels,
storyPoints,
startDate: startDate && dueDate && startDate > dueDate ? null : startDate,
dueDate,
});
});
if (body.length > MAX_IMPORT_ROWS)
plan.skipped.push({
line: MAX_IMPORT_ROWS + 2,
reason: `Solo se importan ${MAX_IMPORT_ROWS} filas por archivo; sobran ${body.length - MAX_IMPORT_ROWS}`,
});
return plan;
}

View File

@@ -18,6 +18,17 @@ export interface CreateIssueProps extends IssueDetails {
type: string;
reporterId: string;
assigneeId?: string | null;
/** Imports only: an active status of the workflow to start in. */
status?: string | null;
}
/** The status asked for if it is active in the workflow, else the first one. */
function startingStatus(
status: string | null | undefined,
workflow: ProjectWorkflow,
): string {
const asked = status ? workflow.status(status) : undefined;
return asked && !asked.archived ? asked.id : workflow.initialStatus();
}
@Injectable()
@@ -51,7 +62,7 @@ export class IssueFactory {
area: props.area ?? null,
title,
description: details.description ?? null,
status: workflow.initialStatus(),
status: startingStatus(props.status, workflow),
priority: props.priority ?? IssuePriority.MEDIUM,
reporterId: props.reporterId,
assigneeId: props.assigneeId ?? null,

View File

@@ -754,6 +754,17 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
active?: boolean;
}
export class ImportQueryDto {
@ApiPropertyOptional({
default: false,
description: 'Only tell what would be created',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
dryRun = false;
}
export class RankIssueDto {
@ApiPropertyOptional({
type: String,

View File

@@ -194,6 +194,25 @@ export class WebhookCreatedDto {
secret!: string;
}
export class ImportLineDto {
@ApiProperty({ description: 'Line of the file (the header is 1)' })
line!: number;
@ApiPropertyOptional() reason?: string;
@ApiPropertyOptional() message?: string;
}
export class ImportReportDto {
@ApiProperty({ enum: ['taskstream', 'jira'] }) format!: string;
@ApiProperty({ description: 'Nothing was created: a preview' })
dryRun!: boolean;
@ApiProperty({ description: 'Rows that become tickets' }) rows!: number;
@ApiProperty() created!: number;
@ApiProperty({ type: [String], example: ['WEB-41', 'WEB-42'] })
keys!: string[];
@ApiProperty({ type: [ImportLineDto] }) skipped!: ImportLineDto[];
@ApiProperty({ type: [ImportLineDto] }) warnings!: ImportLineDto[];
}
export class GitDeliveryResultDto {
@ApiProperty({ description: 'Tickets that got something new' })
recorded!: number;

View File

@@ -0,0 +1,117 @@
import {
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiOptionalPireonAuth,
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
import { ValidationError } from '@task-stream/api/shared/domain';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { TransferService } from '@task-stream/api/tracker/application';
import { Response } from 'express';
import { viewerOf } from '../common/viewer';
import { ImportQueryDto } from './dto/requests.dto';
import { ImportReportDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/** Tickets in and out as CSV: TaskStream's own, or Jira's export. */
@ApiTags('Import and export')
@ApiPireonAuth()
@Controller('projects/:project')
export class TransferController {
constructor(
private readonly transfer: TransferService,
private readonly refs: Refs,
) {}
@Get('export.csv')
@ApiOptionalPireonAuth()
@PROJECT
@ApiProduces('text/csv')
@ApiOperation({
summary: 'Every ticket of the project as CSV',
description:
'UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.',
})
@ApiOkResponse({ description: 'The CSV file' })
@ApiReadErrors()
async exportCsv(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const viewer = viewerOf(me);
const projectId = await this.refs.projectId(viewer, project);
const csv = await this.transfer.exportCsv(viewer, projectId);
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${project.replace(/[^\w-]/g, '') || 'tickets'}.csv"`,
'X-Content-Type-Options': 'nosniff',
});
return new StreamableFile(Buffer.from(csv, 'utf8'));
}
@Post('import')
@HttpCode(200)
@PROJECT
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: { file: { type: 'string', format: 'binary' } },
required: ['file'],
},
})
@ApiOperation({
summary: 'Create tickets from a CSV (owners and admins)',
description:
'TaskStream export or Jira CSV export, up to 5 MB and 2000 rows. Types, statuses and people are matched by id or name; the rest falls back with a warning. With dryRun=true nothing is created.',
})
@ApiOkResponse({ type: ImportReportDto })
@ApiCommandErrors()
async importCsv(
@CurrentUser() me: User,
@Param('project') project: string,
@UploadedFile() file: { buffer: Buffer } | undefined,
@Query() query: ImportQueryDto,
) {
if (!file) throw new ValidationError('Send the CSV as `file`');
return this.transfer.importCsv(
me.id,
await this.refs.projectId(me.id, project),
file.buffer.toString('utf8'),
query.dryRun,
);
}
}

View File

@@ -24,6 +24,7 @@ import { GitController } from './http/git.controller';
import { GitResolver } from './graphql/git.resolver';
import { WebhookResolver } from './graphql/webhook.resolver';
import { WebhooksController } from './http/webhooks.controller';
import { TransferController } from './http/transfer.controller';
/**
* Driving adapters of the tracker context: GraphQL (queries, mutations,
@@ -57,6 +58,7 @@ import { WebhooksController } from './http/webhooks.controller';
SprintsController,
GitController,
WebhooksController,
TransferController,
],
providers: [
Refs,