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:
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
23
libs/api/tracker/application/src/lib/bulk-change.ts
Normal file
23
libs/api/tracker/application/src/lib/bulk-change.ts
Normal 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 }[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
10
libs/api/tracker/application/src/lib/proposals.ts
Normal file
10
libs/api/tracker/application/src/lib/proposals.ts
Normal 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',
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user