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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user