diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index 0d13e6f..ca1689a 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -30,7 +30,8 @@ Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` - `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`). Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo. - `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…). -- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por +- Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que + los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas. @@ -109,7 +110,10 @@ sequenceDiagram - **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla `events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa). - **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la - segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`). + segunda falla con `ConcurrencyError`. El `CommandRunner` (`shared/application`) la repite sobre el estado nuevo hasta + 5 veces, con una pequeña espera creciente: cada comando carga su agregado de cero y guarda sus eventos en una sola + transacción, así que un choque significa que no se guardó nada. Solo si sigue chocando llega al cliente (código + GraphQL `CONFLICT`, HTTP `409`). Las sagas tienen su propio reintento en el `EventsBridge`. - **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas (`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`). - **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una diff --git a/docs/03-api.md b/docs/03-api.md index 2eec854..7444ea9 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -665,7 +665,7 @@ tiempo real, las subscriptions de GraphQL. ### Errores `{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado -por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una +por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (la API ya lo ha reintentado 5 veces; raro) y `422` regla de negocio (p. ej. una transición que el workflow no permite). ### Generar un cliente en otra app diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 727aa5d..9bc784d 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -23,9 +23,6 @@ cuando hay pistas, por dónde empezar. ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir. - **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado. - **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos). -- **Escrituras a la vez en un ticket**: si una saga (por ejemplo, apuntar como seguidor a quien se asigna) escribe en - el mismo ticket justo cuando alguien lo cambia, la petición de esa persona recibe un 409 y hay que repetirla. Con la - máquina muy cargada salta en las pruebas e2e de vez en cuando. Reintentar sola la orden ante un `ConcurrencyError`. - **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y renovarlo a mano cuando caduca. @@ -120,3 +117,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada. - Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área, bugs tras cada release) y abiertos por opción de un campo de lista. +- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en + lugar de devolver un 409. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 8dc94a3..6566ec6 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 3b79ee1..6fca3fb 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/identity/application/src/lib/identity.service.ts b/libs/api/identity/application/src/lib/identity.service.ts index 99c26e6..becd0f5 100644 --- a/libs/api/identity/application/src/lib/identity.service.ts +++ b/libs/api/identity/application/src/lib/identity.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { AuthorizationConfig, TokenGrant, @@ -20,7 +21,7 @@ import { @Injectable() export class IdentityService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -44,14 +45,12 @@ export class IdentityService { } handleWebhook(event: string, data: Record): Promise { - return this.commandBus.execute( - new HandleIdentityWebhookCommand(event, data), - ); + return this.commands.execute(new HandleIdentityWebhookCommand(event, data)); } /** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */ issueToken(grant: TokenGrant): Promise { - return this.commandBus.execute(new IssueTokenCommand(grant)); + return this.commands.execute(new IssueTokenCommand(grant)); } authorizationConfig(): Promise { diff --git a/libs/api/shared/application/src/index.ts b/libs/api/shared/application/src/index.ts index aa46fab..9d10b9d 100644 --- a/libs/api/shared/application/src/index.ts +++ b/libs/api/shared/application/src/index.ts @@ -2,3 +2,4 @@ export * from './lib/ports/event-store'; export * from './lib/aggregate-rehydrator'; export * from './lib/shared.module'; export * from './lib/event-recorder'; +export * from './lib/command-runner'; diff --git a/libs/api/shared/application/src/lib/command-runner.spec.ts b/libs/api/shared/application/src/lib/command-runner.spec.ts new file mode 100644 index 0000000..b86860c --- /dev/null +++ b/libs/api/shared/application/src/lib/command-runner.spec.ts @@ -0,0 +1,48 @@ +import { Command, CommandBus } from '@nestjs/cqrs'; +import { + BusinessRuleError, + ConcurrencyError, +} from '@task-stream/api/shared/domain'; +import { COMMAND_ATTEMPTS, CommandRunner } from './command-runner'; + +class Rename extends Command {} + +const busFailing = (errors: Error[]) => { + const execute = jest.fn(async () => { + const error = errors.shift(); + if (error) throw error; + return 'done'; + }); + return { bus: { execute } as unknown as CommandBus, execute }; +}; + +describe('CommandRunner', () => { + it('runs a command again while its aggregate changed underneath', async () => { + const { bus, execute } = busFailing([ + new ConcurrencyError('Aggregate', 'a'), + new ConcurrencyError('Aggregate', 'a'), + ]); + await expect(new CommandRunner(bus).execute(new Rename())).resolves.toBe( + 'done', + ); + expect(execute).toHaveBeenCalledTimes(3); + }); + + it('gives up after a few conflicts, and never retries other errors', async () => { + const conflicts = Array.from( + { length: COMMAND_ATTEMPTS }, + () => new ConcurrencyError('Aggregate', 'a'), + ); + const stubborn = busFailing(conflicts); + await expect( + new CommandRunner(stubborn.bus).execute(new Rename()), + ).rejects.toBeInstanceOf(ConcurrencyError); + expect(stubborn.execute).toHaveBeenCalledTimes(COMMAND_ATTEMPTS); + + const refused = busFailing([new BusinessRuleError('No')]); + await expect( + new CommandRunner(refused.bus).execute(new Rename()), + ).rejects.toBeInstanceOf(BusinessRuleError); + expect(refused.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/api/shared/application/src/lib/command-runner.ts b/libs/api/shared/application/src/lib/command-runner.ts new file mode 100644 index 0000000..72568cc --- /dev/null +++ b/libs/api/shared/application/src/lib/command-runner.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Command, CommandBus } from '@nestjs/cqrs'; +import { ConcurrencyError } from '@task-stream/api/shared/domain'; + +/** Attempts before a conflict reaches the caller (as a 409). */ +export const COMMAND_ATTEMPTS = 5; + +/** + * Runs commands for the application services, and runs one again when its + * aggregate changed underneath (someone, or a saga, wrote to it at the same + * moment). Every command loads its aggregate fresh and stores its events in + * one transaction, so a ConcurrencyError means nothing was stored: running + * it again on the new state is safe. + */ +@Injectable() +export class CommandRunner { + constructor(private readonly commandBus: CommandBus) {} + + async execute(command: Command): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await this.commandBus.execute(command); + } catch (error) { + if (!(error instanceof ConcurrencyError) || attempt >= COMMAND_ATTEMPTS) + throw error; + // A little later each time, so the other writer can finish. + await new Promise((resolve) => setTimeout(resolve, 25 * attempt)); + } + } + } +} diff --git a/libs/api/shared/application/src/lib/shared.module.ts b/libs/api/shared/application/src/lib/shared.module.ts index d021f2a..7055e78 100644 --- a/libs/api/shared/application/src/lib/shared.module.ts +++ b/libs/api/shared/application/src/lib/shared.module.ts @@ -1,12 +1,13 @@ import { DynamicModule, Global, Module, Type } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { AggregateRehydrator } from './aggregate-rehydrator'; +import { CommandRunner } from './command-runner'; import { EventRecorder } from './event-recorder'; /** * Shared building blocks for every bounded context: the event store (from * the infrastructure module plugged in by the composition root), the - * AggregateRehydrator and the EventRecorder. + * AggregateRehydrator, the EventRecorder and the CommandRunner. */ @Global() @Module({}) @@ -17,8 +18,13 @@ export class SharedModule { return { module: SharedModule, imports: [CqrsModule, infrastructureModule], - providers: [AggregateRehydrator, EventRecorder], - exports: [infrastructureModule, AggregateRehydrator, EventRecorder], + providers: [AggregateRehydrator, EventRecorder, CommandRunner], + exports: [ + infrastructureModule, + AggregateRehydrator, + EventRecorder, + CommandRunner, + ], }; } } diff --git a/libs/api/tracker/application/src/lib/automations.service.ts b/libs/api/tracker/application/src/lib/automations.service.ts index a8aabb0..6c8afa1 100644 --- a/libs/api/tracker/application/src/lib/automations.service.ts +++ b/libs/api/tracker/application/src/lib/automations.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { AutomationInput, CreateAutomationCommand, @@ -14,7 +15,7 @@ import { Automation } from './ports/automations.repository'; @Injectable() export class AutomationsService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -27,7 +28,7 @@ export class AutomationsService { projectId: string, rule: AutomationInput, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new CreateAutomationCommand(actorId, projectId, rule), ); } @@ -37,20 +38,18 @@ export class AutomationsService { automationId: string, changes: { name?: string; active?: boolean }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateAutomationCommand(actorId, automationId, changes), ); } /** Runs a rule on a ticket; the error, if any, as text. */ run(automation: Automation, issueId: string): Promise { - return this.commandBus.execute( - new RunAutomationCommand(automation, issueId), - ); + return this.commands.execute(new RunAutomationCommand(automation, issueId)); } delete(actorId: string, automationId: string): Promise { - return this.commandBus.execute( + return this.commands.execute( new DeleteAutomationCommand(actorId, automationId), ); } diff --git a/libs/api/tracker/application/src/lib/calendar.service.ts b/libs/api/tracker/application/src/lib/calendar.service.ts index 0920db6..f9d8077 100644 --- a/libs/api/tracker/application/src/lib/calendar.service.ts +++ b/libs/api/tracker/application/src/lib/calendar.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { CreateCalendarFeedCommand, GetCalendarIcsQuery, @@ -12,7 +13,7 @@ import { CalendarFeed } from './ports/calendar-feeds.repository'; @Injectable() export class CalendarService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -22,11 +23,11 @@ export class CalendarService { /** A new secret URL; the previous one, if any, stops working. */ create(userId: string): Promise { - return this.commandBus.execute(new CreateCalendarFeedCommand(userId)); + return this.commands.execute(new CreateCalendarFeedCommand(userId)); } revoke(userId: string): Promise { - return this.commandBus.execute(new RevokeCalendarFeedCommand(userId)); + return this.commands.execute(new RevokeCalendarFeedCommand(userId)); } /** The calendar behind `token`, or null if no feed has it. */ diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 158e4ec..545031e 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -2,6 +2,7 @@ import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs'; import { Test } from '@nestjs/testing'; import { AggregateRehydrator, + CommandRunner, EventStore, } from '@task-stream/api/shared/application'; import { @@ -151,6 +152,7 @@ async function setup() { }, { provide: GitIntegrationsRepository, useValue: git }, AggregateRehydrator, + CommandRunner, { provide: EventStore, useValue: store }, { provide: AttachmentStorage, useValue: files }, { provide: UserDirectory, useValue: users }, diff --git a/libs/api/tracker/application/src/lib/comments.service.ts b/libs/api/tracker/application/src/lib/comments.service.ts index bf6d590..a71ddfc 100644 --- a/libs/api/tracker/application/src/lib/comments.service.ts +++ b/libs/api/tracker/application/src/lib/comments.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { CommentReadModel } from '@task-stream/api/tracker/domain'; import { AddCommentCommand } from './commands/add-comment.command'; import { CommandResult } from './commands/command-result'; @@ -12,7 +13,7 @@ import { GetCommentsQuery } from './queries/get-comments.query'; @Injectable() export class CommentsService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -22,7 +23,7 @@ export class CommentsService { body: string, replyTo: string | null = null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AddCommentCommand(actorId, issueId, body, replyTo), ); } @@ -32,7 +33,7 @@ export class CommentsService { commentId: string, body: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new EditCommentCommand(actorId, commentId, body), ); } @@ -44,15 +45,13 @@ export class CommentsService { emoji: string, on: boolean, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new ReactToCommentCommand(actorId, commentId, emoji, on), ); } delete(actorId: string, commentId: string): Promise { - return this.commandBus.execute( - new DeleteCommentCommand(actorId, commentId), - ); + return this.commands.execute(new DeleteCommentCommand(actorId, commentId)); } list( diff --git a/libs/api/tracker/application/src/lib/git.service.spec.ts b/libs/api/tracker/application/src/lib/git.service.spec.ts index 2d25680..1227233 100644 --- a/libs/api/tracker/application/src/lib/git.service.spec.ts +++ b/libs/api/tracker/application/src/lib/git.service.spec.ts @@ -1,4 +1,5 @@ -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; +import { CommandRunner } from '@task-stream/api/shared/application'; import { ForbiddenError } from '@task-stream/api/shared/domain'; import { createHmac } from 'node:crypto'; import { RecordGitMentionsCommand } from './git/git'; @@ -28,7 +29,7 @@ describe('GitService', () => { executed.push(command); return 1; }, - } as unknown as CommandBus, + } as unknown as CommandRunner, {} as QueryBus, { findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null), diff --git a/libs/api/tracker/application/src/lib/git.service.ts b/libs/api/tracker/application/src/lib/git.service.ts index e0ec46c..c3ebe4e 100644 --- a/libs/api/tracker/application/src/lib/git.service.ts +++ b/libs/api/tracker/application/src/lib/git.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { ForbiddenError } from '@task-stream/api/shared/domain'; import { AddGitRepositoryCommand, @@ -30,7 +31,7 @@ export interface GitDelivery { @Injectable() export class GitService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, private readonly findProjects: FindProjectsRepository, private readonly integrations: GitIntegrationsRepository, @@ -47,7 +48,7 @@ export class GitService { projectId: string, name: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AddGitRepositoryCommand(actorId, projectId, name), ); } @@ -57,7 +58,7 @@ export class GitService { projectId: string, repositoryId: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId), ); } @@ -67,7 +68,7 @@ export class GitService { projectId: string, repositoryId: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new RemoveGitRepositoryCommand(actorId, projectId, repositoryId), ); } @@ -104,7 +105,7 @@ export class GitService { delivery.body, ); if (!mentions.length) return { recorded: 0 }; - const recorded: number = await this.commandBus.execute( + const recorded: number = await this.commands.execute( new RecordGitMentionsCommand(project.id, repository.name, mentions), ); return { recorded }; diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index dd82709..b17923a 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { assertThat } from '@task-stream/api/shared/domain'; import { ActivityReadModel, @@ -79,7 +80,7 @@ export interface IssueLinkInput { @Injectable() export class IssuesService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, private readonly access: WriteAccess, private readonly findIssues: FindIssuesRepository, @@ -97,7 +98,7 @@ export class IssuesService { useTemplate, ...details } = input; - return this.commandBus.execute( + return this.commands.execute( new CreateIssueCommand( actorId, projectId, @@ -118,7 +119,7 @@ export class IssuesService { issueId: string, changes: IssueDetails, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateIssueCommand(actorId, issueId, changes), ); } @@ -129,7 +130,7 @@ export class IssuesService { status: string, reason?: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new ChangeIssueStatusCommand(actorId, issueId, status, reason), ); } @@ -140,13 +141,13 @@ export class IssuesService { issueId: string, assigneeId: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AssignIssueCommand(actorId, issueId, assigneeId), ); } link(actorId: string, input: IssueLinkInput): Promise { - return this.commandBus.execute( + return this.commands.execute( new LinkIssuesCommand( actorId, input.issueId, @@ -157,7 +158,7 @@ export class IssuesService { } unlink(actorId: string, input: IssueLinkInput): Promise { - return this.commandBus.execute( + return this.commands.execute( new UnlinkIssuesCommand( actorId, input.issueId, @@ -173,7 +174,7 @@ export class IssuesService { issueId: string, releaseId: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new SetIssueReleaseCommand(actorId, issueId, releaseId), ); } @@ -185,7 +186,7 @@ export class IssuesService { file: IncomingFile, commentId: string | null = null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AttachFileCommand(actorId, issueId, file, commentId), ); } @@ -195,7 +196,7 @@ export class IssuesService { issueId: string, attachmentId: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new DetachFileCommand(actorId, issueId, attachmentId), ); } @@ -206,7 +207,7 @@ export class IssuesService { issueId: string, watching: boolean, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new WatchIssueCommand(actorId, issueId, actorId, watching), ); } @@ -236,7 +237,7 @@ export class IssuesService { issueId: string, parentId: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new SetIssueParentCommand(actorId, issueId, parentId), ); } @@ -248,7 +249,7 @@ export class IssuesService { above: string | null, below: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new RankIssueCommand(actorId, issueId, above, below), ); } @@ -278,7 +279,7 @@ export class IssuesService { if (change.assigneeId !== undefined) await this.assign(actorId, issueId, change.assigneeId); if (change.sprintId !== undefined) - await this.commandBus.execute( + await this.commands.execute( new SetIssueSprintCommand(actorId, issueId, change.sprintId), ); if (change.releaseId !== undefined) @@ -306,12 +307,12 @@ export class IssuesService { /** To the trash (whoever opened it, or a manager); back with `restore`. */ trash(actorId: string, issueId: string): Promise { - return this.commandBus.execute(new TrashIssueCommand(actorId, issueId)); + return this.commands.execute(new TrashIssueCommand(actorId, issueId)); } /** Deletes it for good (the trash is emptied this way). */ delete(actorId: string, issueId: string): Promise { - return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId)); + return this.commands.execute(new DeleteIssueCommand(actorId, issueId)); } /** @@ -326,12 +327,12 @@ export class IssuesService { } restore(actorId: string, issueId: string): Promise { - return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); + return this.commands.execute(new RestoreIssueCommand(actorId, issueId)); } /** Votes for the ticket (`on`) or takes the vote back. */ vote(actorId: string, issueId: string, on: boolean): Promise { - return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on)); + return this.commands.execute(new VoteIssueCommand(actorId, issueId, on)); } /** @@ -378,7 +379,7 @@ export class IssuesService { issueId: string, text: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AddChecklistItemCommand(actorId, issueId, text), ); } @@ -389,7 +390,7 @@ export class IssuesService { itemId: string, changes: { text?: string; done?: boolean }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateChecklistItemCommand(actorId, issueId, itemId, changes), ); } @@ -399,7 +400,7 @@ export class IssuesService { issueId: string, itemId: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new RemoveChecklistItemCommand(actorId, issueId, itemId), ); } diff --git a/libs/api/tracker/application/src/lib/notifications.service.ts b/libs/api/tracker/application/src/lib/notifications.service.ts index 6aa6110..85204c9 100644 --- a/libs/api/tracker/application/src/lib/notifications.service.ts +++ b/libs/api/tracker/application/src/lib/notifications.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { NotificationReadModel } from '@task-stream/api/tracker/domain'; import { SendDueRemindersCommand } from './notifications/reminders'; import { @@ -20,7 +21,7 @@ import { ChangeFeed } from './ports/change-feed'; @Injectable() export class NotificationsService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, private readonly changeFeed: ChangeFeed, ) {} @@ -45,25 +46,23 @@ export class NotificationsService { endpoint: string, keys: { p256dh: string; auth: string }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new SubscribePushCommand(userId, endpoint, keys), ); } /** Push off on this device. */ unsubscribePush(userId: string, endpoint: string): Promise { - return this.commandBus.execute( - new UnsubscribePushCommand(userId, endpoint), - ); + return this.commands.execute(new UnsubscribePushCommand(userId, endpoint)); } /** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */ sendDueReminders(day: string): Promise { - return this.commandBus.execute(new SendDueRemindersCommand(day)); + return this.commands.execute(new SendDueRemindersCommand(day)); } markRead(userId: string, ids?: string[] | null): Promise { - return this.commandBus.execute( + return this.commands.execute( new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), ); } @@ -76,7 +75,7 @@ export class NotificationsService { userId: string, changes: { email: boolean }, ): Promise { - await this.commandBus.execute( + await this.commands.execute( new UpdateNotificationSettingsCommand(userId, changes.email), ); return this.settings(userId); diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index 4f41394..5c95379 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -1,6 +1,7 @@ import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { BusinessRuleError, ForbiddenError, @@ -50,7 +51,7 @@ import { Automation } from './ports/automations.repository'; @Injectable() export class ProjectsService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, private readonly findProjects: FindProjectsRepository, private readonly access: WriteAccess, @@ -92,7 +93,7 @@ export class ProjectsService { const signer = author?.trim().slice(0, 80); const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`; - return this.commandBus.execute( + return this.commands.execute( new CreateIssueCommand( SYSTEM_ACTOR_ID, project.id, @@ -139,7 +140,7 @@ export class ProjectsService { const template = input.templateId ? await this.template(actorId, input.templateId) : null; - const created: CommandResult = await this.commandBus.execute( + const created: CommandResult = await this.commands.execute( new CreateProjectCommand( actorId, input.key, @@ -149,7 +150,7 @@ export class ProjectsService { ), ); if (!template) return created; - const configured: CommandResult = await this.commandBus.execute( + const configured: CommandResult = await this.commands.execute( new UpdateProjectSettingsCommand( actorId, created.id, @@ -182,7 +183,7 @@ export class ProjectsService { rule: Automation, ): Promise { try { - const copy: Automation = await this.commandBus.execute( + const copy: Automation = await this.commands.execute( new CreateAutomationCommand(actorId, projectId, { name: rule.name, trigger: rule.trigger, @@ -192,7 +193,7 @@ export class ProjectsService { }), ); if (!rule.active) - await this.commandBus.execute( + await this.commands.execute( new UpdateAutomationCommand(actorId, copy.id, { active: false }), ); } catch (error) { @@ -215,7 +216,7 @@ export class ProjectsService { acceptsProposals?: boolean; }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateProjectCommand(actorId, projectId, changes), ); } @@ -229,7 +230,7 @@ export class ProjectsService { projectId: string, confirmKey: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new DeleteProjectCommand(actorId, projectId, confirmKey), ); } @@ -245,7 +246,7 @@ export class ProjectsService { projectId: string, settings: ProjectSettings, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateProjectSettingsCommand(actorId, projectId, settings), ); } @@ -255,7 +256,7 @@ export class ProjectsService { input: { projectId: string; userId: string; role: ProjectRole }, accessToken?: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new AddProjectMemberCommand( actorId, input.projectId, @@ -270,7 +271,7 @@ export class ProjectsService { actorId: string, input: { projectId: string; userId: string; role: ProjectRole }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new ChangeProjectMemberRoleCommand( actorId, input.projectId, @@ -285,7 +286,7 @@ export class ProjectsService { projectId: string, userId: string, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new RemoveProjectMemberCommand(actorId, projectId, userId), ); } diff --git a/libs/api/tracker/application/src/lib/releases.service.ts b/libs/api/tracker/application/src/lib/releases.service.ts index 1398965..abce125 100644 --- a/libs/api/tracker/application/src/lib/releases.service.ts +++ b/libs/api/tracker/application/src/lib/releases.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { ReleaseBurnupReadModel, ReleaseProgressReadModel, @@ -26,7 +27,7 @@ interface ReleaseDetails { @Injectable() export class ReleasesService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -34,7 +35,7 @@ export class ReleasesService { actorId: string, input: ReleaseDetails & { projectId: string; name: string }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new CreateReleaseCommand( actorId, input.projectId, @@ -50,7 +51,7 @@ export class ReleasesService { releaseId: string, changes: ReleaseDetails, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateReleaseCommand(actorId, releaseId, changes), ); } @@ -61,7 +62,7 @@ export class ReleasesService { releaseId: string, force = false, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new PublishReleaseCommand(actorId, releaseId, force), ); } @@ -72,7 +73,7 @@ export class ReleasesService { releaseId: string, moveTo: string | null = null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new ArchiveReleaseCommand(actorId, releaseId, moveTo), ); } @@ -95,7 +96,7 @@ export class ReleasesService { releaseId: string, notes: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new WriteReleaseNotesCommand(actorId, releaseId, notes), ); } diff --git a/libs/api/tracker/application/src/lib/saved-filters.service.ts b/libs/api/tracker/application/src/lib/saved-filters.service.ts index 6108479..4817ddf 100644 --- a/libs/api/tracker/application/src/lib/saved-filters.service.ts +++ b/libs/api/tracker/application/src/lib/saved-filters.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { SavedFilterCriteriaReadModel, SavedFilterReadModel, @@ -21,7 +22,7 @@ export interface SaveFilterInput { @Injectable() export class SavedFiltersService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -30,7 +31,7 @@ export class SavedFiltersService { } save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> { - return this.commandBus.execute( + return this.commands.execute( new SaveFilterCommand( actorId, input.projectId, @@ -42,7 +43,7 @@ export class SavedFiltersService { } delete(actorId: string, filterId: string): Promise { - return this.commandBus.execute( + return this.commands.execute( new DeleteSavedFilterCommand(actorId, filterId), ); } diff --git a/libs/api/tracker/application/src/lib/sprints.service.ts b/libs/api/tracker/application/src/lib/sprints.service.ts index 6391985..af7f161 100644 --- a/libs/api/tracker/application/src/lib/sprints.service.ts +++ b/libs/api/tracker/application/src/lib/sprints.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { SprintBurndownReadModel, SprintDetails, @@ -34,7 +35,7 @@ export interface CreateSprintInput { @Injectable() export class SprintsService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -65,7 +66,7 @@ export class SprintsService { } create(actorId: string, input: CreateSprintInput): Promise { - return this.commandBus.execute( + return this.commands.execute( new CreateSprintCommand( actorId, input.projectId, @@ -82,13 +83,13 @@ export class SprintsService { sprintId: string, changes: SprintDetails, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateSprintCommand(actorId, sprintId, changes), ); } start(actorId: string, sprintId: string): Promise { - return this.commandBus.execute(new StartSprintCommand(actorId, sprintId)); + return this.commands.execute(new StartSprintCommand(actorId, sprintId)); } /** Unfinished tickets go to `carryOverTo` (null: the backlog). */ @@ -97,14 +98,14 @@ export class SprintsService { sprintId: string, carryOverTo: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new CloseSprintCommand(actorId, sprintId, carryOverTo), ); } /** Planned sprints only; its tickets go back to the backlog. */ delete(actorId: string, sprintId: string): Promise { - return this.commandBus.execute( + return this.commands.execute( new DeleteSprintCommand(actorId, sprintId), ) as Promise; } @@ -115,7 +116,7 @@ export class SprintsService { issueId: string, sprintId: string | null, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new SetIssueSprintCommand(actorId, issueId, sprintId), ); } diff --git a/libs/api/tracker/application/src/lib/webhooks.service.ts b/libs/api/tracker/application/src/lib/webhooks.service.ts index d4f0c52..55eeb3f 100644 --- a/libs/api/tracker/application/src/lib/webhooks.service.ts +++ b/libs/api/tracker/application/src/lib/webhooks.service.ts @@ -1,5 +1,6 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { QueryBus } from '@nestjs/cqrs'; import { WebhookDelivery } from './ports/outgoing-webhooks.repository'; import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events'; import { @@ -15,7 +16,7 @@ import { @Injectable() export class WebhooksService { constructor( - private readonly commandBus: CommandBus, + private readonly commands: CommandRunner, private readonly queryBus: QueryBus, ) {} @@ -35,7 +36,7 @@ export class WebhooksService { url: string, events: string[], ): Promise<{ id: string; secret: string }> { - return this.commandBus.execute( + return this.commands.execute( new CreateWebhookCommand(actorId, projectId, url, events), ); } @@ -45,18 +46,16 @@ export class WebhooksService { webhookId: string, changes: { url?: string; events?: string[]; active?: boolean }, ): Promise { - return this.commandBus.execute( + return this.commands.execute( new UpdateWebhookCommand(actorId, webhookId, changes), ); } delete(actorId: string, webhookId: string): Promise { - return this.commandBus.execute( - new DeleteWebhookCommand(actorId, webhookId), - ); + return this.commands.execute(new DeleteWebhookCommand(actorId, webhookId)); } test(actorId: string, webhookId: string): Promise { - return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId)); + return this.commands.execute(new TestWebhookCommand(actorId, webhookId)); } }