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:
@@ -8399,6 +8399,217 @@
|
|||||||
"Webhooks"
|
"Webhooks"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/export.csv": {
|
||||||
|
"get": {
|
||||||
|
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
|
||||||
|
"operationId": "TransferController_exportCsv",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The CSV file"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"text/csv": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"text/csv": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"text/csv": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"text/csv": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"summary": "Every ticket of the project as CSV",
|
||||||
|
"tags": [
|
||||||
|
"Import and export"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/import": {
|
||||||
|
"post": {
|
||||||
|
"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.",
|
||||||
|
"operationId": "TransferController_importCsv",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dryRun",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only tell what would be created",
|
||||||
|
"schema": {
|
||||||
|
"default": false,
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ImportReportDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Create tickets from a CSV (owners and admins)",
|
||||||
|
"tags": [
|
||||||
|
"Import and export"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
@@ -11491,6 +11702,78 @@
|
|||||||
"description": "Paused webhooks get nothing"
|
"description": "Paused webhooks get nothing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ImportLineDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"line": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Line of the file (the header is 1)"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"line"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ImportReportDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"taskstream",
|
||||||
|
"jira"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dryRun": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Nothing was created: a preview"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Rows that become tickets"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"example": [
|
||||||
|
"WEB-41",
|
||||||
|
"WEB-42"
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ImportLineDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ImportLineDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"format",
|
||||||
|
"dryRun",
|
||||||
|
"rows",
|
||||||
|
"created",
|
||||||
|
"keys",
|
||||||
|
"skipped",
|
||||||
|
"warnings"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1209,6 +1209,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/projects/{project}/export.csv": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: operations["TransferController_exportCsv"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/projects/{project}/import": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
post: operations["TransferController_importCsv"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -2226,6 +2266,30 @@ export interface components {
|
|||||||
/** @description Paused webhooks get nothing */
|
/** @description Paused webhooks get nothing */
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
ImportLineDto: {
|
||||||
|
/** @description Line of the file (the header is 1) */
|
||||||
|
line: number;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
ImportReportDto: {
|
||||||
|
/** @enum {string} */
|
||||||
|
format: "taskstream" | "jira";
|
||||||
|
/** @description Nothing was created: a preview */
|
||||||
|
dryRun: boolean;
|
||||||
|
/** @description Rows that become tickets */
|
||||||
|
rows: number;
|
||||||
|
created: number;
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* "WEB-41",
|
||||||
|
* "WEB-42"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
keys: string[];
|
||||||
|
skipped: components["schemas"]["ImportLineDto"][];
|
||||||
|
warnings: components["schemas"]["ImportLineDto"][];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -8051,4 +8115,147 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
TransferController_exportCsv: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description The CSV file */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
TransferController_importCsv: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
/** @description Only tell what would be created */
|
||||||
|
dryRun?: components["schemas"]["Object"];
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ImportReportDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,37 @@
|
|||||||
|
|
||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||||
|
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||||
|
|
||||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ stateDiagram-v2
|
|||||||
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
||||||
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
||||||
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
||||||
|
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
|
||||||
|
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
|
||||||
|
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
|
||||||
|
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
|
||||||
|
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
|
||||||
|
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
|
||||||
|
creación normal (con sus eventos), en el estado que traía si está activo.
|
||||||
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||||
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||||
@@ -499,6 +506,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export * from './lib/saved-filters.service';
|
|||||||
export * from './lib/sprints.service';
|
export * from './lib/sprints.service';
|
||||||
export * from './lib/git.service';
|
export * from './lib/git.service';
|
||||||
export * from './lib/webhooks.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/webhooks';
|
||||||
export * from './lib/webhooks/webhook-events';
|
export * from './lib/webhooks/webhook-events';
|
||||||
export * from './lib/git/git';
|
export * from './lib/git/git';
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export class CreateIssueCommandHandler
|
|||||||
title: command.title,
|
title: command.title,
|
||||||
reporterId: command.actorId,
|
reporterId: command.actorId,
|
||||||
assigneeId: command.assigneeId,
|
assigneeId: command.assigneeId,
|
||||||
|
status: command.initialStatus,
|
||||||
},
|
},
|
||||||
workflow,
|
workflow,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
|||||||
public readonly releaseId?: string | null,
|
public readonly releaseId?: string | null,
|
||||||
/** Epic or ticket the new one breaks down. */
|
/** Epic or ticket the new one breaks down. */
|
||||||
public readonly parentId?: string | null,
|
public readonly parentId?: string | null,
|
||||||
|
/** Imports only: start in this (active) status instead of the first one. */
|
||||||
|
public readonly initialStatus?: string | null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { GitService } from './git.service';
|
|||||||
import { GIT_HANDLERS } from './git/git';
|
import { GIT_HANDLERS } from './git/git';
|
||||||
import { WebhooksService } from './webhooks.service';
|
import { WebhooksService } from './webhooks.service';
|
||||||
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
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. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
const NOTIFICATIONS = [
|
const NOTIFICATIONS = [
|
||||||
@@ -59,6 +61,7 @@ const SERVICES = [
|
|||||||
SprintsService,
|
SprintsService,
|
||||||
GitService,
|
GitService,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
|
TransferService,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +86,7 @@ const SERVICES = [
|
|||||||
...SAVED_FILTERS,
|
...SAVED_FILTERS,
|
||||||
...GIT_HANDLERS,
|
...GIT_HANDLERS,
|
||||||
...WEBHOOK_HANDLERS,
|
...WEBHOOK_HANDLERS,
|
||||||
|
...TRANSFER_HANDLERS,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
})
|
})
|
||||||
|
|||||||
31
libs/api/tracker/application/src/lib/transfer.service.ts
Normal file
31
libs/api/tracker/application/src/lib/transfer.service.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
libs/api/tracker/application/src/lib/transfer/csv.ts
Normal file
52
libs/api/tracker/application/src/lib/transfer/csv.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
187
libs/api/tracker/application/src/lib/transfer/import-export.ts
Normal file
187
libs/api/tracker/application/src/lib/transfer/import-export.ts
Normal 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,
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
312
libs/api/tracker/application/src/lib/transfer/transfer.ts
Normal file
312
libs/api/tracker/application/src/lib/transfer/transfer.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ export interface CreateIssueProps extends IssueDetails {
|
|||||||
type: string;
|
type: string;
|
||||||
reporterId: string;
|
reporterId: string;
|
||||||
assigneeId?: string | null;
|
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()
|
@Injectable()
|
||||||
@@ -51,7 +62,7 @@ export class IssueFactory {
|
|||||||
area: props.area ?? null,
|
area: props.area ?? null,
|
||||||
title,
|
title,
|
||||||
description: details.description ?? null,
|
description: details.description ?? null,
|
||||||
status: workflow.initialStatus(),
|
status: startingStatus(props.status, workflow),
|
||||||
priority: props.priority ?? IssuePriority.MEDIUM,
|
priority: props.priority ?? IssuePriority.MEDIUM,
|
||||||
reporterId: props.reporterId,
|
reporterId: props.reporterId,
|
||||||
assigneeId: props.assigneeId ?? null,
|
assigneeId: props.assigneeId ?? null,
|
||||||
|
|||||||
@@ -754,6 +754,17 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ImportQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Only tell what would be created',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
dryRun = false;
|
||||||
|
}
|
||||||
|
|
||||||
export class RankIssueDto {
|
export class RankIssueDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -194,6 +194,25 @@ export class WebhookCreatedDto {
|
|||||||
secret!: string;
|
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 {
|
export class GitDeliveryResultDto {
|
||||||
@ApiProperty({ description: 'Tickets that got something new' })
|
@ApiProperty({ description: 'Tickets that got something new' })
|
||||||
recorded!: number;
|
recorded!: number;
|
||||||
|
|||||||
117
libs/api/tracker/presenters/src/lib/http/transfer.controller.ts
Normal file
117
libs/api/tracker/presenters/src/lib/http/transfer.controller.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { GitController } from './http/git.controller';
|
|||||||
import { GitResolver } from './graphql/git.resolver';
|
import { GitResolver } from './graphql/git.resolver';
|
||||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||||
import { WebhooksController } from './http/webhooks.controller';
|
import { WebhooksController } from './http/webhooks.controller';
|
||||||
|
import { TransferController } from './http/transfer.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||||
@@ -57,6 +58,7 @@ import { WebhooksController } from './http/webhooks.controller';
|
|||||||
SprintsController,
|
SprintsController,
|
||||||
GitController,
|
GitController,
|
||||||
WebhooksController,
|
WebhooksController,
|
||||||
|
TransferController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
|
|||||||
@@ -191,6 +191,17 @@ export interface Webhook {
|
|||||||
lastDelivery: WebhookDelivery | null;
|
lastDelivery: WebhookDelivery | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What an import did (or, as a preview, would do). */
|
||||||
|
export interface ImportReport {
|
||||||
|
format: 'taskstream' | 'jira';
|
||||||
|
dryRun: boolean;
|
||||||
|
rows: number;
|
||||||
|
created: number;
|
||||||
|
keys: string[];
|
||||||
|
skipped: { line: number; reason?: string }[];
|
||||||
|
warnings: { line: number; message?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
/** The project's webhook for GitHub, Gitea or GitLab. */
|
/** The project's webhook for GitHub, Gitea or GitLab. */
|
||||||
export interface GitIntegration {
|
export interface GitIntegration {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
|
ImportReport,
|
||||||
Webhook,
|
Webhook,
|
||||||
WebhookDelivery,
|
WebhookDelivery,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
@@ -133,6 +134,17 @@ export abstract class IssuesGateway {
|
|||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tickets in and out as CSV. */
|
||||||
|
export abstract class TransferGateway {
|
||||||
|
abstract exportCsv(projectId: string): Observable<Blob>;
|
||||||
|
/** `dryRun`: only tell what would be created. */
|
||||||
|
abstract importCsv(
|
||||||
|
projectId: string,
|
||||||
|
file: File,
|
||||||
|
dryRun: boolean,
|
||||||
|
): Observable<ImportReport>;
|
||||||
|
}
|
||||||
|
|
||||||
/** The project's outgoing webhooks (owners and admins). */
|
/** The project's outgoing webhooks (owners and admins). */
|
||||||
export abstract class WebhooksGateway {
|
export abstract class WebhooksGateway {
|
||||||
abstract list(projectId: string): Observable<Webhook[]>;
|
abstract list(projectId: string): Observable<Webhook[]>;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
AccessTokenSource,
|
||||||
|
API_CONFIG,
|
||||||
|
} from '@task-stream/web/shared/infrastructure';
|
||||||
|
import { ImportReport, TransferGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
import { from, Observable, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
/** CSV files travel over REST, like attachments. */
|
||||||
|
@Injectable()
|
||||||
|
export class HttpTransferGateway extends TransferGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly api = inject(API_CONFIG);
|
||||||
|
private readonly tokens = inject(AccessTokenSource);
|
||||||
|
|
||||||
|
exportCsv(projectId: string): Observable<Blob> {
|
||||||
|
return this.authorized((headers) =>
|
||||||
|
this.http.get(`${this.base(projectId)}/export.csv`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
importCsv(
|
||||||
|
projectId: string,
|
||||||
|
file: File,
|
||||||
|
dryRun: boolean,
|
||||||
|
): Observable<ImportReport> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file, file.name);
|
||||||
|
return this.authorized((headers) =>
|
||||||
|
this.http.post<ImportReport>(`${this.base(projectId)}/import`, body, {
|
||||||
|
headers,
|
||||||
|
params: { dryRun: String(dryRun) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private base(projectId: string): string {
|
||||||
|
return `${this.api.restUrl}/projects/${encodeURIComponent(projectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private authorized<T>(
|
||||||
|
request: (headers: HttpHeaders) => Observable<T>,
|
||||||
|
): Observable<T> {
|
||||||
|
return from(this.tokens.accessToken()).pipe(
|
||||||
|
switchMap((token) =>
|
||||||
|
request(
|
||||||
|
token
|
||||||
|
? new HttpHeaders({ Authorization: `Bearer ${token}` })
|
||||||
|
: new HttpHeaders(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SprintsGateway,
|
SprintsGateway,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
WebhooksGateway,
|
WebhooksGateway,
|
||||||
|
TransferGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -32,6 +33,7 @@ import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gat
|
|||||||
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||||
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||||
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||||
|
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
||||||
|
|
||||||
/** Ports → GraphQL adapters (and REST for files). */
|
/** Ports → GraphQL adapters (and REST for files). */
|
||||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||||
@@ -49,5 +51,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||||
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
{ provide: GitGateway, useClass: GraphqlGitGateway },
|
||||||
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||||
|
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { NgTemplateOutlet } from '@angular/common';
|
|||||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||||
import { WebhooksComponent } from '../ui/webhooks';
|
import { WebhooksComponent } from '../ui/webhooks';
|
||||||
|
import { TransferComponent } from '../ui/transfer';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -55,6 +56,7 @@ function idFrom(name: string): string {
|
|||||||
imports: [
|
imports: [
|
||||||
GitIntegrationComponent,
|
GitIntegrationComponent,
|
||||||
WebhooksComponent,
|
WebhooksComponent,
|
||||||
|
TransferComponent,
|
||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDrag,
|
CdkDrag,
|
||||||
CdkDragHandle,
|
CdkDragHandle,
|
||||||
@@ -471,6 +473,7 @@ function idFrom(name: string): string {
|
|||||||
[projectKey]="project.key"
|
[projectKey]="project.key"
|
||||||
/>
|
/>
|
||||||
<ts-webhooks [projectId]="project.id" />
|
<ts-webhooks [projectId]="project.id" />
|
||||||
|
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (facade.isOwner() && facade.project(); as project) {
|
@if (facade.isOwner() && facade.project(); as project) {
|
||||||
|
|||||||
213
libs/web/tracker/presenters/src/lib/ui/transfer.ts
Normal file
213
libs/web/tracker/presenters/src/lib/ui/transfer.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { PiActionButtonComponent, PiFileUploadComponent } from '@pi/ui';
|
||||||
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
|
import { ImportReport, TransferGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
/** Settings card: export every ticket as CSV, or import a CSV (ours or Jira's). */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-transfer',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiActionButtonComponent, PiFileUploadComponent],
|
||||||
|
template: `
|
||||||
|
<section
|
||||||
|
class="ts-panel ts-block ts-transfer"
|
||||||
|
aria-labelledby="transfer-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 id="transfer-title">Importar y exportar</h3>
|
||||||
|
<p class="ts-muted">
|
||||||
|
Exporta todos los tickets a CSV, o crea tickets desde un CSV de
|
||||||
|
TaskStream o una exportación CSV de Jira (hasta 2000 filas). Antes de
|
||||||
|
importar verás qué se va a crear.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
[text]="exporting() ? 'Exportando…' : 'Exportar CSV'"
|
||||||
|
[disabled]="exporting()"
|
||||||
|
(execute)="exportCsv()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pi-file-upload
|
||||||
|
label="CSV para importar"
|
||||||
|
hint="Exportación de TaskStream o de Jira («Exportar → CSV (todos los campos)»)"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
[multiple]="false"
|
||||||
|
[maxFiles]="1"
|
||||||
|
[maxSizeBytes]="maxBytes"
|
||||||
|
[(value)]="files"
|
||||||
|
(valueChange)="preview()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (report(); as plan) {
|
||||||
|
<div class="ts-transfer__report" role="status">
|
||||||
|
<p>
|
||||||
|
@if (plan.dryRun) {
|
||||||
|
Formato {{ plan.format === 'jira' ? 'Jira' : 'TaskStream' }}: se
|
||||||
|
crearán <strong>{{ plan.rows }}</strong> tickets.
|
||||||
|
} @else {
|
||||||
|
Creados <strong>{{ plan.created }}</strong> tickets
|
||||||
|
@if (plan.keys.length) {
|
||||||
|
({{ plan.keys[0] }}
|
||||||
|
@if (plan.keys.length > 1) {
|
||||||
|
… {{ plan.keys[plan.keys.length - 1] }}
|
||||||
|
}
|
||||||
|
).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (plan.skipped.length) {
|
||||||
|
<p class="ts-transfer__bad">
|
||||||
|
{{ plan.skipped.length }} fila(s) fuera:
|
||||||
|
{{ lines(plan.skipped, 'reason') }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (plan.warnings.length) {
|
||||||
|
<details>
|
||||||
|
<summary>{{ plan.warnings.length }} aviso(s)</summary>
|
||||||
|
<ul>
|
||||||
|
@for (warning of plan.warnings.slice(0, 30); track $index) {
|
||||||
|
<li>Línea {{ warning.line }}: {{ warning.message }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
@if (plan.dryRun && plan.rows) {
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
[text]="
|
||||||
|
importing()
|
||||||
|
? 'Importando…'
|
||||||
|
: 'Importar ' + plan.rows + ' tickets'
|
||||||
|
"
|
||||||
|
[disabled]="importing()"
|
||||||
|
(execute)="importCsv()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-transfer {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-transfer__report {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 8%, transparent);
|
||||||
|
p,
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-transfer__bad {
|
||||||
|
color: var(--ts-c-red, var(--ts-ink));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TransferComponent {
|
||||||
|
readonly projectId = input.required<string>();
|
||||||
|
readonly projectKey = input.required<string>();
|
||||||
|
|
||||||
|
private readonly gateway = inject(TransferGateway);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
protected readonly maxBytes = 5 * 1024 * 1024;
|
||||||
|
protected readonly files = signal<File[]>([]);
|
||||||
|
protected readonly report = signal<ImportReport | null>(null);
|
||||||
|
protected readonly exporting = signal(false);
|
||||||
|
protected readonly importing = signal(false);
|
||||||
|
|
||||||
|
protected lines(
|
||||||
|
items: { line: number; reason?: string }[],
|
||||||
|
field: 'reason',
|
||||||
|
): string {
|
||||||
|
return items
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((i) => `línea ${i.line} (${i[field]})`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected exportCsv(): void {
|
||||||
|
this.exporting.set(true);
|
||||||
|
this.gateway
|
||||||
|
.exportCsv(this.projectId())
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${this.projectKey()}-tickets.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.exporting.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.exporting.set(false);
|
||||||
|
this.toast.error('No se pudo exportar');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dry run as soon as a file is chosen. */
|
||||||
|
protected preview(): void {
|
||||||
|
const [file] = this.files();
|
||||||
|
this.report.set(null);
|
||||||
|
if (!file) return;
|
||||||
|
this.run(file, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importCsv(): void {
|
||||||
|
const [file] = this.files();
|
||||||
|
if (file) this.run(file, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(file: File, dryRun: boolean): void {
|
||||||
|
this.importing.set(!dryRun);
|
||||||
|
this.gateway
|
||||||
|
.importCsv(this.projectId(), file, dryRun)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: (report) => {
|
||||||
|
this.report.set(report);
|
||||||
|
this.importing.set(false);
|
||||||
|
if (!dryRun) {
|
||||||
|
this.files.set([]);
|
||||||
|
this.toast.success(`${report.created} tickets importados`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.importing.set(false);
|
||||||
|
const message = (error as { error?: { message?: string } })?.error
|
||||||
|
?.message;
|
||||||
|
this.toast.error(message || 'No se pudo leer el CSV');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user