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

@@ -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,
);
}
}