diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 1d77204..289bf06 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1641,4 +1641,63 @@ describe('task-stream (e2e)', () => { ).status, ).toBe(200); }); + + it('groups members in teams: tickets go to them and @team tells them', async () => { + const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + const project = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '102', + }); + expect( + ( + await rest('token-ana', 'PUT', `/projects/${key}/settings`, { + ...project.body['settings'], + teams: [ + { id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] }, + ], + }) + ).status, + ).toBe(200); + const settings = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.body['settings'].teams.length === 1, + ); + expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']); + + const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Para el equipo', + team: 'FRONT', + }); + const ticket = created.body['key']; + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Sin equipo', + }); + const mine = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`), + (r) => r.body?.['total'] === 1, + ); + expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' }); + + await rest('token-ana', 'POST', `/issues/${ticket}/comments`, { + body: '¿Lo miráis, @[Frontend](team-FRONT)?', + }); + const told = await eventually( + () => rest('token-bob', 'GET', '/me/notifications'), + (r) => + r.body?.items?.some( + (n: { kind: string; issueKey: string }) => + n.kind === 'MENTIONED' && n.issueKey === ticket, + ), + ); + expect( + told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED') + .summary, + ).toMatch(/tu equipo Frontend/); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index aee07ae..015eac4 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2654,6 +2654,18 @@ } } }, + { + "name": "team", + "required": false, + "in": "query", + "description": "Team ids", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "priority", "required": false, @@ -11152,6 +11164,34 @@ "archived" ] }, + "TeamDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "FRONTEND" + }, + "name": { + "type": "string", + "example": "Frontend" + }, + "memberIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "archived": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "memberIds", + "archived" + ] + }, "ProjectSettingsDto": { "type": "object", "properties": { @@ -11189,6 +11229,12 @@ "checklistBeforeDone": { "type": "boolean", "description": "Definition of done: tickets reach DONE only with their checklist ticked" + }, + "teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamDto" + } } }, "required": [ @@ -11197,7 +11243,8 @@ "issueTypes", "areas", "customFields", - "checklistBeforeDone" + "checklistBeforeDone", + "teams" ] }, "ProjectDto": { @@ -11560,6 +11607,35 @@ "type" ] }, + "TeamBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "FRONTEND" + }, + "name": { + "type": "string", + "example": "Frontend", + "maxLength": 40 + }, + "memberIds": { + "description": "Pireon user ids of project members (others are dropped)", + "type": "array", + "items": { + "type": "string" + } + }, + "archived": { + "type": "boolean", + "default": false + } + }, + "required": [ + "id", + "name" + ] + }, "ProjectSettingsBodyDto": { "type": "object", "properties": { @@ -11595,6 +11671,13 @@ "$ref": "#/components/schemas/CustomFieldDefinitionBodyDto" } }, + "teams": { + "description": "Absent: unchanged. Teams cannot be removed: archive them.", + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamBodyDto" + } + }, "checklistBeforeDone": { "type": "boolean", "description": "Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged" @@ -11899,6 +11982,11 @@ "nullable": true, "example": "FRONTEND" }, + "team": { + "type": "string", + "nullable": true, + "description": "A team id" + }, "title": { "type": "string" }, @@ -12088,6 +12176,7 @@ "key", "type", "area", + "team", "title", "description", "status", @@ -13199,6 +13288,12 @@ "example": "FRONTEND", "description": "An area id of the project's settings" }, + "team": { + "type": "string", + "nullable": true, + "example": "FRONTEND", + "description": "A team id of the project's settings" + }, "priority": { "allOf": [ { @@ -13337,6 +13432,12 @@ "example": "FRONTEND", "description": "An area id of the project's settings" }, + "team": { + "type": "string", + "nullable": true, + "example": "FRONTEND", + "description": "A team id of the project's settings" + }, "priority": { "allOf": [ { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 3c8d33c..4ed2218 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -339,6 +339,9 @@ input CreateIssueInput { """When work should start (roadmap), YYYY-MM-DD""" startDate: String storyPoints: Int + + """A team id""" + team: String title: String! """A ticket type id""" @@ -607,6 +610,9 @@ type Issue { status: String! statusCategory: StatusCategory! storyPoints: Int + + """A team id""" + team: String title: String! """In the trash since then; null when it is not""" @@ -669,6 +675,9 @@ input IssueFilterInput { sort: IssueSort = RECENT sprintId: ID statuses: [String!] + + """Team ids""" + teams: [String!] types: [String!] unassigned: Boolean @@ -1204,6 +1213,7 @@ type ProjectSettings { customFields: [CustomFieldDefinition!]! issueTypes: [IssueTypeOption!]! statuses: [WorkflowStatus!]! + teams: [Team!]! transitions: [StatusTransitions!]! } @@ -1222,6 +1232,9 @@ input ProjectSettingsInput { customFields: [CustomFieldDefinitionInput!] issueTypes: [IssueTypeOptionInput!]! statuses: [WorkflowStatusInput!]! + + """Absent: unchanged. Teams cannot be removed: archive them.""" + teams: [TeamInput!] transitions: [StatusTransitionsInput!]! } @@ -1654,6 +1667,21 @@ type Subscription { sprintChanged(projectId: ID!): SprintChange! } +"""A group of members: tickets go to it; @team mentions tell them all""" +type Team { + archived: Boolean! + id: String! + memberIds: [ID!]! + name: String! +} + +input TeamInput { + archived: Boolean! = false + id: String! + memberIds: [ID!]! = [] + name: String! +} + type TeamLoad { blocked: Int! openPoints: Int! @@ -1696,6 +1724,9 @@ input UpdateIssueInput { """When work should start (roadmap), YYYY-MM-DD""" startDate: String storyPoints: Int + + """A team id""" + team: String title: String type: String } diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 0293bd4..ca3a735 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1777,6 +1777,14 @@ export interface components { options: string[]; archived: boolean; }; + TeamDto: { + /** @example FRONTEND */ + id: string; + /** @example Frontend */ + name: string; + memberIds: string[]; + archived: boolean; + }; ProjectSettingsDto: { /** @description In board order */ statuses: components["schemas"]["WorkflowStatusDto"][]; @@ -1786,6 +1794,7 @@ export interface components { customFields: components["schemas"]["CustomFieldDefinitionDto"][]; /** @description Definition of done: tickets reach DONE only with their checklist ticked */ checklistBeforeDone: boolean; + teams: components["schemas"]["TeamDto"][]; }; ProjectDto: { /** Format: uuid */ @@ -1931,6 +1940,16 @@ export interface components { /** @default false */ archived: boolean; }; + TeamBodyDto: { + /** @example FRONTEND */ + id: string; + /** @example Frontend */ + name: string; + /** @description Pireon user ids of project members (others are dropped) */ + memberIds?: string[]; + /** @default false */ + archived: boolean; + }; ProjectSettingsBodyDto: { /** @description In board order. Values cannot be removed: archive them instead. */ statuses: components["schemas"]["WorkflowStatusBodyDto"][]; @@ -1939,6 +1958,8 @@ export interface components { areas: components["schemas"]["AreaOptionBodyDto"][]; /** @description Absent: unchanged. Fields cannot be removed: archive them. */ customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][]; + /** @description Absent: unchanged. Teams cannot be removed: archive them. */ + teams?: components["schemas"]["TeamBodyDto"][]; /** @description Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged */ checklistBeforeDone?: boolean; }; @@ -2051,6 +2072,8 @@ export interface components { type: string; /** @example FRONTEND */ area: string | null; + /** @description A team id */ + team: string | null; title: string; description: string | null; /** @@ -2410,6 +2433,11 @@ export interface components { * @example FRONTEND */ area?: string | null; + /** + * @description A team id of the project's settings + * @example FRONTEND + */ + team?: string | null; priority?: components["schemas"]["IssuePriority"]; labels?: string[]; /** @description Figma frame (https) for UI changes */ @@ -2467,6 +2495,11 @@ export interface components { * @example FRONTEND */ area?: string | null; + /** + * @description A team id of the project's settings + * @example FRONTEND + */ + team?: string | null; priority?: components["schemas"]["IssuePriority"]; labels?: string[]; /** @description Figma frame (https) for UI changes */ @@ -4946,6 +4979,8 @@ export interface operations { type?: string[]; /** @description Area ids */ area?: string[]; + /** @description Team ids */ + team?: string[]; priority?: components["schemas"]["IssuePriority"][]; /** @description Pireon user id */ assigneeId?: string; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 41f5f71..c8b8dc7 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -37,6 +37,10 @@ const ids = (what: string) => const issueFields = { description: z.string().max(50000).optional(), area: z.string().optional().describe('Area id (see get_project)'), + team: z + .string() + .optional() + .describe('Team id (see get_project settings.teams)'), priority: PRIORITY.optional(), labels: z.array(z.string().max(40)).max(20).optional(), figmaUrl: z.string().url().optional().describe('Figma frame (https)'), @@ -57,6 +61,7 @@ const issueFields = { type IssueFields = { description?: string; area?: string; + team?: string; priority?: z.infer; labels?: string[]; figmaUrl?: string; @@ -144,6 +149,7 @@ export class IssuesResolver { categories: z.array(CATEGORY).optional(), types: ids('Ticket type'), areas: ids('Area'), + teams: ids('Team'), priorities: z.array(PRIORITY).optional(), assigneeId: z.string().optional().describe('User id'), unassigned: z.boolean().optional(), @@ -187,6 +193,7 @@ export class IssuesResolver { categories?: z.infer[]; types?: string[]; areas?: string[]; + teams?: string[]; priorities?: z.infer[]; assigneeId?: string; unassigned?: boolean; @@ -216,6 +223,7 @@ export class IssuesResolver { category: params.categories, type: params.types, area: params.areas, + team: params.teams, priority: params.priorities, assigneeId: params.assigneeId, unassigned: params.unassigned, diff --git a/docs/03-api.md b/docs/03-api.md index 2df1dd4..52b3ce6 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -127,6 +127,14 @@ nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen. +### Equipos + +`settings.teams`: grupos de miembros (`id`, `name`, `memberIds`, `archived`), como mucho 30. Al guardar, quien no es +miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si `teams` no viene, se quedan como +estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y por REST), y la lista se filtra con +`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu +equipo…»). + ### Definición de «Hecho» `settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE` diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 6f6048f..dd857c0 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -294,7 +294,7 @@ visualización de la plataforma: | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | -| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, definición de «Hecho» y campos personalizados; archivar en lugar de borrar | +| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar | | `/p/:key/activity` | Actividad en vivo | | `/p/:key/members` | Miembros | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index fc86a76..acf7381 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5). vista. - **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la integración continua. -- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`). - **Presencia**: ver quién está mirando o editando el mismo ticket. - **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. @@ -120,3 +119,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el propietario y los admins sí pueden). - Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva. +- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por + equipo y «@equipo» avisa a todos sus miembros. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 4ca5d35..7e0ea8c 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 3a17f7a..0ee6137 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index ba61351..af5382e 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -86,6 +86,7 @@ const FIELD: Record = { description: 'la descripción', type: 'el tipo', area: 'el área', + team: 'el equipo', priority: 'la prioridad', labels: 'las etiquetas', figmaUrl: 'el enlace de Figma', diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index 7e0f6c1..eb43d0a 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -41,6 +41,7 @@ export class IssueCreatedEventHandler key: issue.key, type: issue.type, area: issue.area, + team: issue.team ?? null, title: issue.title, description: issue.description, status: issue.status, diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index b17923a..ed414b7 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -350,6 +350,7 @@ export class IssuesService { title: `${issue.title} (copia)`.slice(0, 255), description: issue.description, area: issue.area, + team: issue.team, priority: issue.priority, labels: issue.labels, figmaUrl: issue.figmaUrl, diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 97454ee..65ab6eb 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -8,6 +8,9 @@ import { IssueStatusChangedEvent, IssueUpdatedEvent, mentionedUserIds, + DEFAULT_PROJECT_SETTINGS, + ProjectWorkflow, + TEAM_MENTION_PREFIX, NotificationKind, ProjectMemberAddedEvent, ProjectRole, @@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler { if (event instanceof CommentAddedEvent) { const said = `«${excerpt(event.comment.body)}»`; // Only members: a mention cannot reach someone who cannot read the ticket. - for (const userId of mentionedUserIds(event.comment.body)) { + // A team's mention (`team-FRONTEND`) reaches each of its members. + const workflow = new ProjectWorkflow( + project.settings ?? DEFAULT_PROJECT_SETTINGS, + ); + const named = mentionedUserIds(event.comment.body); + for (const userId of named) { if (!memberIds.has(userId)) continue; drafts.push({ userId, @@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler { summary: `${actor} te mencionó en ${issue.key}: ${said}`, }); } + for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) { + const team = workflow.teams.find( + (t) => t.id === id.slice(TEAM_MENTION_PREFIX.length), + ); + for (const userId of workflow.mentionedPeople([id])) { + if (!memberIds.has(userId)) continue; + drafts.push({ + userId, + kind: NotificationKind.MENTIONED, + summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`, + }); + } + } const answered = event.comment.replyTo ? await this.findComments.findById(event.comment.replyTo) : null; diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index dc95e01..55153a3 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -20,6 +20,8 @@ export interface IssueFilter { categories?: StatusCategory[]; types?: string[]; areas?: string[]; + /** Team ids of the project's settings. */ + teams?: string[]; priorities?: IssuePriority[]; assigneeId?: string; unassigned?: boolean; diff --git a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts index 12568a4..d4027a4 100644 --- a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts @@ -44,6 +44,7 @@ export class IssueFactory { const details = normalizeIssueDetails(props); workflow.assertActiveType(props.type); workflow.assertActiveArea(props.area); + workflow.assertActiveTeam(props.team); const title = details.title ?? ''; assertThat(title.length > 0, 'Issue title is required'); if (details.startDate && details.dueDate) @@ -60,6 +61,7 @@ export class IssueFactory { key: `${props.projectKey}-${props.number}`, type: props.type, area: props.area ?? null, + team: props.team ?? null, title, description: details.description ?? null, status: startingStatus(props.status, workflow), diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 1117a2e..95517cf 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -56,6 +56,8 @@ export interface IssueDetails { type?: string; /** Id of one of the project's areas. */ area?: string | null; + /** Id of one of the project's teams. */ + team?: string | null; priority?: IssuePriority; labels?: string[]; figmaUrl?: string | null; @@ -83,6 +85,8 @@ export interface IssueProps { key: string; type: string; area: string | null; + /** Absent in tickets created before teams. */ + team?: string | null; title: string; description: string | null; /** Id of a status of the project's workflow. */ @@ -113,6 +117,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public key!: string; public type!: string; public area: string | null = null; + public team: string | null = null; public title!: string; public description: string | null = null; public status!: string; @@ -190,6 +195,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { assertThat(patch.title.length > 0, 'Issue title is required'); if (patch.type !== undefined) workflow.assertActiveType(patch.type); if (patch.area !== undefined) workflow.assertActiveArea(patch.area); + if (patch.team !== undefined) workflow.assertActiveTeam(patch.team); if (Object.keys(patch).length === 0) return; this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); } diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts index c68f2b4..a2d421d 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -403,4 +403,55 @@ describe('Project settings', () => { }); expect(project.workflow.checklistBeforeDone).toBe(false); }); + + it('keeps teams of members, assigns tickets to them and expands their mentions', () => { + const project = newProject(); + project.updateSettings('owner', { + ...defaultProjectSettings(), + teams: [ + { + id: 'front', + name: ' Frontend ', + memberIds: ['dev', 'dev', 'stranger'], + archived: false, + }, + ], + }); + // Normalised, and only people of the project. + expect(project.workflow.teams).toEqual([ + { id: 'FRONT', name: 'Frontend', memberIds: ['dev'], archived: false }, + ]); + // Absent: unchanged; removing one: refused (archive it instead). + project.updateSettings('owner', defaultProjectSettings()); + expect(project.workflow.teams).toHaveLength(1); + expect(() => + project.updateSettings('owner', { + ...defaultProjectSettings(), + teams: [], + }), + ).toThrow(/archívalo/); + + expect( + project.workflow.mentionedPeople(['owner', 'team-FRONT', 'team-NOPE']), + ).toEqual(['owner', 'dev']); + + const issue = new IssueFactory().create( + { + projectId: project.id, + projectKey: 'WEB', + number: 1, + title: 'Botón', + type: 'TASK', + reporterId: 'dev', + team: 'FRONT', + }, + project.workflow, + ); + expect(issue.team).toBe('FRONT'); + expect(() => + issue.updateDetails('dev', { team: 'NOPE' }, project.workflow), + ).toThrow(ValidationError); + issue.updateDetails('dev', { team: null }, project.workflow); + expect(issue.team).toBeNull(); + }); }); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts index 0f0bc56..ef0c447 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -94,6 +94,22 @@ export interface AreaOption { archived: boolean; } +/** + * A group of the project's members (Frontend, QA…): tickets can go to it and + * `@[Frontend](team-FRONTEND)` tells all of them. + */ +export interface TeamDefinition { + id: string; + name: string; + /** User ids; people who leave the project are dropped. */ + memberIds: string[]; + archived: boolean; +} + +/** Prefix of a team's id in a mention: `@[Frontend](team-FRONTEND)`. */ +export const TEAM_MENTION_PREFIX = 'team-'; +export const MAX_TEAMS = 30; + /** What a custom field holds. */ export enum CustomFieldType { TEXT = 'TEXT', @@ -140,6 +156,8 @@ export interface ProjectSettings { * off; when saving, absent means unchanged. */ checklistBeforeDone?: boolean; + /** Absent in projects that never defined any; when saving, absent means unchanged. */ + teams?: TeamDefinition[]; } const status = ( @@ -369,6 +387,34 @@ export class ProjectWorkflow { ); } + /** The project's teams, archived ones included. */ + get teams(): TeamDefinition[] { + return this.settings.teams ?? []; + } + + assertActiveTeam(id: string | null | undefined): void { + if (id === null || id === undefined) return; + const team = this.teams.find((t) => t.id === id); + if (!team) throw new ValidationError(`Unknown team ${id}`); + if (team.archived) + throw new BusinessRuleError(`El equipo ${team.name} está archivado`); + } + + /** Who a mention id reaches: the person, or every member of the team. */ + mentionedPeople(mentionIds: string[]): string[] { + return [ + ...new Set( + mentionIds.flatMap((id) => + id.startsWith(TEAM_MENTION_PREFIX) + ? (this.teams.find( + (t) => t.id === id.slice(TEAM_MENTION_PREFIX.length), + )?.memberIds ?? []) + : [id], + ), + ), + ]; + } + assertActiveArea(id: string | null | undefined): void { if (id === null || id === undefined) return; const area = this.settings.areas.find((a) => a.id === id); @@ -414,6 +460,20 @@ export function validateProjectSettings( name: normName(a.name), archived: !!a.archived, })); + const teams = (next.teams ?? previous.teams ?? []).map((t) => ({ + id: normId(t.id), + name: normName(t.name), + memberIds: [ + ...new Set( + (t.memberIds ?? []).map((m) => String(m).trim()).filter(Boolean), + ), + ].slice(0, 200), + archived: !!t.archived, + })); + assertThat( + teams.length <= MAX_TEAMS, + `Un proyecto tiene como mucho ${MAX_TEAMS} equipos`, + ); const customFields = (next.customFields ?? previous.customFields ?? []).map( normCustomField, ); @@ -434,6 +494,7 @@ export function validateProjectSettings( ['tipo', issueTypes, previous.issueTypes], ['área', areas, previous.areas], ['campo', customFields, previous.customFields ?? []], + ['equipo', teams, previous.teams ?? []], ] as const) { const ids = list.map((item) => item.id); const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); @@ -517,6 +578,7 @@ export function validateProjectSettings( ...((next.checklistBeforeDone ?? previous.checklistBeforeDone) ? { checklistBeforeDone: true } : {}), + ...(teams.length ? { teams } : {}), }; } diff --git a/libs/api/tracker/domain/src/lib/project/project.ts b/libs/api/tracker/domain/src/lib/project/project.ts index 6af6af5..766139f 100644 --- a/libs/api/tracker/domain/src/lib/project/project.ts +++ b/libs/api/tracker/domain/src/lib/project/project.ts @@ -216,7 +216,17 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { /** Owners and admins customise statuses, transitions, types and areas. */ updateSettings(actorId: string, next: ProjectSettings): void { this.assertCanManage(actorId); - const settings = validateProjectSettings(next, this.settings); + const validated = validateProjectSettings(next, this.settings); + // A team only holds people of the project. + const settings = validated.teams + ? { + ...validated, + teams: validated.teams.map((t) => ({ + ...t, + memberIds: t.memberIds.filter((id) => this.isMember(id)), + })), + } + : validated; if (JSON.stringify(settings) === JSON.stringify(this.settings)) return; this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings)); } diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 993d547..76b6763 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -62,6 +62,8 @@ export class IssueReadModel { /** Ids from the project's settings. */ type!: string; area!: string | null; + /** A team id of the project's settings. */ + team!: string | null; title!: string; description!: string | null; status!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 6d21049..6d2e5b5 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -451,6 +451,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { query['statusCategory'] = { $in: filter.categories }; if (filter.types?.length) query['type'] = { $in: filter.types }; if (filter.areas?.length) query['area'] = { $in: filter.areas }; + if (filter.teams?.length) query['team'] = { $in: filter.teams }; if (filter.priorities?.length) query['priority'] = { $in: filter.priorities }; if (filter.labels?.length) query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) }; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index cd7aa3d..d8b732a 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -428,6 +428,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { parent: rest.parent?.id ? rest.parent : null, rank: rest.rank ?? implicitRank(rest.number), startDate: rest.startDate ?? null, + team: rest.team ?? null, customFields: rest.customFields ?? {}, sprint: rest.sprint?.id ? rest.sprint : null, // Links recorded before projects had several repositories name none. diff --git a/libs/api/tracker/presenters/src/lib/common/settings.ts b/libs/api/tracker/presenters/src/lib/common/settings.ts index 625a78e..718c0c3 100644 --- a/libs/api/tracker/presenters/src/lib/common/settings.ts +++ b/libs/api/tracker/presenters/src/lib/common/settings.ts @@ -27,6 +27,13 @@ export interface SettingsPayload { template?: { description?: string | null; checklist?: string[] } | null; }[]; areas: { id: string; name: string; archived: boolean }[]; + /** Absent when saving: unchanged. */ + teams?: { + id: string; + name: string; + memberIds: string[]; + archived: boolean; + }[]; /** Definition of done. Absent when saving: unchanged. */ checklistBeforeDone?: boolean; /** Absent when saving: unchanged. */ @@ -44,6 +51,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload { ...settings, customFields: settings.customFields ?? [], checklistBeforeDone: !!settings.checklistBeforeDone, + teams: settings.teams ?? [], transitions: settings.statuses.map((s) => ({ from: s.id, to: settings.transitions[s.id] ?? [], @@ -75,6 +83,7 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings { : {}), })), areas: payload.areas, + ...(payload.teams ? { teams: payload.teams } : {}), ...(payload.checklistBeforeDone != null ? { checklistBeforeDone: payload.checklistBeforeDone } : {}), diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 082bdf6..3e5e3e3 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -128,6 +128,11 @@ abstract class IssueFieldsInput { @Matches(SETTING_ID) area?: string | null; + @Field(() => String, { nullable: true, description: 'A team id' }) + @IsOptional() + @Matches(SETTING_ID) + team?: string | null; + @Field(() => IssuePriority, { nullable: true }) @IsOptional() @IsEnum(IssuePriority) @@ -515,6 +520,10 @@ export class IssueFilterInput { @IsOptional() @IsString({ each: true }) areas?: string[]; + @Field(() => [String], { nullable: true, description: 'Team ids' }) + @IsOptional() + @IsString({ each: true }) + teams?: string[]; @Field(() => [IssuePriority], { nullable: true }) @IsOptional() @IsEnum(IssuePriority, { each: true }) @@ -712,6 +721,18 @@ export class AreaOptionInput { @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; } +@InputType() +export class TeamInput { + @Field() @Matches(SETTING_ID) id!: string; + @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; + @Field(() => [ID], { defaultValue: [] }) + @IsArray() + @ArrayMaxSize(200) + @IsString({ each: true }) + memberIds!: string[]; + @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; +} + @InputType() export class CustomFieldDefinitionInput { @Field() @Matches(SETTING_ID) id!: string; @@ -773,6 +794,17 @@ export class ProjectSettingsInput { @Type(() => CustomFieldDefinitionInput) customFields?: CustomFieldDefinitionInput[]; + @Field(() => [TeamInput], { + nullable: true, + description: 'Absent: unchanged. Teams cannot be removed: archive them.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => TeamInput) + teams?: TeamInput[]; + @Field({ nullable: true, description: diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index af31998..2198584 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -104,6 +104,17 @@ export class CustomFieldValueGql { @Field() value!: string; } +@ObjectType('Team', { + description: + 'A group of members: tickets go to it; @team mentions tell them all', +}) +export class TeamGql { + @Field() id!: string; + @Field() name!: string; + @Field(() => [ID]) memberIds!: string[]; + @Field() archived!: boolean; +} + @ObjectType('ProjectSettings', { description: 'Statuses (board order), transitions, ticket types, areas and custom fields', @@ -120,6 +131,7 @@ export class ProjectSettingsGql { 'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)', }) checklistBeforeDone!: boolean; + @Field(() => [TeamGql]) teams!: TeamGql[]; } @ObjectType('Project') @@ -366,6 +378,8 @@ export class IssueGql { type!: string; @Field(() => String, { nullable: true, description: 'An area id' }) area!: string | null; + @Field(() => String, { nullable: true, description: 'A team id' }) + team!: string | null; @Field() title!: string; @Field(() => String, { nullable: true }) description!: string | null; @Field({ description: "A status id of the project's settings" }) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 781c55e..874726e 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -243,6 +243,28 @@ export class AreaOptionBodyDto { archived = false; } +export class TeamBodyDto { + @ApiProperty({ example: 'FRONTEND' }) @Matches(SETTING_ID) id!: string; + @ApiProperty({ example: 'Frontend', maxLength: 40 }) + @IsString() + @MinLength(1) + @MaxLength(40) + name!: string; + @ApiPropertyOptional({ + type: [String], + description: 'Pireon user ids of project members (others are dropped)', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(200) + @IsString({ each: true }) + memberIds: string[] = []; + @ApiPropertyOptional({ type: Boolean, default: false }) + @IsOptional() + @IsBoolean() + archived = false; +} + export class CustomFieldDefinitionBodyDto { @ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Navegador', maxLength: 40 }) @@ -319,6 +341,17 @@ export class ProjectSettingsBodyDto { @Type(() => CustomFieldDefinitionBodyDto) customFields?: CustomFieldDefinitionBodyDto[]; + @ApiPropertyOptional({ + type: [TeamBodyDto], + description: 'Absent: unchanged. Teams cannot be removed: archive them.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => TeamBodyDto) + teams?: TeamBodyDto[]; + @ApiPropertyOptional({ description: 'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged', @@ -584,6 +617,12 @@ export class IssueSearchQueryDto extends PageQueryDto { @Matches(SETTING_ID, { each: true }) area?: string[]; + @ApiPropertyOptional({ type: [String], description: 'Team ids' }) + @IsOptional() + @Transform(toList) + @Matches(SETTING_ID, { each: true }) + team?: string[]; + @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority', @@ -729,6 +768,16 @@ class IssueFieldsDto { @Matches(SETTING_ID) area?: string | null; + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'FRONTEND', + description: "A team id of the project's settings", + }) + @IsOptional() + @Matches(SETTING_ID) + team?: string | null; + @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @IsOptional() @IsEnum(IssuePriority) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index c33aef2..44436e3 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -79,6 +79,13 @@ export class AreaOptionDto { @ApiProperty() archived!: boolean; } +export class TeamDto { + @ApiProperty({ example: 'FRONTEND' }) id!: string; + @ApiProperty({ example: 'Frontend' }) name!: string; + @ApiProperty({ type: [String] }) memberIds!: string[]; + @ApiProperty() archived!: boolean; +} + export class CustomFieldDefinitionDto { @ApiProperty({ example: 'BROWSER' }) id!: string; @ApiProperty({ example: 'Navegador' }) name!: string; @@ -108,6 +115,7 @@ export class ProjectSettingsDto { 'Definition of done: tickets reach DONE only with their checklist ticked', }) checklistBeforeDone!: boolean; + @ApiProperty({ type: [TeamDto] }) teams!: TeamDto[]; } export class ProjectDto { @@ -422,6 +430,8 @@ export class IssueDto { type!: string; @ApiProperty({ type: String, nullable: true, example: 'FRONTEND' }) area!: string | null; + @ApiProperty({ type: String, nullable: true, description: 'A team id' }) + team!: string | null; @ApiProperty() title!: string; @ApiProperty({ type: String, nullable: true }) description!: string | null; @ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' }) diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 12a3209..e802a3b 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -165,6 +165,7 @@ export class IssuesController { categories: query.category, types: query.type, areas: query.area, + teams: query.team, priorities: query.priority, assigneeId: query.assigneeId, unassigned: query.unassigned, diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index 5113750..b1c1335 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -234,6 +234,7 @@ describe('ProjectFacade', () => { issueTypes: [], areas: [], customFields: [], + teams: [], }; project.saveSettings(settings); diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index 0d19304..18e7ed5 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -1,6 +1,7 @@ import { computed, inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; import { + TEAM_MENTION_PREFIX, CreateReleaseInput, CreateSprintInput, ProjectRole, @@ -25,6 +26,20 @@ export class ProjectFacade { readonly project = this.store.project; readonly members = this.store.members; + /** + * Who `@` offers: the members, then the active teams (a team's mention, + * `team-`, tells each of its members). + */ + readonly mentionable = computed(() => [ + ...this.store.members().map((m) => m.user), + ...this.store + .workflow() + .activeTeams() + .map((t) => ({ + id: `${TEAM_MENTION_PREFIX}${t.id}`, + name: `${t.name} · equipo`, + })), + ]); readonly stats = this.store.stats; readonly releases = this.store.releases; /** Under way, planned, then closed; with their progress. */ diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 44ecadc..b7ba1c1 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -46,6 +46,7 @@ export interface CreateIssueInput { title: string; description?: string | null; area?: string | null; + team?: string | null; priority?: IssuePriority; labels?: string[]; figmaUrl?: string | null; @@ -98,6 +99,7 @@ export interface IssueListFilter { search?: string; categories?: StatusCategory[]; types?: string[]; + teams?: string[]; priorities?: IssuePriority[]; assigneeId?: string; unassigned?: boolean; diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 4a3e384..cbeb274 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -131,6 +131,8 @@ export interface IssueSummary { /** Ids of the project's settings. */ type: string; area: string | null; + /** A team id of the project's settings. */ + team: string | null; title: string; status: string; statusCategory: StatusCategory; diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index 898a44a..c5a39a8 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -84,6 +84,17 @@ export interface CustomFieldDefinition { archived: boolean; } +/** A group of the project's members; `@[Name](team-ID)` tells them all. */ +export interface TeamDefinition { + id: string; + name: string; + memberIds: string[]; + archived: boolean; +} + +/** Prefix of a team's id in a mention. */ +export const TEAM_MENTION_PREFIX = 'team-'; + /** A ticket's value of a custom field. */ export interface CustomFieldValue { fieldId: string; @@ -99,6 +110,7 @@ export interface ProjectSettings { customFields: CustomFieldDefinition[]; /** Definition of done: a DONE status only with the checklist ticked. */ checklistBeforeDone?: boolean; + teams: TeamDefinition[]; } export const FINISHED_CATEGORIES: readonly StatusCategory[] = [ @@ -134,6 +146,7 @@ const EMPTY: ProjectSettings = { issueTypes: [], areas: [], customFields: [], + teams: [], }; /** @@ -187,6 +200,15 @@ export class Workflow { return this.settings.areas.filter((a) => !a.archived); } + /** Teams to pick (archived ones only show where a ticket still has them). */ + activeTeams(): TeamDefinition[] { + return (this.settings.teams ?? []).filter((t) => !t.archived); + } + + team(id: string): TeamDefinition | undefined { + return this.settings.teams?.find((t) => t.id === id); + } + /** Custom fields to fill in (archived ones only show where they have a value). */ activeCustomFields(): CustomFieldDefinition[] { return (this.settings.customFields ?? []).filter((f) => !f.archived); diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts index 961783b..a72ae5f 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts @@ -113,5 +113,11 @@ export function toSettingsInput( archived: f.archived, })), checklistBeforeDone: !!settings.checklistBeforeDone, + teams: (settings.teams ?? []).map((t) => ({ + id: t.id, + name: t.name, + memberIds: t.memberIds, + archived: t.archived, + })), }; } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 1f3c3e4..31a965f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -60,6 +60,12 @@ fragment ProjectFields on Project { archived } checklistBeforeDone + teams { + id + name + memberIds + archived + } } } @@ -69,6 +75,7 @@ fragment IssueSummaryFields on Issue { key type area + team title status statusCategory diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 7c56ddb..ff7dd8c 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -104,6 +104,8 @@ export type CreateIssueInput = { /** When work should start (roadmap), YYYY-MM-DD */ startDate?: string | null | undefined; storyPoints?: number | null | undefined; + /** A team id */ + team?: string | null | undefined; title: string; /** A ticket type id */ type: string; @@ -194,6 +196,8 @@ export type IssueFilterInput = { sort?: IssueSort | null | undefined; sprintId?: string | null | undefined; statuses?: Array | null | undefined; + /** Team ids */ + teams?: Array | null | undefined; types?: Array | null | undefined; unassigned?: boolean | null | undefined; /** Only tickets not updated since this instant (ISO) */ @@ -279,6 +283,8 @@ export type ProjectSettingsInput = { customFields?: Array | null | undefined; issueTypes: Array; statuses: Array; + /** Absent: unchanged. Teams cannot be removed: archive them. */ + teams?: Array | null | undefined; transitions: Array; }; @@ -339,6 +345,13 @@ export type StatusTransitionsInput = { to: Array; }; +export type TeamInput = { + archived?: boolean; + id: string; + memberIds?: Array; + name: string; +}; + export type UpdateChecklistItemInput = { done?: boolean | null | undefined; issueId: string; @@ -361,6 +374,8 @@ export type UpdateIssueInput = { /** When work should start (roadmap), YYYY-MM-DD */ startDate?: string | null | undefined; storyPoints?: number | null | undefined; + /** A team id */ + team?: string | null | undefined; title?: string | null | undefined; type?: string | null | undefined; }; @@ -560,6 +575,12 @@ export type ProjectFieldsFragment = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }; @@ -569,6 +590,7 @@ export type IssueSummaryFieldsFragment = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -602,6 +624,7 @@ export type IssueFieldsFragment = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -780,6 +803,7 @@ export type BoardQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -821,6 +845,7 @@ export type IssueQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -886,6 +911,7 @@ export type SearchIssuesQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1059,6 +1085,7 @@ export type MyIssuesQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1096,6 +1123,7 @@ export type IssueChildrenQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1133,6 +1161,7 @@ export type EpicsQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1218,6 +1247,7 @@ export type BacklogQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1289,6 +1319,7 @@ export type TrashQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1325,6 +1356,7 @@ export type DueIssuesQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1364,6 +1396,7 @@ export type IssueListQuery = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -1559,6 +1592,12 @@ export type MyProjectsQuery = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }>; }; @@ -1611,6 +1650,12 @@ export type ProjectQuery = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }; }; @@ -1773,6 +1818,12 @@ export type PublicProjectsQuery = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }>; }; @@ -2331,6 +2382,7 @@ export type IssueChangedSubscription = { key: string; type: string; area: string | null; + team: string | null; title: string; status: string; statusCategory: StatusCategory; @@ -2466,6 +2518,12 @@ export type ProjectChangedSubscription = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }; }; @@ -2521,6 +2579,12 @@ export type MyProjectsChangedSubscription = { options: Array; archived: boolean; }>; + teams: Array<{ + id: string; + name: string; + memberIds: Array; + archived: boolean; + }>; }; }; }; @@ -2909,6 +2973,25 @@ export const ProjectFieldsFragmentDoc = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -2950,6 +3033,7 @@ export const IssueSummaryFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -3209,6 +3293,7 @@ export const IssueFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -4986,6 +5071,7 @@ export const BoardDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -5158,6 +5244,7 @@ export const IssueDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -5465,6 +5552,7 @@ export const SearchIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -6873,6 +6961,7 @@ export const MyIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -7059,6 +7148,7 @@ export const IssueChildrenDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -7288,6 +7378,7 @@ export const EpicsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -7796,6 +7887,7 @@ export const BacklogDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -8241,6 +8333,7 @@ export const TrashDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -8427,6 +8520,7 @@ export const DueIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -8628,6 +8722,7 @@ export const IssueListDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -9655,6 +9750,25 @@ export const MyProjectsDocument = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -9903,6 +10017,25 @@ export const ProjectDocument = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -11002,6 +11135,25 @@ export const PublicProjectsDocument = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -13868,6 +14020,7 @@ export const IssueChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, @@ -14529,6 +14682,25 @@ export const ProjectChangedDocument = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -14764,6 +14936,25 @@ export const MyProjectsChangedDocument = { kind: 'Field', name: { kind: 'Name', value: 'checklistBeforeDone' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'teams' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'memberIds' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 7cd417d..774c5df 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -477,9 +477,7 @@ export class BoardPage { protected readonly board = inject(BoardFacade); protected readonly project = inject(ProjectFacade); /** Who `@` offers in the new ticket's description. */ - protected readonly mentionable = computed(() => - this.project.members().map((m) => m.user), - ); + protected readonly mentionable = this.project.mentionable; private readonly drawer = inject(DrawerService); private readonly toast = inject(ToastService); private readonly auth = inject(AuthFacade); diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 7fce730..e74f326 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -854,6 +854,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; > Área + @if (teamOptions().length > 1) { + + Equipo + + } @for (field of customFields(); track field.id) { ({ value: t.id, label: t.name })); }, ); + protected readonly teamOptions = computed[]>( + () => { + const current = this.facade.issue()?.team; + return [ + { value: NONE, label: 'Sin equipo' }, + ...this.project + .workflow() + .settings.teams.filter((t) => !t.archived || t.id === current) + .map((t) => ({ value: t.id, label: t.name })), + ]; + }, + ); protected readonly areaOptions = computed[]>( () => { const workflow = this.project.workflow(); @@ -1164,9 +1186,7 @@ export class IssuePage { protected readonly pasteImage: ImageUploader = (file) => this.facade.uploadImage(file); /** Who `@` offers in what is written here. */ - protected readonly mentionable = computed(() => - this.project.members().map((m) => m.user), - ); + protected readonly mentionable = this.project.mentionable; /** Pull requests first, then the newest. */ protected readonly devLinks = computed(() => [...(this.facade.issue()?.devLinks ?? [])].sort( @@ -1332,6 +1352,7 @@ export class IssuePage { type?: string; priority?: IssuePriority; area?: string | null; + team?: string | null; storyPoints?: number | null; startDate?: string | null; dueDate?: string | null; diff --git a/libs/web/tracker/presenters/src/lib/pages/list.page.ts b/libs/web/tracker/presenters/src/lib/pages/list.page.ts index 11e69b5..fb4c2c2 100644 --- a/libs/web/tracker/presenters/src/lib/pages/list.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/list.page.ts @@ -112,6 +112,9 @@ const numberOf = (key: string) => Number(key.split('-').pop()); [options]="releaseFilterOptions()" [(value)]="releaseFilter" /> + @if (teamFilterOptions().length > 1) { + + } @for (field of listFields(); track field.id) { (ALL); protected readonly assignee = signal(ALL); protected readonly releaseFilter = signal(ALL); + protected readonly team = signal(ALL); + protected readonly teamFilterOptions = computed< + PiSelectFieldOption[] + >(() => { + const teams = this.project.workflow().activeTeams(); + return teams.length + ? [ + { value: ALL, label: 'Todos los equipos' }, + ...teams.map((t) => ({ value: t.id, label: t.name })), + ] + : []; + }); private readonly releaseFromUrl = effect(() => { const id = this.release(); if (id) untracked(() => this.releaseFilter.set(id)); @@ -626,6 +641,7 @@ export class ListPage { unassigned: assignee === NOBODY || undefined, assigneeId: assignee && assignee !== NOBODY ? assignee : undefined, releaseId: this.releaseFilter() || undefined, + teams: this.team() ? [this.team() as string] : undefined, noEstimate: this.gap() === 'estimate' || undefined, noArea: this.gap() === 'area' || undefined, updatedBefore: this.stale() diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index dfec3ab..4ed47ee 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -32,7 +32,7 @@ import { } from '@task-stream/web/tracker/domain'; import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy'; -type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields'; +type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams'; const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [ { id: CustomFieldType.TEXT, label: 'Texto' }, @@ -530,6 +530,63 @@ function idFrom(name: string): string { + +
+
+
+

Equipos

+

+ Grupos de miembros: los tickets se pueden asignar a uno y + «@equipo» avisa a todos sus miembros. +

+
+ +
+ @if (!d.teams.length) { +

Ningún equipo todavía.

+ } +
    + @for (team of d.teams; track $index; let i = $index) { +
  1. + + + {{ team.id }} + + +
    + @for (member of facade.members(); track member.user.id) { + + } +
    +
  2. + } +
+
+
@@ -1035,6 +1092,11 @@ function idFrom(name: string): string { resize: vertical; } } + .ts-team-members { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + } .ts-empty-fields { margin: 0; } @@ -1277,7 +1339,7 @@ export class SettingsPage { const d = this.draft(); if (!d) return []; const problems: string[] = []; - const lists = [d.statuses, d.issueTypes, d.areas, d.customFields]; + const lists = [d.statuses, d.issueTypes, d.areas, d.customFields, d.teams]; if (lists.some((list) => list.some((item) => !item.name.trim()))) problems.push('Hay nombres vacíos'); if ( @@ -1449,6 +1511,26 @@ export class SettingsPage { ); } + protected addTeam(): void { + this.patch((d) => + d.teams.push({ + id: this.uniqueId(d.teams, 'NUEVO_EQUIPO'), + name: 'Nuevo equipo', + memberIds: [], + archived: false, + }), + ); + } + + protected setTeamMember(index: number, userId: string, on: boolean): void { + this.patch((d) => { + const team = d.teams[index]; + team.memberIds = on + ? [...new Set([...team.memberIds, userId])] + : team.memberIds.filter((id) => id !== userId); + }); + } + protected setChecklistBeforeDone(on: boolean): void { this.patch((d) => { d.checklistBeforeDone = on;