fix(api): retry commands that clash with a concurrent write

Services run commands through a CommandRunner that runs one again (up to
5 times, waiting a little longer each time) when its aggregate changed
underneath, e.g. a saga writing to the same ticket. Each command loads its
aggregate fresh and stores its events in one transaction, so a
ConcurrencyError means nothing was stored and running it again is safe.
People no longer get a 409 for it, and the e2e suite stops flaking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:00:08 +02:00
parent 76312c079d
commit e14f445e6b
23 changed files with 208 additions and 114 deletions

View File

@@ -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`). - `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. Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, 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 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 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. 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 - **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). `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 - **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 - **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`). (`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 - **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una

View File

@@ -665,7 +665,7 @@ tiempo real, las subscriptions de GraphQL.
### Errores ### Errores
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado `{ 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). transición que el workflow no permite).
### Generar un cliente en otra app ### Generar un cliente en otra app

View File

@@ -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. 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. - **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). - **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 - **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. 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. 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, - 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. 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.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
AuthorizationConfig, AuthorizationConfig,
TokenGrant, TokenGrant,
@@ -20,7 +21,7 @@ import {
@Injectable() @Injectable()
export class IdentityService { export class IdentityService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -44,14 +45,12 @@ export class IdentityService {
} }
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> { handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
return this.commandBus.execute( return this.commands.execute(new HandleIdentityWebhookCommand(event, data));
new HandleIdentityWebhookCommand(event, data),
);
} }
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */ /** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
issueToken(grant: TokenGrant): Promise<TokenSet> { issueToken(grant: TokenGrant): Promise<TokenSet> {
return this.commandBus.execute(new IssueTokenCommand(grant)); return this.commands.execute(new IssueTokenCommand(grant));
} }
authorizationConfig(): Promise<AuthorizationConfig | null> { authorizationConfig(): Promise<AuthorizationConfig | null> {

View File

@@ -2,3 +2,4 @@ export * from './lib/ports/event-store';
export * from './lib/aggregate-rehydrator'; export * from './lib/aggregate-rehydrator';
export * from './lib/shared.module'; export * from './lib/shared.module';
export * from './lib/event-recorder'; export * from './lib/event-recorder';
export * from './lib/command-runner';

View File

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

View File

@@ -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<R>(command: Command<R>): Promise<R> {
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));
}
}
}
}

View File

@@ -1,12 +1,13 @@
import { DynamicModule, Global, Module, Type } from '@nestjs/common'; import { DynamicModule, Global, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { AggregateRehydrator } from './aggregate-rehydrator'; import { AggregateRehydrator } from './aggregate-rehydrator';
import { CommandRunner } from './command-runner';
import { EventRecorder } from './event-recorder'; import { EventRecorder } from './event-recorder';
/** /**
* Shared building blocks for every bounded context: the event store (from * Shared building blocks for every bounded context: the event store (from
* the infrastructure module plugged in by the composition root), the * the infrastructure module plugged in by the composition root), the
* AggregateRehydrator and the EventRecorder. * AggregateRehydrator, the EventRecorder and the CommandRunner.
*/ */
@Global() @Global()
@Module({}) @Module({})
@@ -17,8 +18,13 @@ export class SharedModule {
return { return {
module: SharedModule, module: SharedModule,
imports: [CqrsModule, infrastructureModule], imports: [CqrsModule, infrastructureModule],
providers: [AggregateRehydrator, EventRecorder], providers: [AggregateRehydrator, EventRecorder, CommandRunner],
exports: [infrastructureModule, AggregateRehydrator, EventRecorder], exports: [
infrastructureModule,
AggregateRehydrator,
EventRecorder,
CommandRunner,
],
}; };
} }
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
AutomationInput, AutomationInput,
CreateAutomationCommand, CreateAutomationCommand,
@@ -14,7 +15,7 @@ import { Automation } from './ports/automations.repository';
@Injectable() @Injectable()
export class AutomationsService { export class AutomationsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -27,7 +28,7 @@ export class AutomationsService {
projectId: string, projectId: string,
rule: AutomationInput, rule: AutomationInput,
): Promise<Automation> { ): Promise<Automation> {
return this.commandBus.execute( return this.commands.execute(
new CreateAutomationCommand(actorId, projectId, rule), new CreateAutomationCommand(actorId, projectId, rule),
); );
} }
@@ -37,20 +38,18 @@ export class AutomationsService {
automationId: string, automationId: string,
changes: { name?: string; active?: boolean }, changes: { name?: string; active?: boolean },
): Promise<Automation> { ): Promise<Automation> {
return this.commandBus.execute( return this.commands.execute(
new UpdateAutomationCommand(actorId, automationId, changes), new UpdateAutomationCommand(actorId, automationId, changes),
); );
} }
/** Runs a rule on a ticket; the error, if any, as text. */ /** Runs a rule on a ticket; the error, if any, as text. */
run(automation: Automation, issueId: string): Promise<string | null> { run(automation: Automation, issueId: string): Promise<string | null> {
return this.commandBus.execute( return this.commands.execute(new RunAutomationCommand(automation, issueId));
new RunAutomationCommand(automation, issueId),
);
} }
delete(actorId: string, automationId: string): Promise<void> { delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new DeleteAutomationCommand(actorId, automationId), new DeleteAutomationCommand(actorId, automationId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
CreateCalendarFeedCommand, CreateCalendarFeedCommand,
GetCalendarIcsQuery, GetCalendarIcsQuery,
@@ -12,7 +13,7 @@ import { CalendarFeed } from './ports/calendar-feeds.repository';
@Injectable() @Injectable()
export class CalendarService { export class CalendarService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -22,11 +23,11 @@ export class CalendarService {
/** A new secret URL; the previous one, if any, stops working. */ /** A new secret URL; the previous one, if any, stops working. */
create(userId: string): Promise<CalendarFeed> { create(userId: string): Promise<CalendarFeed> {
return this.commandBus.execute(new CreateCalendarFeedCommand(userId)); return this.commands.execute(new CreateCalendarFeedCommand(userId));
} }
revoke(userId: string): Promise<void> { revoke(userId: string): Promise<void> {
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. */ /** The calendar behind `token`, or null if no feed has it. */

View File

@@ -2,6 +2,7 @@ import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
AggregateRehydrator, AggregateRehydrator,
CommandRunner,
EventStore, EventStore,
} from '@task-stream/api/shared/application'; } from '@task-stream/api/shared/application';
import { import {
@@ -151,6 +152,7 @@ async function setup() {
}, },
{ provide: GitIntegrationsRepository, useValue: git }, { provide: GitIntegrationsRepository, useValue: git },
AggregateRehydrator, AggregateRehydrator,
CommandRunner,
{ provide: EventStore, useValue: store }, { provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files }, { provide: AttachmentStorage, useValue: files },
{ provide: UserDirectory, useValue: users }, { provide: UserDirectory, useValue: users },

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; 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 { CommentReadModel } from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './commands/add-comment.command'; import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
@@ -12,7 +13,7 @@ import { GetCommentsQuery } from './queries/get-comments.query';
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -22,7 +23,7 @@ export class CommentsService {
body: string, body: string,
replyTo: string | null = null, replyTo: string | null = null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddCommentCommand(actorId, issueId, body, replyTo), new AddCommentCommand(actorId, issueId, body, replyTo),
); );
} }
@@ -32,7 +33,7 @@ export class CommentsService {
commentId: string, commentId: string,
body: string, body: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new EditCommentCommand(actorId, commentId, body), new EditCommentCommand(actorId, commentId, body),
); );
} }
@@ -44,15 +45,13 @@ export class CommentsService {
emoji: string, emoji: string,
on: boolean, on: boolean,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ReactToCommentCommand(actorId, commentId, emoji, on), new ReactToCommentCommand(actorId, commentId, emoji, on),
); );
} }
delete(actorId: string, commentId: string): Promise<CommandResult> { delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
new DeleteCommentCommand(actorId, commentId),
);
} }
list( list(

View File

@@ -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 { ForbiddenError } from '@task-stream/api/shared/domain';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
import { RecordGitMentionsCommand } from './git/git'; import { RecordGitMentionsCommand } from './git/git';
@@ -28,7 +29,7 @@ describe('GitService', () => {
executed.push(command); executed.push(command);
return 1; return 1;
}, },
} as unknown as CommandBus, } as unknown as CommandRunner,
{} as QueryBus, {} as QueryBus,
{ {
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null), findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; 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 { ForbiddenError } from '@task-stream/api/shared/domain';
import { import {
AddGitRepositoryCommand, AddGitRepositoryCommand,
@@ -30,7 +31,7 @@ export interface GitDelivery {
@Injectable() @Injectable()
export class GitService { export class GitService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository, private readonly findProjects: FindProjectsRepository,
private readonly integrations: GitIntegrationsRepository, private readonly integrations: GitIntegrationsRepository,
@@ -47,7 +48,7 @@ export class GitService {
projectId: string, projectId: string,
name: string, name: string,
): Promise<GitRepository> { ): Promise<GitRepository> {
return this.commandBus.execute( return this.commands.execute(
new AddGitRepositoryCommand(actorId, projectId, name), new AddGitRepositoryCommand(actorId, projectId, name),
); );
} }
@@ -57,7 +58,7 @@ export class GitService {
projectId: string, projectId: string,
repositoryId: string, repositoryId: string,
): Promise<GitRepository> { ): Promise<GitRepository> {
return this.commandBus.execute( return this.commands.execute(
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId), new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
); );
} }
@@ -67,7 +68,7 @@ export class GitService {
projectId: string, projectId: string,
repositoryId: string, repositoryId: string,
): Promise<void> { ): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId), new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
); );
} }
@@ -104,7 +105,7 @@ export class GitService {
delivery.body, delivery.body,
); );
if (!mentions.length) return { recorded: 0 }; 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), new RecordGitMentionsCommand(project.id, repository.name, mentions),
); );
return { recorded }; return { recorded };

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; 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 { assertThat } from '@task-stream/api/shared/domain';
import { import {
ActivityReadModel, ActivityReadModel,
@@ -79,7 +80,7 @@ export interface IssueLinkInput {
@Injectable() @Injectable()
export class IssuesService { export class IssuesService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly access: WriteAccess, private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository, private readonly findIssues: FindIssuesRepository,
@@ -97,7 +98,7 @@ export class IssuesService {
useTemplate, useTemplate,
...details ...details
} = input; } = input;
return this.commandBus.execute( return this.commands.execute(
new CreateIssueCommand( new CreateIssueCommand(
actorId, actorId,
projectId, projectId,
@@ -118,7 +119,7 @@ export class IssuesService {
issueId: string, issueId: string,
changes: IssueDetails, changes: IssueDetails,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateIssueCommand(actorId, issueId, changes), new UpdateIssueCommand(actorId, issueId, changes),
); );
} }
@@ -129,7 +130,7 @@ export class IssuesService {
status: string, status: string,
reason?: string | null, reason?: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ChangeIssueStatusCommand(actorId, issueId, status, reason), new ChangeIssueStatusCommand(actorId, issueId, status, reason),
); );
} }
@@ -140,13 +141,13 @@ export class IssuesService {
issueId: string, issueId: string,
assigneeId: string | null, assigneeId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AssignIssueCommand(actorId, issueId, assigneeId), new AssignIssueCommand(actorId, issueId, assigneeId),
); );
} }
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> { link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new LinkIssuesCommand( new LinkIssuesCommand(
actorId, actorId,
input.issueId, input.issueId,
@@ -157,7 +158,7 @@ export class IssuesService {
} }
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> { unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UnlinkIssuesCommand( new UnlinkIssuesCommand(
actorId, actorId,
input.issueId, input.issueId,
@@ -173,7 +174,7 @@ export class IssuesService {
issueId: string, issueId: string,
releaseId: string | null, releaseId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new SetIssueReleaseCommand(actorId, issueId, releaseId), new SetIssueReleaseCommand(actorId, issueId, releaseId),
); );
} }
@@ -185,7 +186,7 @@ export class IssuesService {
file: IncomingFile, file: IncomingFile,
commentId: string | null = null, commentId: string | null = null,
): Promise<AttachmentResult> { ): Promise<AttachmentResult> {
return this.commandBus.execute( return this.commands.execute(
new AttachFileCommand(actorId, issueId, file, commentId), new AttachFileCommand(actorId, issueId, file, commentId),
); );
} }
@@ -195,7 +196,7 @@ export class IssuesService {
issueId: string, issueId: string,
attachmentId: string, attachmentId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new DetachFileCommand(actorId, issueId, attachmentId), new DetachFileCommand(actorId, issueId, attachmentId),
); );
} }
@@ -206,7 +207,7 @@ export class IssuesService {
issueId: string, issueId: string,
watching: boolean, watching: boolean,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching), new WatchIssueCommand(actorId, issueId, actorId, watching),
); );
} }
@@ -236,7 +237,7 @@ export class IssuesService {
issueId: string, issueId: string,
parentId: string | null, parentId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new SetIssueParentCommand(actorId, issueId, parentId), new SetIssueParentCommand(actorId, issueId, parentId),
); );
} }
@@ -248,7 +249,7 @@ export class IssuesService {
above: string | null, above: string | null,
below: string | null, below: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RankIssueCommand(actorId, issueId, above, below), new RankIssueCommand(actorId, issueId, above, below),
); );
} }
@@ -278,7 +279,7 @@ export class IssuesService {
if (change.assigneeId !== undefined) if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId); await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined) if (change.sprintId !== undefined)
await this.commandBus.execute( await this.commands.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId), new SetIssueSprintCommand(actorId, issueId, change.sprintId),
); );
if (change.releaseId !== undefined) if (change.releaseId !== undefined)
@@ -306,12 +307,12 @@ export class IssuesService {
/** To the trash (whoever opened it, or a manager); back with `restore`. */ /** To the trash (whoever opened it, or a manager); back with `restore`. */
trash(actorId: string, issueId: string): Promise<CommandResult> { trash(actorId: string, issueId: string): Promise<CommandResult> {
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). */ /** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> { delete(actorId: string, issueId: string): Promise<void> {
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<CommandResult> { restore(actorId: string, issueId: string): Promise<CommandResult> {
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. */ /** Votes for the ticket (`on`) or takes the vote back. */
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> { vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
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, issueId: string,
text: string, text: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddChecklistItemCommand(actorId, issueId, text), new AddChecklistItemCommand(actorId, issueId, text),
); );
} }
@@ -389,7 +390,7 @@ export class IssuesService {
itemId: string, itemId: string,
changes: { text?: string; done?: boolean }, changes: { text?: string; done?: boolean },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes), new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
); );
} }
@@ -399,7 +400,7 @@ export class IssuesService {
issueId: string, issueId: string,
itemId: string, itemId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId), new RemoveChecklistItemCommand(actorId, issueId, itemId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; 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 { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders'; import { SendDueRemindersCommand } from './notifications/reminders';
import { import {
@@ -20,7 +21,7 @@ import { ChangeFeed } from './ports/change-feed';
@Injectable() @Injectable()
export class NotificationsService { export class NotificationsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly changeFeed: ChangeFeed, private readonly changeFeed: ChangeFeed,
) {} ) {}
@@ -45,25 +46,23 @@ export class NotificationsService {
endpoint: string, endpoint: string,
keys: { p256dh: string; auth: string }, keys: { p256dh: string; auth: string },
): Promise<void> { ): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new SubscribePushCommand(userId, endpoint, keys), new SubscribePushCommand(userId, endpoint, keys),
); );
} }
/** Push off on this device. */ /** Push off on this device. */
unsubscribePush(userId: string, endpoint: string): Promise<void> { unsubscribePush(userId: string, endpoint: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(new UnsubscribePushCommand(userId, endpoint));
new UnsubscribePushCommand(userId, endpoint),
);
} }
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */ /** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
sendDueReminders(day: string): Promise<number> { sendDueReminders(day: string): Promise<number> {
return this.commandBus.execute(new SendDueRemindersCommand(day)); return this.commands.execute(new SendDueRemindersCommand(day));
} }
markRead(userId: string, ids?: string[] | null): Promise<void> { markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
); );
} }
@@ -76,7 +75,7 @@ export class NotificationsService {
userId: string, userId: string,
changes: { email: boolean }, changes: { email: boolean },
): Promise<MyNotificationSettings> { ): Promise<MyNotificationSettings> {
await this.commandBus.execute( await this.commands.execute(
new UpdateNotificationSettingsCommand(userId, changes.email), new UpdateNotificationSettingsCommand(userId, changes.email),
); );
return this.settings(userId); return this.settings(userId);

View File

@@ -1,6 +1,7 @@
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
BusinessRuleError, BusinessRuleError,
ForbiddenError, ForbiddenError,
@@ -50,7 +51,7 @@ import { Automation } from './ports/automations.repository';
@Injectable() @Injectable()
export class ProjectsService { export class ProjectsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository, private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess, private readonly access: WriteAccess,
@@ -92,7 +93,7 @@ export class ProjectsService {
const signer = author?.trim().slice(0, 80); const signer = author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`; const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
return this.commandBus.execute( return this.commands.execute(
new CreateIssueCommand( new CreateIssueCommand(
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
project.id, project.id,
@@ -139,7 +140,7 @@ export class ProjectsService {
const template = input.templateId const template = input.templateId
? await this.template(actorId, input.templateId) ? await this.template(actorId, input.templateId)
: null; : null;
const created: CommandResult = await this.commandBus.execute( const created: CommandResult = await this.commands.execute(
new CreateProjectCommand( new CreateProjectCommand(
actorId, actorId,
input.key, input.key,
@@ -149,7 +150,7 @@ export class ProjectsService {
), ),
); );
if (!template) return created; if (!template) return created;
const configured: CommandResult = await this.commandBus.execute( const configured: CommandResult = await this.commands.execute(
new UpdateProjectSettingsCommand( new UpdateProjectSettingsCommand(
actorId, actorId,
created.id, created.id,
@@ -182,7 +183,7 @@ export class ProjectsService {
rule: Automation, rule: Automation,
): Promise<void> { ): Promise<void> {
try { try {
const copy: Automation = await this.commandBus.execute( const copy: Automation = await this.commands.execute(
new CreateAutomationCommand(actorId, projectId, { new CreateAutomationCommand(actorId, projectId, {
name: rule.name, name: rule.name,
trigger: rule.trigger, trigger: rule.trigger,
@@ -192,7 +193,7 @@ export class ProjectsService {
}), }),
); );
if (!rule.active) if (!rule.active)
await this.commandBus.execute( await this.commands.execute(
new UpdateAutomationCommand(actorId, copy.id, { active: false }), new UpdateAutomationCommand(actorId, copy.id, { active: false }),
); );
} catch (error) { } catch (error) {
@@ -215,7 +216,7 @@ export class ProjectsService {
acceptsProposals?: boolean; acceptsProposals?: boolean;
}, },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateProjectCommand(actorId, projectId, changes), new UpdateProjectCommand(actorId, projectId, changes),
); );
} }
@@ -229,7 +230,7 @@ export class ProjectsService {
projectId: string, projectId: string,
confirmKey: string, confirmKey: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new DeleteProjectCommand(actorId, projectId, confirmKey), new DeleteProjectCommand(actorId, projectId, confirmKey),
); );
} }
@@ -245,7 +246,7 @@ export class ProjectsService {
projectId: string, projectId: string,
settings: ProjectSettings, settings: ProjectSettings,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateProjectSettingsCommand(actorId, projectId, settings), new UpdateProjectSettingsCommand(actorId, projectId, settings),
); );
} }
@@ -255,7 +256,7 @@ export class ProjectsService {
input: { projectId: string; userId: string; role: ProjectRole }, input: { projectId: string; userId: string; role: ProjectRole },
accessToken?: string, accessToken?: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddProjectMemberCommand( new AddProjectMemberCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -270,7 +271,7 @@ export class ProjectsService {
actorId: string, actorId: string,
input: { projectId: string; userId: string; role: ProjectRole }, input: { projectId: string; userId: string; role: ProjectRole },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ChangeProjectMemberRoleCommand( new ChangeProjectMemberRoleCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -285,7 +286,7 @@ export class ProjectsService {
projectId: string, projectId: string,
userId: string, userId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RemoveProjectMemberCommand(actorId, projectId, userId), new RemoveProjectMemberCommand(actorId, projectId, userId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
ReleaseBurnupReadModel, ReleaseBurnupReadModel,
ReleaseProgressReadModel, ReleaseProgressReadModel,
@@ -26,7 +27,7 @@ interface ReleaseDetails {
@Injectable() @Injectable()
export class ReleasesService { export class ReleasesService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -34,7 +35,7 @@ export class ReleasesService {
actorId: string, actorId: string,
input: ReleaseDetails & { projectId: string; name: string }, input: ReleaseDetails & { projectId: string; name: string },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new CreateReleaseCommand( new CreateReleaseCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -50,7 +51,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
changes: ReleaseDetails, changes: ReleaseDetails,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateReleaseCommand(actorId, releaseId, changes), new UpdateReleaseCommand(actorId, releaseId, changes),
); );
} }
@@ -61,7 +62,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
force = false, force = false,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new PublishReleaseCommand(actorId, releaseId, force), new PublishReleaseCommand(actorId, releaseId, force),
); );
} }
@@ -72,7 +73,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
moveTo: string | null = null, moveTo: string | null = null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ArchiveReleaseCommand(actorId, releaseId, moveTo), new ArchiveReleaseCommand(actorId, releaseId, moveTo),
); );
} }
@@ -95,7 +96,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
notes: string | null, notes: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new WriteReleaseNotesCommand(actorId, releaseId, notes), new WriteReleaseNotesCommand(actorId, releaseId, notes),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
SavedFilterCriteriaReadModel, SavedFilterCriteriaReadModel,
SavedFilterReadModel, SavedFilterReadModel,
@@ -21,7 +22,7 @@ export interface SaveFilterInput {
@Injectable() @Injectable()
export class SavedFiltersService { export class SavedFiltersService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -30,7 +31,7 @@ export class SavedFiltersService {
} }
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> { save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
return this.commandBus.execute( return this.commands.execute(
new SaveFilterCommand( new SaveFilterCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -42,7 +43,7 @@ export class SavedFiltersService {
} }
delete(actorId: string, filterId: string): Promise<void> { delete(actorId: string, filterId: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new DeleteSavedFilterCommand(actorId, filterId), new DeleteSavedFilterCommand(actorId, filterId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
SprintBurndownReadModel, SprintBurndownReadModel,
SprintDetails, SprintDetails,
@@ -34,7 +35,7 @@ export interface CreateSprintInput {
@Injectable() @Injectable()
export class SprintsService { export class SprintsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -65,7 +66,7 @@ export class SprintsService {
} }
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> { create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new CreateSprintCommand( new CreateSprintCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -82,13 +83,13 @@ export class SprintsService {
sprintId: string, sprintId: string,
changes: SprintDetails, changes: SprintDetails,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateSprintCommand(actorId, sprintId, changes), new UpdateSprintCommand(actorId, sprintId, changes),
); );
} }
start(actorId: string, sprintId: string): Promise<CommandResult> { start(actorId: string, sprintId: string): Promise<CommandResult> {
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId)); return this.commands.execute(new StartSprintCommand(actorId, sprintId));
} }
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */ /** Unfinished tickets go to `carryOverTo` (null: the backlog). */
@@ -97,14 +98,14 @@ export class SprintsService {
sprintId: string, sprintId: string,
carryOverTo: string | null, carryOverTo: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new CloseSprintCommand(actorId, sprintId, carryOverTo), new CloseSprintCommand(actorId, sprintId, carryOverTo),
); );
} }
/** Planned sprints only; its tickets go back to the backlog. */ /** Planned sprints only; its tickets go back to the backlog. */
delete(actorId: string, sprintId: string): Promise<CommandResult> { delete(actorId: string, sprintId: string): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new DeleteSprintCommand(actorId, sprintId), new DeleteSprintCommand(actorId, sprintId),
) as Promise<CommandResult>; ) as Promise<CommandResult>;
} }
@@ -115,7 +116,7 @@ export class SprintsService {
issueId: string, issueId: string,
sprintId: string | null, sprintId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new SetIssueSprintCommand(actorId, issueId, sprintId), new SetIssueSprintCommand(actorId, issueId, sprintId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { WebhookDelivery } from './ports/outgoing-webhooks.repository'; import { WebhookDelivery } from './ports/outgoing-webhooks.repository';
import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events'; import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events';
import { import {
@@ -15,7 +16,7 @@ import {
@Injectable() @Injectable()
export class WebhooksService { export class WebhooksService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -35,7 +36,7 @@ export class WebhooksService {
url: string, url: string,
events: string[], events: string[],
): Promise<{ id: string; secret: string }> { ): Promise<{ id: string; secret: string }> {
return this.commandBus.execute( return this.commands.execute(
new CreateWebhookCommand(actorId, projectId, url, events), new CreateWebhookCommand(actorId, projectId, url, events),
); );
} }
@@ -45,18 +46,16 @@ export class WebhooksService {
webhookId: string, webhookId: string,
changes: { url?: string; events?: string[]; active?: boolean }, changes: { url?: string; events?: string[]; active?: boolean },
): Promise<WebhookView> { ): Promise<WebhookView> {
return this.commandBus.execute( return this.commands.execute(
new UpdateWebhookCommand(actorId, webhookId, changes), new UpdateWebhookCommand(actorId, webhookId, changes),
); );
} }
delete(actorId: string, webhookId: string): Promise<void> { delete(actorId: string, webhookId: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(new DeleteWebhookCommand(actorId, webhookId));
new DeleteWebhookCommand(actorId, webhookId),
);
} }
test(actorId: string, webhookId: string): Promise<WebhookDelivery> { test(actorId: string, webhookId: string): Promise<WebhookDelivery> {
return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId)); return this.commands.execute(new TestWebhookCommand(actorId, webhookId));
} }
} }