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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,10 +75,8 @@ import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { DuplicateIssueCommand } from './duplicate-issue.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { UpdateProjectCommand } from './update-project.command';
import { SubmitProposalCommand } from './submit-proposal.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
@@ -87,6 +85,8 @@ import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { IssuesService } from '../issues.service';
import { ProjectsService } from '../projects.service';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { CreateProjectCommand } from './create-project.command';
@@ -96,7 +96,6 @@ import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -131,6 +130,9 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
// What composes several commands lives in the services.
IssuesService,
ProjectsService,
...GIT_HANDLERS,
RunAutomationCommandHandler,
...TRACKER_FACTORIES,
@@ -196,6 +198,8 @@ async function setup() {
projectViews,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
issues: moduleRef.get(IssuesService),
projects: moduleRef.get(ProjectsService),
};
}
@@ -702,7 +706,7 @@ describe('tracker commands (event sourced)', () => {
});
it('changes several tickets at once and says which refused', async () => {
const { run, rehydrator } = await setup();
const { issues, run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids: string[] = [];
for (const title of ['A', 'B'])
@@ -716,13 +720,11 @@ describe('tracker commands (event sourced)', () => {
for (const status of ['TODO', 'IN_PROGRESS'])
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
// A is in BACKLOG: the workflow does not let it jump to review.
const result = (await run(
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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