diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 88505fa..4a867a6 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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({ diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index 5987c21..61996e4 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -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 diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index f4772b7..1a40c51 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 699a479..edea2f5 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index c598e51..d408268 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/automations.service.ts b/libs/api/tracker/application/src/lib/automations.service.ts index 6e641ce..a8aabb0 100644 --- a/libs/api/tracker/application/src/lib/automations.service.ts +++ b/libs/api/tracker/application/src/lib/automations.service.ts @@ -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 { + return this.commandBus.execute( + new RunAutomationCommand(automation, issueId), + ); + } + delete(actorId: string, automationId: string): Promise { return this.commandBus.execute( new DeleteAutomationCommand(actorId, automationId), diff --git a/libs/api/tracker/application/src/lib/automations/automations.event-handler.ts b/libs/api/tracker/application/src/lib/automations/automations.event-handler.ts new file mode 100644 index 0000000..39c6eab --- /dev/null +++ b/libs/api/tracker/application/src/lib/automations/automations.event-handler.ts @@ -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 { + 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); + } + } +} diff --git a/libs/api/tracker/application/src/lib/automations/automations.ts b/libs/api/tracker/application/src/lib/automations/automations.ts index 40ccdf8..9f3944d 100644 --- a/libs/api/tracker/application/src/lib/automations/automations.ts +++ b/libs/api/tracker/application/src/lib/automations/automations.ts @@ -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 { - 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, ]; diff --git a/libs/api/tracker/application/src/lib/bulk-change.ts b/libs/api/tracker/application/src/lib/bulk-change.ts new file mode 100644 index 0000000..53973f3 --- /dev/null +++ b/libs/api/tracker/application/src/lib/bulk-change.ts @@ -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 }[]; +} diff --git a/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts b/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts deleted file mode 100644 index b71ce13..0000000 --- a/libs/api/tracker/application/src/lib/commands/bulk-update-issues.ts +++ /dev/null @@ -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 { - constructor( - public readonly actorId: string, - public readonly issueIds: string[], - public readonly change: BulkChange, - ) { - super(); - } -} - -@CommandHandler(BulkUpdateIssuesCommand) -export class BulkUpdateIssuesCommandHandler - implements ICommandHandler -{ - constructor( - private readonly commandBus: CommandBus, - private readonly access: WriteAccess, - ) {} - - async execute(command: BulkUpdateIssuesCommand): Promise { - 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 { - return this.commandBus.execute(command as never); - } -} diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 3975106..eadf1cc 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -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, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index f2bd045..158e4ec 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -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]], { - status: 'IN_REVIEW', - assigneeId: 'qa', - addLabels: ['sprint-14'], - }), - )) as unknown as BulkResult; + const result = await issues.bulkUpdate('dev', [...ids, ids[1]], { + status: 'IN_REVIEW', + assigneeId: 'qa', + addLabels: ['sprint-14'], + }); 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( - 'ts', - 'BUG', - 'No carga en Firefox', - 'Pantalla en blanco', - 'Lucía', - ), + projects.propose( + 'ts', + 'BUG', + 'No carga en Firefox', + 'Pantalla en blanco', + 'Lucía', ); await expect(propose()).rejects.toThrow(ForbiddenError); diff --git a/libs/api/tracker/application/src/lib/commands/duplicate-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/duplicate-issue.command-handler.ts deleted file mode 100644 index afc58d1..0000000 --- a/libs/api/tracker/application/src/lib/commands/duplicate-issue.command-handler.ts +++ /dev/null @@ -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 -{ - constructor( - private readonly access: WriteAccess, - private readonly commandBus: CommandBus, - ) {} - - async execute(command: DuplicateIssueCommand): Promise { - 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 }; - } -} diff --git a/libs/api/tracker/application/src/lib/commands/duplicate-issue.command.ts b/libs/api/tracker/application/src/lib/commands/duplicate-issue.command.ts deleted file mode 100644 index f7dcc89..0000000 --- a/libs/api/tracker/application/src/lib/commands/duplicate-issue.command.ts +++ /dev/null @@ -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 { - constructor( - public readonly actorId: string, - public readonly issueId: string, - ) { - super(); - } -} diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 45dc9cf..4d7ff77 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/commands/submit-proposal.command-handler.ts b/libs/api/tracker/application/src/lib/commands/submit-proposal.command-handler.ts deleted file mode 100644 index d569cf4..0000000 --- a/libs/api/tracker/application/src/lib/commands/submit-proposal.command-handler.ts +++ /dev/null @@ -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 = { 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 -{ - constructor( - private readonly findProjects: FindProjectsRepository, - private readonly access: WriteAccess, - private readonly commandBus: CommandBus, - ) {} - - async execute(command: SubmitProposalCommand): Promise { - 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, - ), - ); - } -} diff --git a/libs/api/tracker/application/src/lib/commands/submit-proposal.command.ts b/libs/api/tracker/application/src/lib/commands/submit-proposal.command.ts deleted file mode 100644 index 3e63c41..0000000 --- a/libs/api/tracker/application/src/lib/commands/submit-proposal.command.ts +++ /dev/null @@ -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 { - 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(); - } -} diff --git a/libs/api/tracker/application/src/lib/commands/trash.ts b/libs/api/tracker/application/src/lib/commands/trash.ts index c65f9fa..e9fb2b7 100644 --- a/libs/api/tracker/application/src/lib/commands/trash.ts +++ b/libs/api/tracker/application/src/lib/commands/trash.ts @@ -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 { } } -/** Scheduler only: deletes for good what went to the trash before `before`. */ -export class EmptyTrashCommand extends Command { - constructor(public readonly before: string) { - super(); - } -} - @CommandHandler(TrashIssueCommand) export class TrashIssueCommandHandler implements ICommandHandler @@ -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 -{ - constructor( - private readonly findIssues: FindIssuesRepository, - private readonly commandBus: CommandBus, - ) {} - - async execute(command: EmptyTrashCommand): Promise { - 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, ]; diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 42d09e6..6e39805 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -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 { 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 { @@ -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 { - 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 { + 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 { + 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 { 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 { - 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 { + 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( diff --git a/libs/api/tracker/application/src/lib/notifications.service.ts b/libs/api/tracker/application/src/lib/notifications.service.ts index b72dcc6..d55a105 100644 --- a/libs/api/tracker/application/src/lib/notifications.service.ts +++ b/libs/api/tracker/application/src/lib/notifications.service.ts @@ -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 { + return this.commandBus.execute(new SendDueRemindersCommand(day)); + } + markRead(userId: string, ids?: string[] | null): Promise { return this.commandBus.execute( new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 79c1f31..97454ee 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index 6f91655..b8ad70f 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -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 { + 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 { return this.queryBus.execute(new GetShowcaseQuery(project)); } diff --git a/libs/api/tracker/application/src/lib/proposals.ts b/libs/api/tracker/application/src/lib/proposals.ts new file mode 100644 index 0000000..b13b4d0 --- /dev/null +++ b/libs/api/tracker/application/src/lib/proposals.ts @@ -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 = { + BUG: 'BUG', + IDEA: 'FEATURE', +}; diff --git a/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts b/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts index 1b20f4d..d48e04e 100644 --- a/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts +++ b/libs/api/tracker/application/src/lib/queries/get-showcase.query.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 364cf3d..5308f57 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -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, }) diff --git a/libs/api/tracker/application/src/lib/transfer.service.ts b/libs/api/tracker/application/src/lib/transfer.service.ts index 4eca8a9..baba14e 100644 --- a/libs/api/tracker/application/src/lib/transfer.service.ts +++ b/libs/api/tracker/application/src/lib/transfer.service.ts @@ -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 { 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 { - 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(); + const ids = new Map(); + 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; } } diff --git a/libs/api/tracker/application/src/lib/transfer/import-export.ts b/libs/api/tracker/application/src/lib/transfer/import-export.ts index 86c2379..105b92b 100644 --- a/libs/api/tracker/application/src/lib/transfer/import-export.ts +++ b/libs/api/tracker/application/src/lib/transfer/import-export.ts @@ -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 { - 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 { constructor( public readonly viewerId: string | null, @@ -57,108 +32,6 @@ export class ExportIssuesQuery extends Query { } } -/** - * 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 -{ - constructor( - private readonly access: WriteAccess, - private readonly findProjects: FindProjectsRepository, - private readonly commandBus: CommandBus, - ) {} - - async execute(command: ImportIssuesCommand): Promise { - (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(); - const ids = new Map(); - 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 @@ -182,7 +55,4 @@ export class ExportIssuesQueryHandler } } -export const TRANSFER_HANDLERS = [ - ImportIssuesCommandHandler, - ExportIssuesQueryHandler, -]; +export const TRANSFER_HANDLERS = [ExportIssuesQueryHandler]; diff --git a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts index 0566d0e..bd00442 100644 --- a/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts +++ b/libs/api/tracker/infrastructure/src/lib/identity/identity-user-directory.ts @@ -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 { - 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 { 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 { 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, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 2e3f79b..ea7e733 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -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( - { - 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( - { - 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( - { - 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( - { 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( - { - 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( - { - 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( - { 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( - { - 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( - { 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( - { - 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( - { 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( - { - 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( - { - 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( - { 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 }); diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index d809c4d..ae6db9f 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -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] }; + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts b/libs/api/tracker/presenters/src/lib/schedulers/reminder.scheduler.spec.ts similarity index 100% rename from libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts rename to libs/api/tracker/presenters/src/lib/schedulers/reminder.scheduler.spec.ts diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts b/libs/api/tracker/presenters/src/lib/schedulers/reminder.scheduler.ts similarity index 85% rename from libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts rename to libs/api/tracker/presenters/src/lib/schedulers/reminder.scheduler.ts index a3bd70a..43c4cc3 100644 --- a/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts +++ b/libs/api/tracker/presenters/src/lib/schedulers/reminder.scheduler.ts @@ -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[] = []; 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('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) { diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts b/libs/api/tracker/presenters/src/lib/schedulers/trash.scheduler.ts similarity index 87% rename from libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts rename to libs/api/tracker/presenters/src/lib/schedulers/trash.scheduler.ts index 86dabe0..13d3152 100644 --- a/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts +++ b/libs/api/tracker/presenters/src/lib/schedulers/trash.scheduler.ts @@ -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[] = []; 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) { diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 7ecf3d2..e0c49f9 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -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,