refactor(api): buses only in services, atomic commands, @Prop schemas

- Mongo read models are @Schema/@Prop classes (SchemaFactory), still
  loose: the props declared are the indexed or shaped ones.
- CommandBus and QueryBus are used by the application services alone.
  Duplicating a ticket, bulk updates, CSV imports, proposals and
  emptying the trash chain several commands, so they are service
  methods now and their composite commands are gone; every command
  handler is atomic. No forwardRef is needed.
- The due-date and trash timers are entry points: they live in the
  presenters and call the services. The automations event handler has
  its own file and calls AutomationsService.
- The tracker's user directory asks IdentityService; the composition
  root hands the identity application module to the tracker
  infrastructure (withIdentity), with no global module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 19:56:59 +02:00
parent 2c501a50d0
commit 1550332d41
34 changed files with 673 additions and 784 deletions

View File

@@ -24,11 +24,16 @@ import { HealthController } from './health.controller';
* Composition root. Each bounded context is assembled outside-in:
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
*/
const identity = IdentityPresentersModule.withApplication(
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule),
const identityApplication = IdentityApplicationModule.withInfrastructure(
IdentityInfrastructureModule,
);
const identity = IdentityPresentersModule.withApplication(identityApplication);
// The tracker's user directory asks the identity context (IdentityService):
// the same module, so one identity context serves both.
const tracker = TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
TrackerApplicationModule.withInfrastructure(
TrackerInfrastructureModule.withIdentity(identityApplication),
),
);
@Module({

View File

@@ -23,14 +23,23 @@ flowchart LR
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, temporizadores, páginas Angular) | `domain`, `application`, `presenters` |
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
- `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…).
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; 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.
- Los comandos son atómicos: un handler no ejecuta otros comandos. Lo que encadena varios (duplicar un ticket, la
edición en bloque, importar un CSV, las propuestas, vaciar la papelera) es un método del servicio, que ejecuta los
comandos uno a uno con su bus. Así no hay ciclos entre servicios y handlers, ni `forwardRef`.
- Sin módulos globales entre contextos: el tracker recibe el módulo de aplicación de identidad en la raíz de composición
(`TrackerInfrastructureModule.withIdentity(...)`), el mismo con el que se monta ese contexto.
- `presenters` nunca importa `infrastructure`: habla con los `*.service.ts` (API) o con las facades (web).
- `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
no depende de la API (y viceversa).
@@ -125,6 +134,9 @@ sequenceDiagram
que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`.
- **Esquemas**: clases con `@Schema()` y `@Prop()` de `@nestjs/mongoose` (`read-model.schemas.ts`,
`user.schema.ts`), en `strict: false`: cada documento guarda el read model entero y las props declaradas son las que
tienen índice o una forma que conviene fijar.
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,8 @@ export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/blockers.service';
export * from './lib/bulk-change';
export * from './lib/proposals';
export * from './lib/proposal-quota.service';
export * from './lib/notifications.service';
export * from './lib/notifications';

View File

@@ -5,6 +5,7 @@ import {
CreateAutomationCommand,
DeleteAutomationCommand,
GetAutomationsQuery,
RunAutomationCommand,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
@@ -41,6 +42,13 @@ export class AutomationsService {
);
}
/** Runs a rule on a ticket; the error, if any, as text. */
run(automation: Automation, issueId: string): Promise<string | null> {
return this.commandBus.execute(
new RunAutomationCommand(automation, issueId),
);
}
delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute(
new DeleteAutomationCommand(actorId, automationId),

View File

@@ -0,0 +1,66 @@
import { Logger } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssueStatusChangedEvent,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { AutomationsService } from '../automations.service';
import { WriteAccess } from '../commands/write-access';
import { AutomationsRepository } from '../ports/automations.repository';
import { ruleApplies, triggerOf } from './automation-rules';
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly service: AutomationsService,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.service.run(rule, trigger.issueId);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}

View File

@@ -1,13 +1,9 @@
import { randomUUID } from 'node:crypto';
import { Logger } from '@nestjs/common';
import {
Command,
CommandBus,
CommandHandler,
EventPublisher,
EventsHandler,
ICommandHandler,
IEventHandler,
IQueryHandler,
Query,
QueryHandler,
@@ -17,17 +13,11 @@ import {
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
CommentAddedEvent,
CommentFactory,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
Project,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import {
@@ -42,8 +32,6 @@ import {
AutomationConditions,
AutomationTrigger,
normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules';
/** A project keeps at most this many rules. */
@@ -314,63 +302,10 @@ export class GetAutomationsQueryHandler
}
}
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.commandBus.execute(
new RunAutomationCommand(rule, trigger.issueId),
);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}
export const AUTOMATION_HANDLERS = [
CreateAutomationCommandHandler,
UpdateAutomationCommandHandler,
DeleteAutomationCommandHandler,
RunAutomationCommandHandler,
GetAutomationsQueryHandler,
AutomationsEventHandler,
];

View File

@@ -0,0 +1,23 @@
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}

View File

@@ -1,115 +0,0 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { IssuePriority } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './assign-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { SetIssueSprintCommand } from './sprint.commands';
import { UpdateIssueCommand } from './update-issue.command';
import { WriteAccess } from './write-access';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
constructor(
public readonly actorId: string,
public readonly issueIds: string[],
public readonly change: BulkChange,
) {
super();
}
}
@CommandHandler(BulkUpdateIssuesCommand)
export class BulkUpdateIssuesCommandHandler
implements ICommandHandler<BulkUpdateIssuesCommand>
{
constructor(
private readonly commandBus: CommandBus,
private readonly access: WriteAccess,
) {}
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
const ids = [...new Set(command.issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
const change = command.change;
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const actor = command.actorId;
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.run(
new ChangeIssueStatusCommand(actor, issueId, change.status),
);
if (change.assigneeId !== undefined)
await this.run(
new AssignIssueCommand(actor, issueId, change.assigneeId),
);
if (change.sprintId !== undefined)
await this.run(
new SetIssueSprintCommand(actor, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.run(
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.run(new UpdateIssueCommand(actor, issueId, details));
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
private run(command: object): Promise<unknown> {
return this.commandBus.execute(command as never);
}
}

View File

@@ -1,7 +1,5 @@
import { DuplicateIssueCommandHandler } from './duplicate-issue.command-handler';
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { SubmitProposalCommandHandler } from './submit-proposal.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
@@ -36,7 +34,6 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
import { TRASH_HANDLERS } from './trash';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
@@ -46,10 +43,8 @@ import {
} from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [
SubmitProposalCommandHandler,
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
DuplicateIssueCommandHandler,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
@@ -85,7 +80,6 @@ export const COMMAND_HANDLERS = [
RankIssueCommandHandler,
WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS,
BulkUpdateIssuesCommandHandler,
...TRASH_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,

View File

@@ -75,10 +75,8 @@ import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { UpdateProjectCommand } from './update-project.command';
import { SubmitProposalCommand } from './submit-proposal.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
@@ -87,6 +85,8 @@ import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { IssuesService } from '../issues.service';
import { ProjectsService } from '../projects.service';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { CreateProjectCommand } from './create-project.command';
@@ -96,7 +96,6 @@ import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -131,6 +130,9 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
// What composes several commands lives in the services.
IssuesService,
ProjectsService,
...GIT_HANDLERS,
RunAutomationCommandHandler,
...TRACKER_FACTORIES,
@@ -196,6 +198,8 @@ async function setup() {
projectViews,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
issues: moduleRef.get(IssuesService),
projects: moduleRef.get(ProjectsService),
};
}
@@ -702,7 +706,7 @@ describe('tracker commands (event sourced)', () => {
});
it('changes several tickets at once and says which refused', async () => {
const { run, rehydrator } = await setup();
const { issues, run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids: string[] = [];
for (const title of ['A', 'B'])
@@ -716,13 +720,11 @@ describe('tracker commands (event sourced)', () => {
for (const status of ['TODO', 'IN_PROGRESS'])
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
// A is in BACKLOG: the workflow does not let it jump to review.
const result = (await run(
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
const result = await issues.bulkUpdate('dev', [...ids, ids[1]], {
status: 'IN_REVIEW',
assigneeId: 'qa',
addLabels: ['sprint-14'],
}),
)) as unknown as BulkResult;
});
expect(result.updated).toBe(1);
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
@@ -735,9 +737,9 @@ describe('tracker commands (event sourced)', () => {
'qa',
['sprint-14'],
]);
await expect(
run(new BulkUpdateIssuesCommand('dev', ids, {})),
).rejects.toThrow(/Nothing to change/);
await expect(issues.bulkUpdate('dev', ids, {})).rejects.toThrow(
/Nothing to change/,
);
});
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
@@ -850,7 +852,7 @@ describe('tracker commands (event sourced)', () => {
});
it('duplicates what a ticket says, not where it stands', async () => {
const { run, rehydrator } = await setup();
const { issues, run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const original = await run(
new CreateIssueCommand(
@@ -869,7 +871,7 @@ describe('tracker commands (event sourced)', () => {
);
await run(new AddChecklistItemCommand('dev', original.id, 'Reproducir'));
const copy = await run(new DuplicateIssueCommand('qa', original.id));
const copy = await issues.duplicate('qa', original.id);
expect(copy.key).not.toBe(original.key);
const issue = await rehydrator.rehydrate(copy.id, Issue);
expect(issue).toMatchObject({
@@ -884,9 +886,9 @@ describe('tracker commands (event sourced)', () => {
expect(issue.checklist.map((s) => [s.text, s.done])).toEqual([
['Reproducir', false],
]);
await expect(
run(new DuplicateIssueCommand('pm', original.id)),
).rejects.toThrow(ForbiddenError);
await expect(issues.duplicate('pm', original.id)).rejects.toThrow(
ForbiddenError,
);
});
it('takes reactions from members, once each, viewers included', async () => {
@@ -939,7 +941,7 @@ describe('tracker commands (event sourced)', () => {
});
it('turns a proposal into a ticket, only where the project takes them', async () => {
const { run, rehydrator, projectViews } = await setup();
const { run, rehydrator, projectViews, projects } = await setup();
const projectId = await projectWithTeam(run);
const view = {
id: projectId,
@@ -949,14 +951,12 @@ describe('tracker commands (event sourced)', () => {
};
projectViews.set(projectId, view);
const propose = () =>
run(
new SubmitProposalCommand(
projects.propose(
'ts',
'BUG',
'No carga en Firefox',
'Pantalla en blanco',
'Lucía',
),
);
await expect(propose()).rejects.toThrow(ForbiddenError);

View File

@@ -1,61 +0,0 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AddChecklistItemCommand } from './change-checklist.command';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { WriteAccess } from './write-access';
/**
* Copies what a ticket says (type, title, description, area, priority,
* labels, Figma, estimate, dates, parent and checklist steps, unticked),
* not where it stands: the copy starts in the first status, unassigned,
* out of sprints and releases, without comments or files. Created through
* the usual command, so the same rules and events apply.
*/
@CommandHandler(DuplicateIssueCommand)
export class DuplicateIssueCommandHandler
implements ICommandHandler<DuplicateIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: DuplicateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const copy: CommandResult = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
issue.projectId,
issue.type,
`${issue.title} (copia)`.slice(0, 255),
{
description: issue.description,
area: issue.area,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
},
null,
null,
issue.parentId,
null,
// The original's text is the content, not the type's template.
false,
),
);
let result = copy;
for (const step of issue.checklist) {
result = await this.commandBus.execute(
new AddChecklistItemCommand(command.actorId, copy.id, step.text),
);
}
return { ...result, key: copy.key };
}
}

View File

@@ -1,12 +0,0 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** A new ticket like this one, to start from: the copy's key in the result. */
export class DuplicateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}

View File

@@ -6,11 +6,8 @@ export * from './assign-issue.command';
export * from './change-issue-status.command';
export * from './change-project-member-role.command';
export * from './create-issue.command';
export * from './duplicate-issue.command';
export * from './react-to-comment.command';
export * from './vote-issue.command';
export * from './submit-proposal.command';
export { PROPOSAL_LABEL } from './submit-proposal.command-handler';
export * from './create-project.command';
export * from './create-release.command';
export * from './delete-comment.command';
@@ -40,5 +37,4 @@ export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';
export * from './write-release-notes.command';
export * from './bulk-update-issues';
export * from './trash';

View File

@@ -1,82 +0,0 @@
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { ProposalKind, SubmitProposalCommand } from './submit-proposal.command';
import { WriteAccess } from './write-access';
/** The label every proposal carries, for the team to find and sort them. */
export const PROPOSAL_LABEL = 'propuesta';
/** Preferred ticket type of each kind, when the project has it active. */
const TYPE_OF: Record<ProposalKind, string> = { BUG: 'BUG', IDEA: 'FEATURE' };
/**
* Only a public project that takes proposals accepts one. It is opened by
* TaskStream (nobody signed in), in the first status, as a Bug or a Feature
* (or the first active type if the project has neither), and signed with
* the name they gave, if any.
*/
@CommandHandler(SubmitProposalCommand)
export class SubmitProposalCommandHandler
implements ICommandHandler<SubmitProposalCommand>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess,
private readonly commandBus: CommandBus,
) {}
async execute(command: SubmitProposalCommand): Promise<CommandResult> {
const found =
(await this.findProjects.findById(command.project)) ??
(await this.findProjects.findByKey(command.project.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', command.project);
if (!found.acceptsProposals)
throw new ForbiddenError(`${found.key} does not take proposals`);
const title = command.title.trim();
assertThat(
title.length >= 3 && title.length <= 150,
'A proposal needs a title of 3 to 150 characters',
);
const text = command.description.trim();
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
const project = await this.access.project(found.id);
const active = project.settings.issueTypes.filter((t) => !t.archived);
const type =
active.find((t) => t.id === TYPE_OF[command.kind])?.id ?? active[0]?.id;
const author = command.author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${author ? ` por ${author}` : ''}._`;
return this.commandBus.execute(
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
project.id,
type,
title,
{
description: text ? `${text}\n\n${signature}` : signature,
labels: [PROPOSAL_LABEL],
priority: IssuePriority.MEDIUM,
},
null,
null,
null,
null,
// What they wrote is the content, not the type's template.
false,
),
);
}
}

View File

@@ -1,22 +0,0 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export type ProposalKind = 'BUG' | 'IDEA';
/**
* A bug report or an idea from anyone, through a public project's form:
* it becomes a ticket labelled `propuesta`, for the team to look at.
*/
export class SubmitProposalCommand extends Command<CommandResult> {
constructor(
/** Project id or key. */
public readonly project: string,
public readonly kind: ProposalKind,
public readonly title: string,
public readonly description: string,
/** How they sign it, if they want to: shown in the ticket. */
public readonly author: string | null,
) {
super();
}
}

View File

@@ -1,13 +1,5 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { DeleteIssueCommand } from './delete-issue.command';
import { WriteAccess } from './write-access';
/** Days a ticket stays in the trash before it is deleted for good. */
@@ -31,13 +23,6 @@ export class RestoreIssueCommand extends Command<CommandResult> {
}
}
/** Scheduler only: deletes for good what went to the trash before `before`. */
export class EmptyTrashCommand extends Command<number> {
constructor(public readonly before: string) {
super();
}
}
@CommandHandler(TrashIssueCommand)
export class TrashIssueCommandHandler
implements ICommandHandler<TrashIssueCommand>
@@ -73,31 +58,7 @@ export class RestoreIssueCommandHandler
}
}
/**
* Each deletion is the usual one: comments purged, links dropped and
* subtasks back to the top, by the sagas.
*/
@CommandHandler(EmptyTrashCommand)
export class EmptyTrashCommandHandler
implements ICommandHandler<EmptyTrashCommand>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: EmptyTrashCommand): Promise<number> {
const old = await this.findIssues.findTrashedBefore(command.before);
for (const issue of old)
await this.commandBus.execute(
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
);
return old.length;
}
}
export const TRASH_HANDLERS = [
TrashIssueCommandHandler,
RestoreIssueCommandHandler,
EmptyTrashCommandHandler,
];

View File

@@ -1,14 +1,17 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
ActivityReadModel,
EpicReadModel,
IssueDetails,
IssueLinkType,
IssuePriority,
IssueReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
import { VoteIssueCommand } from './commands/vote-issue.command';
import {
AttachFileCommand,
@@ -19,13 +22,12 @@ import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command';
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
import { SetIssueSprintCommand } from './commands/sprint.commands';
import { WriteAccess } from './commands/write-access';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { GetTrashQuery } from './queries/get-trash.query';
import {
BulkChange,
BulkResult,
BulkUpdateIssuesCommand,
} from './commands/bulk-update-issues';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
@@ -62,6 +64,10 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
parentId?: string | null;
/** Imports only: start in this (active) status instead of the first one. */
initialStatus?: string | null;
/** Fill in the type's template (false when the content comes with it). */
useTemplate?: boolean;
}
export interface IssueLinkInput {
@@ -75,6 +81,8 @@ export class IssuesService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
@@ -85,6 +93,8 @@ export class IssuesService {
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate,
...details
} = input;
return this.commandBus.execute(
@@ -97,6 +107,8 @@ export class IssuesService {
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate ?? true,
),
);
}
@@ -241,15 +253,55 @@ export class IssuesService {
);
}
/** The same change on several tickets; each one may succeed or not. */
bulkUpdate(
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
async bulkUpdate(
actorId: string,
issueIds: string[],
change: BulkChange,
): Promise<BulkResult> {
return this.commandBus.execute(
new BulkUpdateIssuesCommand(actorId, issueIds, change),
const ids = [...new Set(issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.changeStatus(actorId, issueId, change.status);
if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined)
await this.commandBus.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.setRelease(actorId, issueId, change.releaseId);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.update(actorId, issueId, details);
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/** To the trash (whoever opened it, or a manager); back with `restore`. */
@@ -257,6 +309,22 @@ export class IssuesService {
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
}
/** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> {
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
}
/**
* Deletes for good what went to the trash before `before`; how many. Each
* deletion is the usual one: comments purged, links dropped and subtasks
* back to the top, by the sagas.
*/
async emptyTrash(before: string): Promise<number> {
const old = await this.findIssues.findTrashedBefore(before);
for (const issue of old) await this.delete(SYSTEM_ACTOR_ID, issue.id);
return old.length;
}
restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
}
@@ -266,9 +334,35 @@ export class IssuesService {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
}
/** A new ticket like this one (what it says, not where it stands). */
duplicate(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId));
/**
* A new ticket like this one: what it says (type, title, description,
* area, priority, labels, Figma, estimate, dates, parent and checklist
* steps, unticked), not where it stands. The copy starts in the first
* status, unassigned, out of sprints and releases, without comments or
* files; the copy's key comes in the result.
*/
async duplicate(actorId: string, issueId: string): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(issueId, actorId);
const copy = await this.create(actorId, {
projectId: issue.projectId,
type: issue.type,
title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description,
area: issue.area,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
parentId: issue.parentId,
// The original's text is the content, not the type's template.
useTemplate: false,
});
let result = copy;
for (const step of issue.checklist)
result = await this.addChecklistItem(actorId, copy.id, step.text);
return { ...result, key: copy.key };
}
trashed(

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders';
import {
MarkNotificationsReadCommand,
UpdateNotificationSettingsCommand,
@@ -36,6 +37,11 @@ export class NotificationsService {
}
/** `ids` omitted: everything. */
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
sendDueReminders(day: string): Promise<number> {
return this.commandBus.execute(new SendDueRemindersCommand(day));
}
markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),

View File

@@ -16,7 +16,7 @@ import {
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { PROPOSAL_LABEL } from '../proposals';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';

View File

@@ -1,18 +1,25 @@
import {
ProposalKind,
SubmitProposalCommand,
} from './commands/submit-proposal.command';
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectAnalyticsReadModel,
ProjectReadModel,
ProjectRole,
ProjectSettings,
ProjectStatsReadModel,
ReleaseReadModel,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { CreateIssueCommand } from './commands/create-issue.command';
import { WriteAccess } from './commands/write-access';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { PROPOSAL_LABEL, PROPOSAL_TYPE, ProposalKind } from './proposals';
import { AddProjectMemberCommand } from './commands/add-project-member.command';
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
import { CommandResult } from './commands/command-result';
@@ -35,22 +42,68 @@ export class ProjectsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess,
) {}
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
/** A bug report or an idea from anyone, for a public project that takes them. */
propose(
project: string,
/**
* A bug report or an idea from anyone, for a public project that takes
* them. It becomes a ticket labelled «propuesta», opened by TaskStream
* (nobody signed in) in the first status, as a Bug or a Feature (or the
* first active type if the project has neither), signed with the name
* they gave, if any.
*/
async propose(
projectIdOrKey: string,
kind: ProposalKind,
title: string,
description: string,
author: string | null,
): Promise<CommandResult> {
const found =
(await this.findProjects.findById(projectIdOrKey)) ??
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
if (!found.acceptsProposals)
throw new ForbiddenError(`${found.key} does not take proposals`);
const cleanTitle = title.trim();
assertThat(
cleanTitle.length >= 3 && cleanTitle.length <= 150,
'A proposal needs a title of 3 to 150 characters',
);
const text = description.trim();
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
const project = await this.access.project(found.id);
const active = project.settings.issueTypes.filter((t) => !t.archived);
const type =
active.find((t) => t.id === PROPOSAL_TYPE[kind])?.id ?? active[0]?.id;
const signer = author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
return this.commandBus.execute(
new SubmitProposalCommand(project, kind, title, description, author),
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
project.id,
type,
cleanTitle,
{
description: text ? `${text}\n\n${signature}` : signature,
labels: [PROPOSAL_LABEL],
priority: IssuePriority.MEDIUM,
},
null,
null,
null,
null,
// What they wrote is the content, not the type's template.
false,
),
);
}
/** A public project's roadmap, releases, sprint and good first tickets (no token). */
showcase(project: string): Promise<Showcase> {
return this.queryBus.execute(new GetShowcaseQuery(project));
}

View File

@@ -0,0 +1,10 @@
export type ProposalKind = 'BUG' | 'IDEA';
/** The label every proposal carries, for the team to find and sort them. */
export const PROPOSAL_LABEL = 'propuesta';
/** Preferred ticket type of each kind, when the project has it active. */
export const PROPOSAL_TYPE: Record<ProposalKind, string> = {
BUG: 'BUG',
IDEA: 'FEATURE',
};

View File

@@ -6,7 +6,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { PROPOSAL_LABEL } from '../proposals';
/** Tickets with this label are offered to newcomers on public pages. */
export const GOOD_FIRST_LABEL = 'good-first-issue';

View File

@@ -43,6 +43,7 @@ import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -97,6 +98,7 @@ const SERVICES = [
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS,
AutomationsEventHandler,
],
exports: SERVICES,
})

View File

@@ -1,31 +1,115 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { QueryBus } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommandResult } from './commands/command-result';
import { WriteAccess } from './commands/write-access';
import { IssuesService } from './issues.service';
import { FindProjectsRepository } from './ports/find-projects.repository';
import {
ExportIssuesQuery,
ImportIssuesCommand,
ImportReport,
MAX_IMPORT_MB,
} from './transfer/import-export';
import { planImport } from './transfer/transfer';
/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */
@Injectable()
export class TransferService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly issues: IssuesService,
) {}
exportCsv(viewerId: string | null, projectId: string): Promise<string> {
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
}
importCsv(
/**
* Creates the tickets of a CSV one by one (each a normal ticket creation,
* with its own events), then places them under their parents. Owners and
* admins. With `dryRun`, only tells what would happen.
*/
async importCsv(
actorId: string,
projectId: string,
csv: string,
dryRun: boolean,
): Promise<ImportReport> {
return this.commandBus.execute(
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
(await this.access.project(projectId)).assertCanManage(actorId);
if (Buffer.byteLength(csv) > MAX_IMPORT_MB * 1024 * 1024)
throw new BusinessRuleError(
`The file is larger than ${MAX_IMPORT_MB} MB`,
);
const project = await this.findProjects.findById(projectId);
if (!project) throw new BusinessRuleError('The project is not ready yet');
const plan = planImport(csv, project);
const report: ImportReport = {
format: plan.format,
dryRun: dryRun,
rows: plan.rows.length,
created: 0,
keys: [],
skipped: [...plan.skipped],
warnings: [...plan.warnings],
};
if (dryRun) return report;
const created = new Map<string, string>();
const ids = new Map<number, string>();
for (const row of plan.rows) {
try {
const result: CommandResult = await this.issues.create(actorId, {
projectId: project.id,
type: row.type,
title: row.title,
description: row.description,
priority: row.priority,
labels: row.labels,
storyPoints: row.storyPoints,
startDate: row.startDate,
dueDate: row.dueDate,
assigneeId: row.assigneeId,
initialStatus: row.status,
// The file brings the content, not the type's template.
useTemplate: false,
});
report.created++;
report.keys.push(result.key ?? result.id);
ids.set(row.line, result.id);
for (const key of row.sourceKeys) created.set(key, result.id);
if (result.key) created.set(result.key, result.id);
} catch (error) {
report.skipped.push({
line: row.line,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// Parents last: they may come after their children in the file.
for (const row of plan.rows) {
const id = ids.get(row.line);
const parent = row.parentSource
? created.get(row.parentSource)
: undefined;
if (!id || !row.parentSource) continue;
if (!parent) {
report.warnings.push({
line: row.line,
message: `Su padre (${row.parentSource}) no está en el archivo`,
});
continue;
}
try {
await this.issues.setParent(actorId, id, parent);
} catch (error) {
report.warnings.push({
line: row.line,
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
});
}
}
return report;
}
}

View File

@@ -1,25 +1,12 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { SetIssueParentCommand } from '../commands/set-issue-parent.command';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { ImportFormat, exportIssuesCsv, planImport } from './transfer';
import { ImportFormat, exportIssuesCsv } from './transfer';
/** A file is at most this many megabytes. */
export const MAX_IMPORT_MB = 5;
@@ -36,18 +23,6 @@ export interface ImportReport {
warnings: { line: number; message: string }[];
}
export class ImportIssuesCommand extends Command<ImportReport> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly csv: string,
/** Plan only: tell what would happen and create nothing. */
public readonly dryRun: boolean,
) {
super();
}
}
export class ExportIssuesQuery extends Query<string> {
constructor(
public readonly viewerId: string | null,
@@ -57,108 +32,6 @@ export class ExportIssuesQuery extends Query<string> {
}
}
/**
* Creates the tickets of a CSV one by one (each a normal ticket creation, with
* its own events), then places them under their parents. Owners and admins.
*/
@CommandHandler(ImportIssuesCommand)
export class ImportIssuesCommandHandler
implements ICommandHandler<ImportIssuesCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: ImportIssuesCommand): Promise<ImportReport> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
if (Buffer.byteLength(command.csv) > MAX_IMPORT_MB * 1024 * 1024)
throw new BusinessRuleError(
`The file is larger than ${MAX_IMPORT_MB} MB`,
);
const project = await this.findProjects.findById(command.projectId);
if (!project) throw new BusinessRuleError('The project is not ready yet');
const plan = planImport(command.csv, project);
const report: ImportReport = {
format: plan.format,
dryRun: command.dryRun,
rows: plan.rows.length,
created: 0,
keys: [],
skipped: [...plan.skipped],
warnings: [...plan.warnings],
};
if (command.dryRun) return report;
const created = new Map<string, string>();
const ids = new Map<number, string>();
for (const row of plan.rows) {
try {
const result: CommandResult = await this.commandBus.execute(
new CreateIssueCommand(
command.actorId,
project.id,
row.type,
row.title,
{
description: row.description,
priority: row.priority,
labels: row.labels,
storyPoints: row.storyPoints,
startDate: row.startDate,
dueDate: row.dueDate,
},
row.assigneeId,
null,
null,
row.status,
false,
),
);
report.created++;
report.keys.push(result.key ?? result.id);
ids.set(row.line, result.id);
for (const key of row.sourceKeys) created.set(key, result.id);
if (result.key) created.set(result.key, result.id);
} catch (error) {
report.skipped.push({
line: row.line,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// Parents last: they may come after their children in the file.
for (const row of plan.rows) {
const id = ids.get(row.line);
const parent = row.parentSource
? created.get(row.parentSource)
: undefined;
if (!id || !row.parentSource) continue;
if (!parent) {
report.warnings.push({
line: row.line,
message: `Su padre (${row.parentSource}) no está en el archivo`,
});
continue;
}
try {
await this.commandBus.execute(
new SetIssueParentCommand(command.actorId, id, parent),
);
} catch (error) {
report.warnings.push({
line: row.line,
message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`,
});
}
}
return report;
}
}
@QueryHandler(ExportIssuesQuery)
export class ExportIssuesQueryHandler
implements IQueryHandler<ExportIssuesQuery, string>
@@ -182,7 +55,4 @@ export class ExportIssuesQueryHandler
}
}
export const TRANSFER_HANDLERS = [
ImportIssuesCommandHandler,
ExportIssuesQueryHandler,
];
export const TRANSFER_HANDLERS = [ExportIssuesQueryHandler];

View File

@@ -3,11 +3,7 @@ import {
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
FindUserQuery,
FindUsersByIdsQuery,
} from '@task-stream/api/identity/application';
import { IdentityService } from '@task-stream/api/identity/application';
import {
DirectoryUser,
UserContact,
@@ -15,10 +11,13 @@ import {
UserRefs,
} from '@task-stream/api/tracker/application';
/** Anti-corruption layer: the tracker asks the identity context about users. */
/**
* Anti-corruption layer: the tracker asks the identity context about users,
* through its service (the identity module is global for this).
*/
@Injectable()
export class IdentityUserDirectory extends UserDirectory {
constructor(private readonly queryBus: QueryBus) {
constructor(private readonly identity: IdentityService) {
super();
}
@@ -26,9 +25,7 @@ export class IdentityUserDirectory extends UserDirectory {
userId: string,
accessToken?: string,
): Promise<DirectoryUser | null> {
const user = await this.queryBus.execute(
new FindUserQuery(userId, accessToken),
);
const user = await this.identity.findUser(userId, accessToken);
return user
? { id: user.id, name: user.name, isActive: user.isActive }
: null;
@@ -37,7 +34,7 @@ export class IdentityUserDirectory extends UserDirectory {
async contacts(userIds: string[]): Promise<UserContact[]> {
const wanted = [...new Set(userIds.filter(Boolean))];
if (!wanted.length) return [];
const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted));
const users = await this.identity.findUsers(wanted);
return users.map((u) => ({
id: u.id,
name: u.name,
@@ -47,9 +44,7 @@ export class IdentityUserDirectory extends UserDirectory {
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
const users = wanted.length
? await this.queryBus.execute(new FindUsersByIdsQuery(wanted))
: [];
const users = wanted.length ? await this.identity.findUsers(wanted) : [];
const names = new Map(users.map((u) => [u.id, u.name]));
const of = (id: string) => ({
id,

View File

@@ -15,7 +15,8 @@ import {
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema, Types } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Types } from 'mongoose';
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
@@ -86,65 +87,105 @@ export const ReadModels = {
AutomationRun: 'AutomationRun',
} as const;
const userRef = { _id: false, id: String, name: String };
// `type` must be wrapped: a bare `type` key would declare the whole subdocument a String.
const link = {
_id: false,
type: { type: String },
direction: String,
issueId: String,
issueKey: String,
};
const loose = { strict: false, versionKey: false } as const;
/** The domain id: what every query looks a document up by. */
const domainId = { type: String, required: true, unique: true };
/*
* Every collection keeps the whole read model (`strict: false`); the props
* declared here are the ones with an index or a shape worth stating. `id` is
* the domain id (unique, what every query goes by); `_id` is Mongo's own.
*/
const LOOSE = { strict: false, versionKey: false } as const;
export const ProjectSchema = new Schema<ProjectDoc>(
{
id: domainId,
key: { type: String, unique: true },
isPublic: { type: Boolean, index: true },
memberIds: { type: [String], index: true },
owner: userRef,
members: [{ _id: false, user: userRef, role: String }],
version: Number,
},
{ ...loose, collection: 'projects_view' },
);
@Schema({ _id: false })
class UserRefProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) name!: string;
}
const UserRefSchema = SchemaFactory.createForClass(UserRefProp);
export const IssueSchema = new Schema<IssueDoc>(
{
id: domainId,
projectId: { type: String, index: true },
key: { type: String, unique: true },
status: String,
statusCategory: String,
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
sprint: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
parent: { _id: false, id: String, key: String, title: String },
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
attachments: [
{
_id: false,
id: String,
fileName: String,
mimeType: String,
size: Number,
uploadedBy: userRef,
uploadedAt: String,
commentId: String,
},
],
labels: [String],
version: Number,
},
{ ...loose, collection: 'issues_view' },
);
@Schema({ _id: false })
class MemberProp {
@Prop({ type: UserRefSchema }) user!: UserRefProp;
@Prop({ type: String }) role!: string;
}
@Schema({ _id: false })
class LinkProp {
@Prop({ type: String }) type!: string;
@Prop({ type: String }) direction!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) issueKey!: string;
}
const LinkSchema = SchemaFactory.createForClass(LinkProp);
@Schema({ _id: false })
class NamedRefProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) name!: string;
}
@Schema({ _id: false })
class ParentProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) key!: string;
@Prop({ type: String }) title!: string;
}
@Schema({ _id: false })
class ChecklistItemProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) text!: string;
@Prop({ type: Boolean }) done!: boolean;
}
@Schema({ _id: false })
class AttachmentProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) fileName!: string;
@Prop({ type: String }) mimeType!: string;
@Prop({ type: Number }) size!: number;
@Prop({ type: UserRefSchema }) uploadedBy!: UserRefProp;
@Prop({ type: String }) uploadedAt!: string;
@Prop({ type: String }) commentId!: string;
}
@Schema({ ...LOOSE, collection: 'projects_view' })
class ProjectDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, unique: true }) key!: string;
@Prop({ type: Boolean, index: true }) isPublic!: boolean;
@Prop({ type: [String], index: true }) memberIds!: string[];
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
@Prop({ type: [SchemaFactory.createForClass(MemberProp)] })
members!: MemberProp[];
@Prop({ type: Number }) version!: number;
}
export const ProjectSchema = SchemaFactory.createForClass(ProjectDocument);
@Schema({ ...LOOSE, collection: 'issues_view' })
class IssueDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: String, unique: true }) key!: string;
@Prop({ type: String }) status!: string;
@Prop({ type: String }) statusCategory!: string;
@Prop({ type: UserRefSchema }) reporter!: UserRefProp;
@Prop({ type: UserRefSchema }) assignee!: UserRefProp | null;
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
release!: NamedRefProp | null;
@Prop({ type: SchemaFactory.createForClass(NamedRefProp) })
sprint!: NamedRefProp | null;
@Prop({ type: [LinkSchema] }) outwardLinks!: LinkProp[];
@Prop({ type: [LinkSchema] }) inwardLinks!: LinkProp[];
@Prop({ type: [UserRefSchema] }) watchers!: UserRefProp[];
@Prop({ type: SchemaFactory.createForClass(ParentProp) })
parent!: ParentProp | null;
@Prop({ type: [SchemaFactory.createForClass(ChecklistItemProp)] })
checklist!: ChecklistItemProp[];
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
attachments!: AttachmentProp[];
@Prop({ type: [String] }) labels!: string[];
@Prop({ type: Number }) version!: number;
}
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
@@ -156,115 +197,137 @@ IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ trashedAt: 1 }, { sparse: true });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
{
id: domainId,
issueId: { type: String, index: true },
replyTo: { type: String, index: true },
author: userRef,
version: Number,
},
{ ...loose, collection: 'comments_view' },
);
@Schema({ ...LOOSE, collection: 'comments_view' })
class CommentDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) issueId!: string;
@Prop({ type: String, index: true }) replyTo!: string | null;
@Prop({ type: UserRefSchema }) author!: UserRefProp;
@Prop({ type: Number }) version!: number;
}
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
CommentSchema.index({ issueId: 1, createdAt: 1 });
export const ReleaseSchema = new Schema<ReleaseDoc>(
{ id: domainId, projectId: { type: String, index: true }, version: Number },
{ ...loose, collection: 'releases_view' },
);
@Schema({ ...LOOSE, collection: 'releases_view' })
class ReleaseDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Number }) version!: number;
}
export const ReleaseSchema = SchemaFactory.createForClass(ReleaseDocument);
export const SprintSchema = new Schema<SprintDoc>(
{
id: domainId,
projectId: { type: String, index: true },
status: String,
version: Number,
},
{ ...loose, collection: 'sprints_view' },
);
@Schema({ ...LOOSE, collection: 'sprints_view' })
class SprintDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: String }) status!: string;
@Prop({ type: Number }) version!: number;
}
export const SprintSchema = SchemaFactory.createForClass(SprintDocument);
export const ActivitySchema = new Schema<ActivityDoc>(
{
id: domainId,
projectId: String,
issueId: String,
actor: userRef,
},
{ ...loose, collection: 'activity' },
);
@Schema({ ...LOOSE, collection: 'activity' })
class ActivityDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: String }) issueId!: string | null;
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
}
export const ActivitySchema = SchemaFactory.createForClass(ActivityDocument);
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
/** Every status and release step of every ticket (statistics). */
export const TimelineSchema = new Schema<TimelineDoc>(
{ id: domainId, projectId: String, issueId: String, at: String },
{ ...loose, collection: 'issue_timeline' },
);
@Schema({ ...LOOSE, collection: 'issue_timeline' })
class TimelineDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) at!: string;
}
export const TimelineSchema = SchemaFactory.createForClass(TimelineDocument);
TimelineSchema.index({ projectId: 1, at: 1 });
export const NotificationSchema = new Schema<NotificationDoc>(
{
id: domainId,
userId: String,
readAt: String,
createdAt: String,
actor: userRef,
},
{ ...loose, collection: 'notifications' },
);
@Schema({ ...LOOSE, collection: 'notifications' })
class NotificationDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) readAt!: string | null;
@Prop({ type: String }) createdAt!: string;
@Prop({ type: UserRefSchema }) actor!: UserRefProp;
}
export const NotificationSchema =
SchemaFactory.createForClass(NotificationDocument);
NotificationSchema.index({ userId: 1, createdAt: -1 });
NotificationSchema.index({ userId: 1, readAt: 1 });
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ id: domainId, email: Boolean },
{ ...loose, collection: 'notification_settings' },
@Schema({ ...LOOSE, collection: 'notification_settings' })
class NotificationSettingsDocument {
/** The user's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: Boolean }) email!: boolean;
}
export const NotificationSettingsSchema = SchemaFactory.createForClass(
NotificationSettingsDocument,
);
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
{
id: domainId,
projectId: String,
owner: userRef,
name: String,
shared: Boolean,
createdAt: String,
},
{ ...loose, collection: 'saved_filters' },
);
@Schema({ ...LOOSE, collection: 'saved_filters' })
class SavedFilterDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) projectId!: string;
@Prop({ type: UserRefSchema }) owner!: UserRefProp;
@Prop({ type: String }) name!: string;
@Prop({ type: Boolean }) shared!: boolean;
@Prop({ type: String }) createdAt!: string;
}
export const SavedFilterSchema =
SchemaFactory.createForClass(SavedFilterDocument);
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ id: domainId, repositories: [Object], secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' },
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: [Object] }) repositories!: GitRepositoryDoc[];
@Prop({ type: String }) secret!: string;
@Prop({ type: String }) createdAt!: string;
}
export const GitIntegrationSchema = SchemaFactory.createForClass(
GitIntegrationDocument,
);
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
{
id: domainId,
projectId: { type: String, index: true },
active: Boolean,
events: [String],
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'outgoing_webhooks' },
@Schema({ ...LOOSE, collection: 'outgoing_webhooks' })
class OutgoingWebhookDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Boolean }) active!: boolean;
@Prop({ type: [String] }) events!: string[];
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
@Prop({ type: String }) createdAt!: string;
}
export const OutgoingWebhookSchema = SchemaFactory.createForClass(
OutgoingWebhookDocument,
);
export const AutomationSchema = new Schema<AutomationDoc>(
{
id: domainId,
projectId: { type: String, index: true },
active: Boolean,
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'automations' },
);
@Schema({ ...LOOSE, collection: 'automations' })
class AutomationDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) projectId!: string;
@Prop({ type: Boolean }) active!: boolean;
@Prop({ type: UserRefSchema }) createdBy!: UserRefProp;
@Prop({ type: String }) createdAt!: string;
}
export const AutomationSchema =
SchemaFactory.createForClass(AutomationDocument);
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
{ id: domainId, at: Date },
{ ...loose, collection: 'automation_runs' },
@Schema({ ...LOOSE, collection: 'automation_runs' })
class AutomationRunDocument {
/** `ruleId:eventKey`: a rule runs once per event. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: Date }) at!: Date;
}
export const AutomationRunSchema = SchemaFactory.createForClass(
AutomationRunDocument,
);
// Redeliveries happen within minutes; a month of memory is plenty.
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { MongooseModule } from '@nestjs/mongoose';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -54,8 +54,6 @@ import {
} from './mongo/upsert-materialized.repositories';
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { RankBackfill } from './mongo/rank.backfill';
import { ReminderScheduler } from './notifications/reminder.scheduler';
import { TrashScheduler } from './notifications/trash.scheduler';
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -158,13 +156,16 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
],
providers: [
...PORTS,
StatusCategoryBackfill,
RankBackfill,
ReminderScheduler,
TrashScheduler,
],
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
exports: PORTS.map((port) => port.provide),
})
export class TrackerInfrastructureModule {}
export class TrackerInfrastructureModule {
/**
* The user directory asks the identity context through its service, so
* the tracker needs that context's application module: the composition
* root passes the same one the identity context is built with.
*/
static withIdentity(identity: Type | DynamicModule): DynamicModule {
return { module: TrackerInfrastructureModule, imports: [identity] };
}
}

View File

@@ -5,8 +5,7 @@ import {
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import { SendDueRemindersCommand } from '@task-stream/api/tracker/application';
import { NotificationsService } from '@task-stream/api/tracker/application';
const HOUR = 3_600_000;
@@ -34,7 +33,7 @@ export class ReminderScheduler
private timers: ReturnType<typeof setTimeout>[] = [];
constructor(
private readonly commandBus: CommandBus,
private readonly notifications: NotificationsService,
private readonly config: ConfigService,
) {}
@@ -56,8 +55,8 @@ export class ReminderScheduler
const timeZone =
this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid';
try {
const sent: number = await this.commandBus.execute(
new SendDueRemindersCommand(dayIn(new Date(), timeZone)),
const sent = await this.notifications.sendDueReminders(
dayIn(new Date(), timeZone),
);
if (sent) this.logger.log(`Due-date reminders checked (${sent})`);
} catch (error) {

View File

@@ -5,9 +5,8 @@ import {
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import {
EmptyTrashCommand,
IssuesService,
TRASH_DAYS,
} from '@task-stream/api/tracker/application';
@@ -25,7 +24,7 @@ export class TrashScheduler
private timers: ReturnType<typeof setTimeout>[] = [];
constructor(
private readonly commandBus: CommandBus,
private readonly issues: IssuesService,
private readonly config: ConfigService,
) {}
@@ -46,9 +45,7 @@ export class TrashScheduler
Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS;
const before = new Date(Date.now() - days * 24 * HOUR).toISOString();
try {
const deleted: number = await this.commandBus.execute(
new EmptyTrashCommand(before),
);
const deleted = await this.issues.emptyTrash(before);
if (deleted)
this.logger.log(`Deleted ${deleted} ticket(s) from the trash`);
} catch (error) {

View File

@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller';
import { ExploreController } from './http/explore.controller';
import { ProposalLimiter } from './http/proposal-limiter';
import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler';
import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller';
@@ -65,6 +67,9 @@ import { AutomationResolver } from './graphql/automation.resolver';
providers: [
Refs,
ProposalLimiter,
// Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler,
TrashScheduler,
ProjectResolver,
IssueResolver,
CommentResolver,