first commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(AddCommentCommand)
|
||||
export class AddCommentCommandHandler
|
||||
implements ICommandHandler<AddCommentCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
authorId: command.actorId,
|
||||
body: command.body,
|
||||
});
|
||||
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AddCommentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(AddProjectMemberCommand)
|
||||
export class AddProjectMemberCommandHandler
|
||||
implements ICommandHandler<AddProjectMemberCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: AddProjectMemberCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
await this.access.assertKnownActiveUser(
|
||||
command.userId,
|
||||
command.accessToken,
|
||||
);
|
||||
project.addMember(command.actorId, command.userId, command.role);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AddProjectMemberCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly userId: string,
|
||||
public readonly role: ProjectRole,
|
||||
/** Caller's Pireon token, to look up users unknown to task-stream. */
|
||||
public readonly accessToken?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ArchiveReleaseCommand } from './archive-release.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(ArchiveReleaseCommand)
|
||||
export class ArchiveReleaseCommandHandler
|
||||
implements ICommandHandler<ArchiveReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
release.archive(command.actorId);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class ArchiveReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(AssignIssueCommand)
|
||||
export class AssignIssueCommandHandler
|
||||
implements ICommandHandler<AssignIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: AssignIssueCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
if (command.assigneeId)
|
||||
this.access.assertAssignable(project, command.assigneeId);
|
||||
issue.assign(command.actorId, command.assigneeId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AssignIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
/** null to unassign. */
|
||||
public readonly assigneeId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(ChangeIssueStatusCommand)
|
||||
export class ChangeIssueStatusCommandHandler
|
||||
implements ICommandHandler<ChangeIssueStatusCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
issue.changeStatus(command.actorId, command.status, command.reason);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { IssueStatus } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class ChangeIssueStatusCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly status: IssueStatus,
|
||||
/** Required for QA_FAILED and CANCELLED. */
|
||||
public readonly reason?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ChangeProjectMemberRoleCommand } from './change-project-member-role.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(ChangeProjectMemberRoleCommand)
|
||||
export class ChangeProjectMemberRoleCommandHandler
|
||||
implements ICommandHandler<ChangeProjectMemberRoleCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(
|
||||
command: ChangeProjectMemberRoleCommand,
|
||||
): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.changeMemberRole(command.actorId, command.userId, command.role);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class ChangeProjectMemberRoleCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly userId: string,
|
||||
public readonly role: ProjectRole,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
|
||||
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
|
||||
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
|
||||
import { CreateIssueCommandHandler } from './create-issue.command-handler';
|
||||
import { UpdateIssueCommandHandler } from './update-issue.command-handler';
|
||||
import { ChangeIssueStatusCommandHandler } from './change-issue-status.command-handler';
|
||||
import { AssignIssueCommandHandler } from './assign-issue.command-handler';
|
||||
import { LinkIssuesCommandHandler } from './link-issues.command-handler';
|
||||
import { UnlinkIssuesCommandHandler } from './unlink-issues.command-handler';
|
||||
import { SetIssueReleaseCommandHandler } from './set-issue-release.command-handler';
|
||||
import { AddCommentCommandHandler } from './add-comment.command-handler';
|
||||
import { EditCommentCommandHandler } from './edit-comment.command-handler';
|
||||
import { DeleteCommentCommandHandler } from './delete-comment.command-handler';
|
||||
import { CreateReleaseCommandHandler } from './create-release.command-handler';
|
||||
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
||||
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
||||
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
AddProjectMemberCommandHandler,
|
||||
ChangeProjectMemberRoleCommandHandler,
|
||||
RemoveProjectMemberCommandHandler,
|
||||
CreateIssueCommandHandler,
|
||||
UpdateIssueCommandHandler,
|
||||
ChangeIssueStatusCommandHandler,
|
||||
AssignIssueCommandHandler,
|
||||
LinkIssuesCommandHandler,
|
||||
UnlinkIssuesCommandHandler,
|
||||
SetIssueReleaseCommandHandler,
|
||||
AddCommentCommandHandler,
|
||||
EditCommentCommandHandler,
|
||||
DeleteCommentCommandHandler,
|
||||
CreateReleaseCommandHandler,
|
||||
UpdateReleaseCommandHandler,
|
||||
PublishReleaseCommandHandler,
|
||||
ArchiveReleaseCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
|
||||
|
||||
/**
|
||||
* Commands only acknowledge: the read model is updated asynchronously.
|
||||
* Clients can wait for a subscription event with `version >= result.version`.
|
||||
*/
|
||||
export interface CommandResult {
|
||||
id: string;
|
||||
version: number;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export function ack(
|
||||
aggregate: VersionedAggregateRoot & { key?: string },
|
||||
): CommandResult {
|
||||
return {
|
||||
id: aggregate.id,
|
||||
key: aggregate.key,
|
||||
version: aggregate.version.value,
|
||||
};
|
||||
}
|
||||
287
libs/api/tracker/application/src/lib/commands/commands.spec.ts
Normal file
287
libs/api/tracker/application/src/lib/commands/commands.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
AggregateRehydrator,
|
||||
EventStore,
|
||||
} from '@task-stream/api/shared/application';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ConcurrencyError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
Comment,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
InMemoryEventStore,
|
||||
InMemoryEventStorePublisher,
|
||||
InMemoryIssueNumbers,
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryReleaseIssues,
|
||||
} from '../../testing/in-memory';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { COMMAND_HANDLERS } from './command-handlers';
|
||||
import { CommandResult } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { CreateProjectCommand } from './create-project.command';
|
||||
import { CreateReleaseCommand } from './create-release.command';
|
||||
import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const users = new FakeUserDirectory()
|
||||
.add('owner')
|
||||
.add('dev')
|
||||
.add('qa')
|
||||
.add('pm')
|
||||
.add('gone', 'Gone', false);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
|
||||
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
|
||||
{ provide: ReleaseNamesRepository, useClass: InMemoryReleaseNames },
|
||||
{
|
||||
provide: ReleaseIssuesRepository,
|
||||
useValue: new InMemoryReleaseIssues(store),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
InMemoryEventStorePublisher.install(moduleRef.get(EventBus), store);
|
||||
|
||||
const commandBus = moduleRef.get(CommandBus);
|
||||
const run = (command: object): Promise<CommandResult> =>
|
||||
commandBus.execute(command as never);
|
||||
return { store, run, rehydrator: moduleRef.get(AggregateRehydrator) };
|
||||
}
|
||||
|
||||
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
|
||||
const { id } = await run(
|
||||
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
|
||||
);
|
||||
await run(
|
||||
new AddProjectMemberCommand('owner', id, 'dev', ProjectRole.MEMBER),
|
||||
);
|
||||
await run(new AddProjectMemberCommand('owner', id, 'qa', ProjectRole.MEMBER));
|
||||
await run(new AddProjectMemberCommand('owner', id, 'pm', ProjectRole.VIEWER));
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('tracker commands (event sourced)', () => {
|
||||
it('appends events to the store and returns the new version', async () => {
|
||||
const { store, run } = await setup();
|
||||
const created = await run(
|
||||
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
|
||||
);
|
||||
const added = await run(
|
||||
new AddProjectMemberCommand(
|
||||
'owner',
|
||||
created.id,
|
||||
'dev',
|
||||
ProjectRole.MEMBER,
|
||||
),
|
||||
);
|
||||
|
||||
expect(created).toMatchObject({ key: 'TS', version: 1 });
|
||||
expect(added.version).toBe(2);
|
||||
expect(store.types()).toEqual([
|
||||
'ProjectCreatedEvent',
|
||||
'ProjectMemberAddedEvent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects duplicated project keys', async () => {
|
||||
const { run } = await setup();
|
||||
await run(new CreateProjectCommand('owner', 'TS', 'One'));
|
||||
await expect(
|
||||
run(new CreateProjectCommand('dev', 'ts', 'Two')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('only adds known, active Pireon users', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
await expect(
|
||||
run(
|
||||
new AddProjectMemberCommand(
|
||||
'owner',
|
||||
projectId,
|
||||
'ghost',
|
||||
ProjectRole.MEMBER,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
await expect(
|
||||
run(
|
||||
new AddProjectMemberCommand(
|
||||
'owner',
|
||||
projectId,
|
||||
'gone',
|
||||
ProjectRole.MEMBER,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/deactivated/);
|
||||
});
|
||||
|
||||
it('numbers tickets per project and assigns members only', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const first = await run(
|
||||
new CreateIssueCommand(
|
||||
'qa',
|
||||
projectId,
|
||||
IssueType.BUG,
|
||||
'Crash on login',
|
||||
{},
|
||||
'dev',
|
||||
),
|
||||
);
|
||||
const second = await run(
|
||||
new CreateIssueCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
IssueType.FEATURE,
|
||||
'Dark mode',
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
expect([first.key, second.key]).toEqual(['TS-1', 'TS-2']);
|
||||
await expect(
|
||||
run(new AssignIssueCommand('dev', second.id, 'stranger')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
await expect(
|
||||
run(new CreateIssueCommand('pm', projectId, IssueType.TASK, 'Nope', {})),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('rebuilds the issue from its events on every command (QA round trip)', async () => {
|
||||
const { run, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const { id } = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
|
||||
for (const status of [
|
||||
IssueStatus.TODO,
|
||||
IssueStatus.IN_PROGRESS,
|
||||
IssueStatus.READY_FOR_QA,
|
||||
IssueStatus.IN_QA,
|
||||
]) {
|
||||
await run(new ChangeIssueStatusCommand('dev', id, status));
|
||||
}
|
||||
const failed = await run(
|
||||
new ChangeIssueStatusCommand(
|
||||
'qa',
|
||||
id,
|
||||
IssueStatus.QA_FAILED,
|
||||
'Still crashes on iOS',
|
||||
),
|
||||
);
|
||||
|
||||
expect(failed.version).toBe(6);
|
||||
expect(store.events.at(-1)?.data).toMatchObject({
|
||||
from: IssueStatus.IN_QA,
|
||||
to: IssueStatus.QA_FAILED,
|
||||
reason: 'Still crashes on iOS',
|
||||
aggregateVersion: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('controls which tickets ship in a release', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const release = await run(
|
||||
new CreateReleaseCommand('owner', projectId, 'v1.0.0'),
|
||||
);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
IssueType.FEATURE,
|
||||
'Export CSV',
|
||||
{},
|
||||
null,
|
||||
release.id,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
run(new PublishReleaseCommand('owner', release.id)),
|
||||
).rejects.toThrow(/unfinished/);
|
||||
await expect(
|
||||
run(new PublishReleaseCommand('dev', release.id, true)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
await run(new SetIssueReleaseCommand('dev', issue.id, null));
|
||||
await run(new PublishReleaseCommand('owner', release.id));
|
||||
await expect(
|
||||
run(new SetIssueReleaseCommand('dev', issue.id, release.id)),
|
||||
).rejects.toThrow(/locked/);
|
||||
});
|
||||
|
||||
it('lets admins moderate comments', async () => {
|
||||
const { run, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('dev', issue.id, 'Decision: use Redis streams'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
run(new DeleteCommentCommand('qa', comment.id)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new DeleteCommentCommand('owner', comment.id));
|
||||
const [rebuilt] = await store.all(Comment, 'Comment');
|
||||
expect(rebuilt.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('detects concurrent modifications through stream positions', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
// Two requests load the same version of the project at the same time...
|
||||
const first = await rehydrator.rehydrate(projectId, Project);
|
||||
const second = await rehydrator.rehydrate(projectId, Project);
|
||||
first.updateDetails('owner', { name: 'Renamed by first' });
|
||||
second.updateDetails('owner', { name: 'Renamed by second' });
|
||||
|
||||
// ...the first one wins, the second is stale.
|
||||
await first.commitAndWait();
|
||||
await expect(second.commitAndWait()).rejects.toThrow(ConcurrencyError);
|
||||
expect(first.version.value).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
export class CreateIssueCommandHandler
|
||||
implements ICommandHandler<CreateIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly issueFactory: IssueFactory,
|
||||
private readonly issueNumbers: IssueNumbersRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.assertCanContribute(command.actorId);
|
||||
if (command.assigneeId)
|
||||
this.access.assertAssignable(project, command.assigneeId);
|
||||
if (command.releaseId)
|
||||
await this.access.openReleaseOf(project, command.releaseId);
|
||||
|
||||
const issue = this.issueFactory.create({
|
||||
...command.details,
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
number: await this.issueNumbers.next(project.id),
|
||||
type: command.type,
|
||||
title: command.title,
|
||||
reporterId: command.actorId,
|
||||
assigneeId: command.assigneeId,
|
||||
});
|
||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(issue);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly type: IssueType,
|
||||
public readonly title: string,
|
||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||
public readonly assigneeId?: string | null,
|
||||
public readonly releaseId?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ProjectFactory } from '@task-stream/api/tracker/domain';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateProjectCommand } from './create-project.command';
|
||||
|
||||
@CommandHandler(CreateProjectCommand)
|
||||
export class CreateProjectCommandHandler
|
||||
implements ICommandHandler<CreateProjectCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly projectFactory: ProjectFactory,
|
||||
private readonly projectKeys: ProjectKeysRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateProjectCommand): Promise<CommandResult> {
|
||||
const project = this.projectFactory.create({
|
||||
key: command.key,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
ownerId: command.actorId,
|
||||
});
|
||||
await this.projectKeys.reserve(project.key, project.id);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(project);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly key: string,
|
||||
public readonly name: string,
|
||||
public readonly description?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseFactory } from '@task-stream/api/tracker/domain';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateReleaseCommand } from './create-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(CreateReleaseCommand)
|
||||
export class CreateReleaseCommandHandler
|
||||
implements ICommandHandler<CreateReleaseCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly releaseFactory: ReleaseFactory,
|
||||
private readonly releaseNames: ReleaseNamesRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateReleaseCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.assertCanManage(command.actorId);
|
||||
const release = this.releaseFactory.create(command.actorId, {
|
||||
projectId: project.id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
targetDate: command.targetDate,
|
||||
});
|
||||
await this.releaseNames.reserve(project.id, release.name, release.id);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(release);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly name: string,
|
||||
public readonly description?: string | null,
|
||||
public readonly targetDate?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DeleteCommentCommand)
|
||||
export class DeleteCommentCommandHandler
|
||||
implements ICommandHandler<DeleteCommentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteCommentCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
const role = project.roleOf(command.actorId);
|
||||
comment.delete(
|
||||
command.actorId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class DeleteCommentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { EditCommentCommand } from './edit-comment.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(EditCommentCommand)
|
||||
export class EditCommentCommandHandler
|
||||
implements ICommandHandler<EditCommentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: EditCommentCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
comment.edit(command.actorId, command.body);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class EditCommentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
20
libs/api/tracker/application/src/lib/commands/index.ts
Normal file
20
libs/api/tracker/application/src/lib/commands/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export * from './command-result';
|
||||
export * from './add-comment.command';
|
||||
export * from './add-project-member.command';
|
||||
export * from './archive-release.command';
|
||||
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 './create-project.command';
|
||||
export * from './create-release.command';
|
||||
export * from './delete-comment.command';
|
||||
export * from './edit-comment.command';
|
||||
export * from './link-issues.command';
|
||||
export * from './publish-release.command';
|
||||
export * from './remove-project-member.command';
|
||||
export * from './set-issue-release.command';
|
||||
export * from './unlink-issues.command';
|
||||
export * from './update-issue.command';
|
||||
export * from './update-project.command';
|
||||
export * from './update-release.command';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { LinkIssuesCommand } from './link-issues.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(LinkIssuesCommand)
|
||||
export class LinkIssuesCommandHandler
|
||||
implements ICommandHandler<LinkIssuesCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: LinkIssuesCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// Links may cross projects (FE ↔ BE ↔ QA), but the actor must see the target.
|
||||
const target = await this.access.issue(command.targetIssueId);
|
||||
(await this.access.project(target.projectId)).assertCanView(
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
issue.linkTo(command.actorId, command.type, {
|
||||
id: target.id,
|
||||
key: target.key,
|
||||
});
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class LinkIssuesCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly type: IssueLinkType,
|
||||
public readonly targetIssueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueWorkflow } from '@task-stream/api/tracker/domain';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(PublishReleaseCommand)
|
||||
export class PublishReleaseCommandHandler
|
||||
implements ICommandHandler<PublishReleaseCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly releaseIssues: ReleaseIssuesRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: PublishReleaseCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
// Rebuilt from the event store (source of truth), not from the read model.
|
||||
const candidates = await this.releaseIssues.findIssueIds(release.id);
|
||||
const issues = await Promise.all(
|
||||
candidates.map((id) => this.access.issue(id)),
|
||||
);
|
||||
const unfinished = issues.filter(
|
||||
(issue) =>
|
||||
issue.releaseId === release.id &&
|
||||
!IssueWorkflow.isFinished(issue.status),
|
||||
).length;
|
||||
|
||||
release.ship(command.actorId, unfinished, command.force);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class PublishReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
/** Ship even with unfinished tickets. */
|
||||
public readonly force = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { RemoveProjectMemberCommand } from './remove-project-member.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(RemoveProjectMemberCommand)
|
||||
export class RemoveProjectMemberCommandHandler
|
||||
implements ICommandHandler<RemoveProjectMemberCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RemoveProjectMemberCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.removeMember(command.actorId, command.userId);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class RemoveProjectMemberCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly userId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(SetIssueReleaseCommand)
|
||||
export class SetIssueReleaseCommandHandler
|
||||
implements ICommandHandler<SetIssueReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: SetIssueReleaseCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// Neither the old nor the new release may be already shipped.
|
||||
if (issue.releaseId)
|
||||
await this.access.openReleaseOf(project, issue.releaseId);
|
||||
if (command.releaseId)
|
||||
await this.access.openReleaseOf(project, command.releaseId);
|
||||
issue.setRelease(command.actorId, command.releaseId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class SetIssueReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
/** null takes the ticket out of its release. */
|
||||
public readonly releaseId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { UnlinkIssuesCommand } from './unlink-issues.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UnlinkIssuesCommand)
|
||||
export class UnlinkIssuesCommandHandler
|
||||
implements ICommandHandler<UnlinkIssuesCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UnlinkIssuesCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
issue.unlink(command.actorId, command.type, command.targetIssueId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class UnlinkIssuesCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly type: IssueLinkType,
|
||||
public readonly targetIssueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { UpdateIssueCommand } from './update-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UpdateIssueCommand)
|
||||
export class UpdateIssueCommandHandler
|
||||
implements ICommandHandler<UpdateIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UpdateIssueCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
issue.updateDetails(command.actorId, command.changes);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class UpdateIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly changes: IssueDetails,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UpdateProjectCommand)
|
||||
export class UpdateProjectCommandHandler
|
||||
implements ICommandHandler<UpdateProjectCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UpdateProjectCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.updateDetails(command.actorId, command.changes);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class UpdateProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly changes: { name?: string; description?: string | null },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { UpdateReleaseCommand } from './update-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UpdateReleaseCommand)
|
||||
export class UpdateReleaseCommandHandler
|
||||
implements ICommandHandler<UpdateReleaseCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly releaseNames: ReleaseNamesRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateReleaseCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
const previousName = release.name;
|
||||
release.update(command.actorId, command.changes);
|
||||
if (release.name !== previousName) {
|
||||
await this.releaseNames.reserve(
|
||||
release.projectId,
|
||||
release.name,
|
||||
release.id,
|
||||
);
|
||||
}
|
||||
await release.commitAndWait();
|
||||
if (release.name !== previousName) {
|
||||
await this.releaseNames.free(release.projectId, previousName);
|
||||
}
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class UpdateReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
public readonly changes: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
targetDate?: string | null;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Injectable, Type } from '@nestjs/common';
|
||||
import { AggregateRehydrator } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
Project,
|
||||
Release,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
|
||||
/** Loads aggregates from the event store, failing with domain errors. */
|
||||
@Injectable()
|
||||
export class WriteAccess {
|
||||
constructor(
|
||||
private readonly rehydrator: AggregateRehydrator,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
project(id: string): Promise<Project> {
|
||||
return this.load(id, Project);
|
||||
}
|
||||
|
||||
issue(id: string): Promise<Issue> {
|
||||
return this.load(id, Issue);
|
||||
}
|
||||
|
||||
release(id: string): Promise<Release> {
|
||||
return this.load(id, Release);
|
||||
}
|
||||
|
||||
comment(id: string): Promise<Comment> {
|
||||
return this.load(id, Comment);
|
||||
}
|
||||
|
||||
/** Issue + its project, checking the actor can modify tickets there. */
|
||||
async issueForContributor(
|
||||
issueId: string,
|
||||
actorId: string,
|
||||
): Promise<{ issue: Issue; project: Project }> {
|
||||
const issue = await this.issue(issueId);
|
||||
const project = await this.project(issue.projectId);
|
||||
project.assertCanContribute(actorId);
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
/** Release of the same project that still accepts tickets. */
|
||||
async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
|
||||
const release = await this.release(releaseId);
|
||||
if (release.projectId !== project.id) {
|
||||
throw new BusinessRuleError(
|
||||
`Release ${release.name} belongs to another project`,
|
||||
);
|
||||
}
|
||||
release.assertAcceptsIssues();
|
||||
return release;
|
||||
}
|
||||
|
||||
async assertKnownActiveUser(
|
||||
userId: string,
|
||||
accessToken?: string,
|
||||
): Promise<void> {
|
||||
const user = await this.users.findById(userId, accessToken);
|
||||
if (!user) throw new NotFoundError('User', userId);
|
||||
if (!user.isActive)
|
||||
throw new BusinessRuleError(`User ${user.name} is deactivated`);
|
||||
}
|
||||
|
||||
assertAssignable(project: Project, userId: string): void {
|
||||
if (!project.isMember(userId)) {
|
||||
throw new BusinessRuleError(
|
||||
`User ${userId} must be a member of ${project.key} to be assigned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async load<T extends VersionedAggregateRoot & { createdAt?: string }>(
|
||||
id: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T> {
|
||||
const aggregate = await this.rehydrator.rehydrate(id, AggregateCls);
|
||||
// The stream exists but belongs to another kind of aggregate.
|
||||
if (!aggregate.createdAt) throw new NotFoundError(AggregateCls.name, id);
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user