first commit
This commit is contained in:
26
libs/api/tracker/application/README.md
Normal file
26
libs/api/tracker/application/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# api-tracker-application
|
||||
|
||||
Casos de uso del tracker (CQRS).
|
||||
|
||||
- **Import**: `@task-stream/api/tracker/application`
|
||||
- **Capa**: `application` · **Tags**: `scope:api-tracker`, `type:application`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions).
|
||||
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`).
|
||||
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad.
|
||||
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
|
||||
- `TrackerApplicationModule.withInfrastructure(...)`.
|
||||
|
||||
Cómo añadir un caso de uso: [Desarrollo](../../../../docs/06-desarrollo.md#64-cómo-añadir-una-funcionalidad).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-tracker-application
|
||||
npx nx test api-tracker-application # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/tracker/application/eslint.config.mjs
Normal file
3
libs/api/tracker/application/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/tracker/application/jest.config.cts
Normal file
11
libs/api/tracker/application/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-tracker-application',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/tracker/application',
|
||||
};
|
||||
12
libs/api/tracker/application/project.json
Normal file
12
libs/api/tracker/application/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-tracker-application",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:application"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
9
libs/api/tracker/application/src/index.ts
Normal file
9
libs/api/tracker/application/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './lib/ports';
|
||||
export * from './lib/commands';
|
||||
export * from './lib/queries';
|
||||
export * from './lib/projects.service';
|
||||
export * from './lib/issues.service';
|
||||
export * from './lib/comments.service';
|
||||
export * from './lib/releases.service';
|
||||
export * from './lib/changes.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
77
libs/api/tracker/application/src/lib/changes.service.ts
Normal file
77
libs/api/tracker/application/src/lib/changes.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
ChangeFeed,
|
||||
CommentChange,
|
||||
IssueChange,
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
} from './ports/change-feed';
|
||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
|
||||
/** Realtime subscriptions, authorised at subscribe time. */
|
||||
@Injectable()
|
||||
export class ChangesService {
|
||||
constructor(
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
|
||||
async watchIssues(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<IssueChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.issues(projectId);
|
||||
}
|
||||
|
||||
async watchComments(
|
||||
viewerId: string,
|
||||
issueId: string,
|
||||
): Promise<AsyncIterableIterator<CommentChange>> {
|
||||
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
|
||||
return this.changeFeed.comments(issueId);
|
||||
}
|
||||
|
||||
async watchActivity(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<ActivityReadModel>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.activity(projectId);
|
||||
}
|
||||
|
||||
async watchReleases(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<ReleaseChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.releases(projectId);
|
||||
}
|
||||
|
||||
async watchProject(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<ProjectChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.project(projectId);
|
||||
}
|
||||
|
||||
/** Projects the viewer is added to or removed from. */
|
||||
watchMyProjects(viewerId: string): AsyncIterableIterator<ProjectChange> {
|
||||
return this.changeFeed.userProjects(viewerId);
|
||||
}
|
||||
|
||||
private async assertCanView(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const allowed = await this.queryBus.execute(
|
||||
new CanViewProjectQuery(viewerId, projectId),
|
||||
);
|
||||
if (!allowed) throw new NotFoundError('Project', projectId);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
50
libs/api/tracker/application/src/lib/comments.service.ts
Normal file
50
libs/api/tracker/application/src/lib/comments.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './commands/add-comment.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { DeleteCommentCommand } from './commands/delete-comment.command';
|
||||
import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
add(actorId: string, issueId: string, body: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AddCommentCommand(actorId, issueId, body),
|
||||
);
|
||||
}
|
||||
|
||||
edit(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
body: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new EditCommentCommand(actorId, commentId, body),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, commentId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteCommentCommand(actorId, commentId),
|
||||
);
|
||||
}
|
||||
|
||||
list(
|
||||
viewerId: string,
|
||||
issueId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<Page<CommentReadModel>> {
|
||||
return this.queryBus.execute(
|
||||
new GetCommentsQuery(viewerId, issueId, limit, offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentDeletedEvent,
|
||||
CommentEditedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueStatus,
|
||||
IssueStatusChangedEvent,
|
||||
IssueType,
|
||||
IssueUnlinkedEvent,
|
||||
IssueUpdatedEvent,
|
||||
ProjectCreatedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectRole,
|
||||
ProjectUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface SummaryContext {
|
||||
actor: string;
|
||||
user: (id: string | null) => string;
|
||||
issueKey: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
}
|
||||
|
||||
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
|
||||
|
||||
const STATUS: Record<IssueStatus, string> = {
|
||||
[IssueStatus.BACKLOG]: 'Backlog',
|
||||
[IssueStatus.TODO]: 'Por hacer',
|
||||
[IssueStatus.IN_PROGRESS]: 'En curso',
|
||||
[IssueStatus.IN_REVIEW]: 'En revisión',
|
||||
[IssueStatus.READY_FOR_QA]: 'Listo para QA',
|
||||
[IssueStatus.IN_QA]: 'En QA',
|
||||
[IssueStatus.QA_FAILED]: 'QA fallido',
|
||||
[IssueStatus.DONE]: 'Hecho',
|
||||
[IssueStatus.CANCELLED]: 'Cancelado',
|
||||
};
|
||||
|
||||
const TYPE: Record<IssueType, string> = {
|
||||
[IssueType.BUG]: 'bug',
|
||||
[IssueType.FEATURE]: 'feature',
|
||||
[IssueType.UI_CHANGE]: 'cambio de UI',
|
||||
[IssueType.TASK]: 'tarea',
|
||||
[IssueType.QA]: 'QA',
|
||||
};
|
||||
|
||||
const LINK: Record<IssueLinkType, string> = {
|
||||
[IssueLinkType.BLOCKS]: 'bloquea a',
|
||||
[IssueLinkType.DEPENDS_ON]: 'depende de',
|
||||
[IssueLinkType.TESTS]: 'prueba',
|
||||
[IssueLinkType.DUPLICATES]: 'duplica a',
|
||||
[IssueLinkType.RELATES_TO]: 'está relacionado con',
|
||||
};
|
||||
|
||||
const ROLE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.OWNER]: 'propietario',
|
||||
[ProjectRole.ADMIN]: 'admin',
|
||||
[ProjectRole.MEMBER]: 'miembro',
|
||||
[ProjectRole.VIEWER]: 'lector',
|
||||
};
|
||||
|
||||
const FIELD: Record<string, string> = {
|
||||
title: 'el título',
|
||||
description: 'la descripción',
|
||||
type: 'el tipo',
|
||||
area: 'el área',
|
||||
priority: 'la prioridad',
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
storyPoints: 'los puntos',
|
||||
dueDate: 'la fecha límite',
|
||||
};
|
||||
|
||||
const list = (items: string[]) =>
|
||||
items.length <= 1
|
||||
? items.join('')
|
||||
: `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`;
|
||||
|
||||
/** One human-readable line per event, for the project activity feed. */
|
||||
export function describeEvent(
|
||||
event: TrackerEvent,
|
||||
ctx: SummaryContext,
|
||||
): string {
|
||||
const { actor, issueKey } = ctx;
|
||||
|
||||
if (event instanceof ProjectCreatedEvent)
|
||||
return `${actor} creó el proyecto ${event.project.key}`;
|
||||
if (event instanceof ProjectUpdatedEvent)
|
||||
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectMemberAddedEvent) {
|
||||
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
|
||||
}
|
||||
if (event instanceof ProjectMemberRemovedEvent) {
|
||||
return event.userId === event.actorId
|
||||
? `${actor} salió del proyecto`
|
||||
: `${actor} quitó a ${ctx.user(event.userId)} del proyecto`;
|
||||
}
|
||||
if (event instanceof ProjectMemberRoleChangedEvent) {
|
||||
return `${actor} cambió el rol de ${ctx.user(event.userId)} a ${ROLE[event.role]}`;
|
||||
}
|
||||
|
||||
if (event instanceof IssueCreatedEvent) {
|
||||
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`;
|
||||
}
|
||||
if (event instanceof IssueUpdatedEvent) {
|
||||
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
|
||||
}
|
||||
if (event instanceof IssueStatusChangedEvent) {
|
||||
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`;
|
||||
}
|
||||
if (event instanceof IssueAssignedEvent) {
|
||||
return event.assigneeId
|
||||
? `${actor} asignó ${issueKey} a ${ctx.user(event.assigneeId)}`
|
||||
: `${actor} dejó ${issueKey} sin asignar`;
|
||||
}
|
||||
if (event instanceof IssueLinkedEvent) {
|
||||
return `${actor} indicó que ${issueKey} ${LINK[event.link.type]} ${event.link.targetKey}`;
|
||||
}
|
||||
if (event instanceof IssueUnlinkedEvent) {
|
||||
return `${actor} quitó la relación entre ${issueKey} y ${event.link.targetKey}`;
|
||||
}
|
||||
if (event instanceof IssueReleaseChangedEvent) {
|
||||
return event.releaseId
|
||||
? `${actor} metió ${issueKey} en la release ${ctx.releaseName}`
|
||||
: `${actor} sacó ${issueKey} de su release`;
|
||||
}
|
||||
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return `${actor} comentó en ${issueKey}`;
|
||||
if (event instanceof CommentEditedEvent)
|
||||
return `${actor} editó un comentario en ${issueKey}`;
|
||||
if (event instanceof CommentDeletedEvent)
|
||||
return `${actor} borró un comentario en ${issueKey}`;
|
||||
|
||||
if (event instanceof ReleaseCreatedEvent)
|
||||
return `${actor} creó la release ${event.release.name}`;
|
||||
if (event instanceof ReleaseUpdatedEvent)
|
||||
return `${actor} actualizó la release ${ctx.releaseName}`;
|
||||
if (event instanceof ReleaseShippedEvent)
|
||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
||||
if (event instanceof ReleaseArchivedEvent)
|
||||
return `${actor} archivó la release ${ctx.releaseName}`;
|
||||
return `${actor}: ${event.constructor.name}`;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentAddedEvent,
|
||||
CommentDeletedEvent,
|
||||
CommentEditedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUnlinkedEvent,
|
||||
IssueUpdatedEvent,
|
||||
ProjectCreatedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { describeEvent } from './activity-summary';
|
||||
import { versionOf } from './read-model-notifier';
|
||||
|
||||
/** Project activity feed / ticket history: one entry per domain event. */
|
||||
@EventsHandler(
|
||||
ProjectCreatedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueUpdatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueUnlinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
CommentAddedEvent,
|
||||
CommentEditedEvent,
|
||||
CommentDeletedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
)
|
||||
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
private readonly upsertMaterializedActivityRepository: UpsertMaterializedActivityRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent) {
|
||||
const issueId = this.issueIdOf(event);
|
||||
const issueKey =
|
||||
event instanceof IssueCreatedEvent
|
||||
? event.issue.key
|
||||
: issueId
|
||||
? ((await this.findIssues.findById(issueId))?.key ?? null)
|
||||
: null;
|
||||
const releaseId = this.releaseIdOf(event);
|
||||
const release = releaseId
|
||||
? await this.findReleases.findById(releaseId)
|
||||
: null;
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
const mentioned = [
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
|
||||
const activity: ActivityReadModel = {
|
||||
id: `${event.aggregateId}:${versionOf(event)}`,
|
||||
type: event.constructor.name,
|
||||
projectId: event.projectId,
|
||||
issueId,
|
||||
issueKey,
|
||||
actor: users.of(event.actorId),
|
||||
summary: describeEvent(event, {
|
||||
actor: users.of(event.actorId).name,
|
||||
user: (id) => users.ofNullable(id)?.name ?? '?',
|
||||
issueKey: issueKey ?? '',
|
||||
projectKey: project?.key ?? '',
|
||||
releaseName: release?.name ?? '',
|
||||
}),
|
||||
occurredAt: event.occurredAt,
|
||||
};
|
||||
if (await this.upsertMaterializedActivityRepository.insert(activity)) {
|
||||
await this.changeFeed.publishActivity(activity);
|
||||
}
|
||||
}
|
||||
|
||||
private issueIdOf(event: TrackerEvent): string | null {
|
||||
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
|
||||
if (event instanceof CommentAddedEvent) return event.comment.issueId;
|
||||
if (
|
||||
event instanceof CommentEditedEvent ||
|
||||
event instanceof CommentDeletedEvent
|
||||
)
|
||||
return event.issueId;
|
||||
return null;
|
||||
}
|
||||
|
||||
private releaseIdOf(event: TrackerEvent): string | null {
|
||||
if (event.constructor.name.startsWith('Release')) return event.aggregateId;
|
||||
if (event instanceof IssueReleaseChangedEvent) return event.releaseId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentAddedEvent)
|
||||
export class CommentAddedEventHandler
|
||||
implements IEventHandler<CommentAddedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentAddedEvent) {
|
||||
const { comment } = event;
|
||||
const users = await this.users.refs([comment.authorId]);
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: comment.id,
|
||||
issueId: comment.issueId,
|
||||
projectId: comment.projectId,
|
||||
author: users.of(comment.authorId),
|
||||
body: comment.body,
|
||||
createdAt: comment.createdAt,
|
||||
editedAt: null,
|
||||
version: versionOf(event),
|
||||
});
|
||||
// Recount instead of incrementing, so redelivery stays idempotent.
|
||||
await this.upsertMaterializedIssueRepository.setCommentCount(
|
||||
comment.issueId,
|
||||
await this.findComments.countByIssue(comment.issueId),
|
||||
);
|
||||
await this.notifier.comment(comment.id, comment.issueId, event);
|
||||
await this.notifier.issue(comment.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentDeletedEvent)
|
||||
export class CommentDeletedEventHandler
|
||||
implements IEventHandler<CommentDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentDeletedEvent) {
|
||||
await this.upsertMaterializedCommentRepository.remove(event.aggregateId);
|
||||
await this.upsertMaterializedIssueRepository.setCommentCount(
|
||||
event.issueId,
|
||||
await this.findComments.countByIssue(event.issueId),
|
||||
);
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
await this.notifier.issue(event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentEditedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentEditedEvent)
|
||||
export class CommentEditedEventHandler
|
||||
implements IEventHandler<CommentEditedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentEditedEvent) {
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
body: event.body,
|
||||
editedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ActivityEventHandler } from './activity.event-handler';
|
||||
import { CommentAddedEventHandler } from './comment-added.event-handler';
|
||||
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
|
||||
import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
import { IssueLinkedEventHandler } from './issue-linked.event-handler';
|
||||
import { IssueReleaseChangedEventHandler } from './issue-release-changed.event-handler';
|
||||
import { IssueStatusChangedEventHandler } from './issue-status-changed.event-handler';
|
||||
import { IssueUnlinkedEventHandler } from './issue-unlinked.event-handler';
|
||||
import { IssueUpdatedEventHandler } from './issue-updated.event-handler';
|
||||
import { ProjectCreatedEventHandler } from './project-created.event-handler';
|
||||
import { ProjectMemberAddedEventHandler } from './project-member-added.event-handler';
|
||||
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
|
||||
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
|
||||
import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
|
||||
import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
|
||||
import { ReleaseCreatedEventHandler } from './release-created.event-handler';
|
||||
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
|
||||
import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
|
||||
import { ReadModelNotifier } from './read-model-notifier';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
ActivityEventHandler,
|
||||
CommentAddedEventHandler,
|
||||
CommentDeletedEventHandler,
|
||||
CommentEditedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
IssueLinkedEventHandler,
|
||||
IssueReleaseChangedEventHandler,
|
||||
IssueStatusChangedEventHandler,
|
||||
IssueUnlinkedEventHandler,
|
||||
IssueUpdatedEventHandler,
|
||||
ProjectCreatedEventHandler,
|
||||
ProjectMemberAddedEventHandler,
|
||||
ProjectMemberRemovedEventHandler,
|
||||
ProjectMemberRoleChangedEventHandler,
|
||||
ProjectUpdatedEventHandler,
|
||||
ReleaseArchivedEventHandler,
|
||||
ReleaseCreatedEventHandler,
|
||||
ReleaseShippedEventHandler,
|
||||
ReleaseUpdatedEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueAssignedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueAssignedEvent)
|
||||
export class IssueAssignedEventHandler
|
||||
implements IEventHandler<IssueAssignedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueAssignedEvent) {
|
||||
const users = await this.users.refs([event.assigneeId]);
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
assignee: users.ofNullable(event.assigneeId),
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueCreatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueCreatedEvent)
|
||||
export class IssueCreatedEventHandler
|
||||
implements IEventHandler<IssueCreatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueCreatedEvent) {
|
||||
const { issue } = event;
|
||||
const users = await this.users.refs([issue.reporterId, issue.assigneeId]);
|
||||
const release = issue.releaseId
|
||||
? await this.findReleases.findById(issue.releaseId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: issue.id,
|
||||
projectId: issue.projectId,
|
||||
projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')),
|
||||
number: issue.number,
|
||||
key: issue.key,
|
||||
type: issue.type,
|
||||
area: issue.area,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
reporter: users.of(issue.reporterId),
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(issue.id, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueLinkedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueLinkedEvent)
|
||||
export class IssueLinkedEventHandler
|
||||
implements IEventHandler<IssueLinkedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueLinkedEvent) {
|
||||
const source = await this.findIssues.findById(event.aggregateId);
|
||||
if (!source) return;
|
||||
const { type, targetIssueId, targetKey } = event.link;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
// Both sides show the relation: TS-1 DEPENDS_ON BE-3 / BE-3 is depended on by TS-1.
|
||||
await this.upsertMaterializedIssueRepository.addLink(event.aggregateId, {
|
||||
type,
|
||||
direction: 'OUTWARD',
|
||||
issueId: targetIssueId,
|
||||
issueKey: targetKey,
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.addLink(targetIssueId, {
|
||||
type,
|
||||
direction: 'INWARD',
|
||||
issueId: source.id,
|
||||
issueKey: source.key,
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
await this.notifier.issue(targetIssueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueReleaseChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueReleaseChangedEvent)
|
||||
export class IssueReleaseChangedEventHandler
|
||||
implements IEventHandler<IssueReleaseChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueReleaseChangedEvent) {
|
||||
const release = event.releaseId
|
||||
? await this.findReleases.findById(event.releaseId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
release: event.releaseId
|
||||
? { id: event.releaseId, name: release?.name ?? '' }
|
||||
: null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueStatusChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueStatusChangedEvent)
|
||||
export class IssueStatusChangedEventHandler
|
||||
implements IEventHandler<IssueStatusChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueStatusChangedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
status: event.to,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueUnlinkedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueUnlinkedEvent)
|
||||
export class IssueUnlinkedEventHandler
|
||||
implements IEventHandler<IssueUnlinkedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueUnlinkedEvent) {
|
||||
const { type, targetIssueId } = event.link;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.removeLink(event.aggregateId, {
|
||||
type,
|
||||
direction: 'OUTWARD',
|
||||
issueId: targetIssueId,
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.removeLink(targetIssueId, {
|
||||
type,
|
||||
direction: 'INWARD',
|
||||
issueId: event.aggregateId,
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
await this.notifier.issue(targetIssueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueUpdatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueUpdatedEvent)
|
||||
export class IssueUpdatedEventHandler
|
||||
implements IEventHandler<IssueUpdatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueUpdatedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
...event.changes,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectCreatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectCreatedEvent)
|
||||
export class ProjectCreatedEventHandler
|
||||
implements IEventHandler<ProjectCreatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectCreatedEvent) {
|
||||
const { project } = event;
|
||||
const users = await this.users.refs(project.members.map((m) => m.userId));
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
owner: users.of(project.ownerId),
|
||||
members: project.members.map((m) => ({
|
||||
user: users.of(m.userId),
|
||||
role: m.role,
|
||||
})),
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event, [project.ownerId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectMemberAddedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectMemberAddedEvent)
|
||||
export class ProjectMemberAddedEventHandler
|
||||
implements IEventHandler<ProjectMemberAddedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectMemberAddedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
if (!project) return;
|
||||
const users = await this.users.refs([event.userId]);
|
||||
const others = project.members.filter((m) => m.user.id !== event.userId);
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
members: [...others, { user: users.of(event.userId), role: event.role }],
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event, [event.userId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectMemberRemovedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectMemberRemovedEvent)
|
||||
export class ProjectMemberRemovedEventHandler
|
||||
implements IEventHandler<ProjectMemberRemovedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectMemberRemovedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
if (!project) return;
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
members: project.members.filter((m) => m.user.id !== event.userId),
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event, [event.userId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectMemberRoleChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectMemberRoleChangedEvent)
|
||||
export class ProjectMemberRoleChangedEventHandler
|
||||
implements IEventHandler<ProjectMemberRoleChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectMemberRoleChangedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
if (!project) return;
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
members: project.members.map((m) =>
|
||||
m.user.id === event.userId ? { ...m, role: event.role } : m,
|
||||
),
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectUpdatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectUpdatedEvent)
|
||||
export class ProjectUpdatedEventHandler
|
||||
implements IEventHandler<ProjectUpdatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectUpdatedEvent) {
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
...event.changes,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TrackerEvent } from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
|
||||
/** Position of the event in its stream (stamped by the event store). */
|
||||
export function versionOf(event: TrackerEvent): number {
|
||||
return event.aggregateVersion ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the fresh read model to realtime subscribers, after a projection
|
||||
* updated it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReadModelNotifier {
|
||||
constructor(
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
) {}
|
||||
|
||||
async project(
|
||||
event: TrackerEvent,
|
||||
notifyUserIds: string[] = [],
|
||||
): Promise<void> {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
if (!project) return;
|
||||
await this.changeFeed.publishProject(
|
||||
{ ...this.meta(event), project },
|
||||
notifyUserIds,
|
||||
);
|
||||
}
|
||||
|
||||
async issue(issueId: string, event: TrackerEvent): Promise<void> {
|
||||
const issue = await this.findIssues.findById(issueId);
|
||||
if (!issue) return;
|
||||
await this.changeFeed.publishIssue({ ...this.meta(event), issue });
|
||||
}
|
||||
|
||||
async comment(
|
||||
commentId: string,
|
||||
issueId: string,
|
||||
event: TrackerEvent,
|
||||
): Promise<void> {
|
||||
const comment = await this.findComments.findById(commentId);
|
||||
await this.changeFeed.publishComment({
|
||||
...this.meta(event),
|
||||
issueId,
|
||||
commentId,
|
||||
comment,
|
||||
});
|
||||
}
|
||||
|
||||
async release(releaseId: string, event: TrackerEvent): Promise<void> {
|
||||
const release = await this.findReleases.findById(releaseId);
|
||||
if (!release) return;
|
||||
await this.changeFeed.publishRelease({ ...this.meta(event), release });
|
||||
}
|
||||
|
||||
private meta(event: TrackerEvent) {
|
||||
return {
|
||||
action: event.constructor.name,
|
||||
version: versionOf(event),
|
||||
actorId: event.actorId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseArchivedEvent)
|
||||
export class ReleaseArchivedEventHandler
|
||||
implements IEventHandler<ReleaseArchivedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseArchivedEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
status: ReleaseStatus.ARCHIVED,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.release(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseCreatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseCreatedEvent)
|
||||
export class ReleaseCreatedEventHandler
|
||||
implements IEventHandler<ReleaseCreatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseCreatedEvent) {
|
||||
const { release } = event;
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
...release,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.release(release.id, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ReleaseShippedEvent,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseShippedEvent)
|
||||
export class ReleaseShippedEventHandler
|
||||
implements IEventHandler<ReleaseShippedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseShippedEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
status: ReleaseStatus.RELEASED,
|
||||
releasedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.release(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseUpdatedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseUpdatedEvent)
|
||||
export class ReleaseUpdatedEventHandler
|
||||
implements IEventHandler<ReleaseUpdatedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseUpdatedEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
...event.changes,
|
||||
version: versionOf(event),
|
||||
});
|
||||
if (event.changes.name) {
|
||||
await this.upsertMaterializedIssueRepository.renameRelease(
|
||||
event.aggregateId,
|
||||
event.changes.name,
|
||||
);
|
||||
}
|
||||
await this.notifier.release(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
157
libs/api/tracker/application/src/lib/issues.service.ts
Normal file
157
libs/api/tracker/application/src/lib/issues.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
IssueDetails,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AssignIssueCommand } from './commands/assign-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||
import { LinkIssuesCommand } from './commands/link-issues.command';
|
||||
import { SetIssueReleaseCommand } from './commands/set-issue-release.command';
|
||||
import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
|
||||
import { UpdateIssueCommand } from './commands/update-issue.command';
|
||||
import { IssueFilter, Page } from './ports/find-issues.repository';
|
||||
import { GetActivityQuery } from './queries/get-activity.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
||||
import { SearchIssuesQuery } from './queries/search-issues.query';
|
||||
|
||||
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
||||
projectId: string;
|
||||
type: IssueType;
|
||||
title: string;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueLinkInput {
|
||||
issueId: string;
|
||||
type: IssueLinkType;
|
||||
targetIssueId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IssuesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
||||
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
|
||||
return this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
actorId,
|
||||
projectId,
|
||||
type,
|
||||
title,
|
||||
details,
|
||||
assigneeId,
|
||||
releaseId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
changes: IssueDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateIssueCommand(actorId, issueId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
changeStatus(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
status: IssueStatus,
|
||||
reason?: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param assigneeId null to unassign */
|
||||
assign(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
assigneeId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AssignIssueCommand(actorId, issueId, assigneeId),
|
||||
);
|
||||
}
|
||||
|
||||
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new LinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
input.type,
|
||||
input.targetIssueId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UnlinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
input.type,
|
||||
input.targetIssueId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param releaseId null to take the ticket out of its release */
|
||||
setRelease(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new SetIssueReleaseCommand(actorId, issueId, releaseId),
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<IssueReadModel> {
|
||||
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
findMany(viewerId: string, ids: string[]): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||
}
|
||||
|
||||
search(viewerId: string, filter: IssueFilter): Promise<Page<IssueReadModel>> {
|
||||
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
|
||||
}
|
||||
|
||||
activity(
|
||||
viewerId: string,
|
||||
params: {
|
||||
projectId: string;
|
||||
issueId?: string;
|
||||
limit?: number;
|
||||
before?: string;
|
||||
},
|
||||
): Promise<ActivityReadModel[]> {
|
||||
return this.queryBus.execute(
|
||||
new GetActivityQuery(
|
||||
viewerId,
|
||||
params.projectId,
|
||||
params.issueId,
|
||||
params.limit,
|
||||
params.before,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
libs/api/tracker/application/src/lib/ports/change-feed.ts
Normal file
59
libs/api/tracker/application/src/lib/ports/change-feed.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Every realtime message says which event caused it and the resulting version. */
|
||||
export interface Change {
|
||||
/** Event class name, e.g. `IssueStatusChangedEvent`. */
|
||||
action: string;
|
||||
version: number;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
export interface IssueChange extends Change {
|
||||
issue: IssueReadModel;
|
||||
}
|
||||
|
||||
export interface CommentChange extends Change {
|
||||
issueId: string;
|
||||
commentId: string;
|
||||
/** Null when the comment was deleted. */
|
||||
comment: CommentReadModel | null;
|
||||
}
|
||||
|
||||
export interface ReleaseChange extends Change {
|
||||
release: ReleaseReadModel;
|
||||
}
|
||||
|
||||
export interface ProjectChange extends Change {
|
||||
project: ProjectReadModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live stream of read-model changes: event handlers publish once the read
|
||||
* model is up to date, GraphQL subscriptions watch.
|
||||
*/
|
||||
export abstract class ChangeFeed {
|
||||
abstract publishIssue(change: IssueChange): Promise<void>;
|
||||
abstract publishComment(change: CommentChange): Promise<void>;
|
||||
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
|
||||
abstract publishRelease(change: ReleaseChange): Promise<void>;
|
||||
/** @param notifyUserIds users whose project list changed (joined / left). */
|
||||
abstract publishProject(
|
||||
change: ProjectChange,
|
||||
notifyUserIds?: string[],
|
||||
): Promise<void>;
|
||||
|
||||
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
||||
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
||||
abstract activity(
|
||||
projectId: string,
|
||||
): AsyncIterableIterator<ActivityReadModel>;
|
||||
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
||||
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
||||
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindActivityRepository {
|
||||
abstract find(params: {
|
||||
projectId: string;
|
||||
issueId?: string;
|
||||
limit: number;
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
before?: string;
|
||||
}): Promise<ActivityReadModel[]>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Page } from './find-issues.repository';
|
||||
|
||||
export abstract class FindCommentsRepository {
|
||||
abstract findById(id: string): Promise<CommentReadModel | null>;
|
||||
abstract findByIssue(
|
||||
issueId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<Page<CommentReadModel>>;
|
||||
abstract countByIssue(issueId: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface IssueFilter {
|
||||
projectId: string;
|
||||
statuses?: IssueStatus[];
|
||||
types?: IssueType[];
|
||||
areas?: IssueArea[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
labels?: string[];
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export abstract class FindIssuesRepository {
|
||||
abstract findById(id: string): Promise<IssueReadModel | null>;
|
||||
abstract findByKey(key: string): Promise<IssueReadModel | null>;
|
||||
abstract findByIds(ids: string[]): Promise<IssueReadModel[]>;
|
||||
abstract search(filter: IssueFilter): Promise<Page<IssueReadModel>>;
|
||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||
abstract releaseProgress(
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ProjectReadModel, ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindProjectsRepository {
|
||||
abstract findById(id: string): Promise<ProjectReadModel | null>;
|
||||
abstract findByKey(key: string): Promise<ProjectReadModel | null>;
|
||||
abstract findByMember(userId: string): Promise<ProjectReadModel[]>;
|
||||
abstract findRole(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectRole | null>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindReleasesRepository {
|
||||
abstract findById(id: string): Promise<ReleaseReadModel | null>;
|
||||
abstract findByProject(projectId: string): Promise<ReleaseReadModel[]>;
|
||||
}
|
||||
16
libs/api/tracker/application/src/lib/ports/index.ts
Normal file
16
libs/api/tracker/application/src/lib/ports/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from './project-keys.repository';
|
||||
export * from './issue-numbers.repository';
|
||||
export * from './release-names.repository';
|
||||
export * from './find-projects.repository';
|
||||
export * from './find-issues.repository';
|
||||
export * from './find-comments.repository';
|
||||
export * from './find-releases.repository';
|
||||
export * from './find-activity.repository';
|
||||
export * from './upsert-materialized-project.repository';
|
||||
export * from './upsert-materialized-issue.repository';
|
||||
export * from './upsert-materialized-comment.repository';
|
||||
export * from './upsert-materialized-release.repository';
|
||||
export * from './upsert-materialized-activity.repository';
|
||||
export * from './change-feed';
|
||||
export * from './user-directory';
|
||||
export * from './release-issues.repository';
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Per-project ticket counter (TS-1, TS-2...). */
|
||||
export abstract class IssueNumbersRepository {
|
||||
/** Atomically reserves the next number of the project. */
|
||||
abstract next(projectId: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Write-side uniqueness of project keys ("TS", "SHOP"...). */
|
||||
export abstract class ProjectKeysRepository {
|
||||
/** Throws BusinessRuleError if the key is already taken. */
|
||||
abstract reserve(key: string, projectId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Write-side lookup over the event store: tickets that were ever put in a
|
||||
* release. Rules about a release (e.g. "no unfinished tickets") must not
|
||||
* depend on the eventually-consistent read model.
|
||||
*/
|
||||
export abstract class ReleaseIssuesRepository {
|
||||
abstract findIssueIds(releaseId: string): Promise<string[]>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Write-side uniqueness of release names inside a project. */
|
||||
export abstract class ReleaseNamesRepository {
|
||||
/** Throws BusinessRuleError if the name is already used in the project. */
|
||||
abstract reserve(
|
||||
projectId: string,
|
||||
name: string,
|
||||
releaseId: string,
|
||||
): Promise<void>;
|
||||
abstract free(projectId: string, name: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedActivityRepository {
|
||||
/** Inserts the entry unless it already exists. Returns true when inserted. */
|
||||
abstract insert(activity: ActivityReadModel): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedCommentRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
comment: Pick<CommentReadModel, 'id' | 'version'> &
|
||||
Partial<CommentReadModel>,
|
||||
): Promise<void>;
|
||||
abstract remove(id: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedIssueRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
issue: Pick<IssueReadModel, 'id' | 'version'> &
|
||||
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>,
|
||||
): Promise<void>;
|
||||
/** Adds a relation to an issue (idempotent). */
|
||||
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
|
||||
abstract removeLink(
|
||||
issueId: string,
|
||||
link: Omit<IssueLinkReadModel, 'issueKey'>,
|
||||
): Promise<void>;
|
||||
abstract setCommentCount(
|
||||
issueId: string,
|
||||
commentCount: number,
|
||||
): Promise<void>;
|
||||
/** Keeps the denormalised release name of every issue in sync. */
|
||||
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedProjectRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
project: Pick<ProjectReadModel, 'id' | 'version'> &
|
||||
Partial<ProjectReadModel>,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedReleaseRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
release: Pick<ReleaseReadModel, 'id' | 'version'> &
|
||||
Partial<ReleaseReadModel>,
|
||||
): Promise<void>;
|
||||
}
|
||||
28
libs/api/tracker/application/src/lib/ports/user-directory.ts
Normal file
28
libs/api/tracker/application/src/lib/ports/user-directory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface DirectoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Outbound port to the identity context. */
|
||||
export abstract class UserDirectory {
|
||||
/**
|
||||
* @param accessToken caller's token, lets the identity server be asked for
|
||||
* users that never used task-stream yet.
|
||||
*/
|
||||
abstract findById(
|
||||
userId: string,
|
||||
accessToken?: string,
|
||||
): Promise<DirectoryUser | null>;
|
||||
|
||||
/** Resolves user ids to {id, name} for read models, in one lookup. */
|
||||
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
|
||||
}
|
||||
|
||||
export interface UserRefs {
|
||||
/** Unknown users keep their id as name. */
|
||||
of(userId: string): UserRefReadModel;
|
||||
ofNullable(userId: string | null | undefined): UserRefReadModel | null;
|
||||
}
|
||||
118
libs/api/tracker/application/src/lib/projects.service.ts
Normal file
118
libs/api/tracker/application/src/lib/projects.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddProjectMemberCommand } from './commands/add-project-member.command';
|
||||
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateProjectCommand } from './commands/create-project.command';
|
||||
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
|
||||
import { UpdateProjectCommand } from './commands/update-project.command';
|
||||
import { IssueFilter } from './ports/find-issues.repository';
|
||||
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
||||
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||
import { GetProjectQuery } from './queries/get-project.query';
|
||||
import { GetReleasesQuery } from './queries/get-releases.query';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
input: { key: string; name: string; description?: string | null },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateProjectCommand(
|
||||
actorId,
|
||||
input.key,
|
||||
input.name,
|
||||
input.description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
changes: { name?: string; description?: string | null },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateProjectCommand(actorId, projectId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
addMember(
|
||||
actorId: string,
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
accessToken?: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AddProjectMemberCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
input.userId,
|
||||
input.role,
|
||||
accessToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
changeMemberRole(
|
||||
actorId: string,
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new ChangeProjectMemberRoleCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
input.userId,
|
||||
input.role,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
removeMember(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new RemoveProjectMemberCommand(actorId, projectId, userId),
|
||||
);
|
||||
}
|
||||
|
||||
findMine(viewerId: string): Promise<ProjectReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyProjectsQuery(viewerId));
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<ProjectReadModel> {
|
||||
return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
stats(viewerId: string, projectId: string): Promise<ProjectStatsReadModel> {
|
||||
return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
board(
|
||||
viewerId: string,
|
||||
filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
perColumn?: number,
|
||||
): Promise<BoardColumn[]> {
|
||||
return this.queryBus.execute(
|
||||
new GetBoardQuery(viewerId, filter, perColumn),
|
||||
);
|
||||
}
|
||||
|
||||
releases(viewerId: string, projectId: string): Promise<ReleaseReadModel[]> {
|
||||
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ReadAccess } from './read-access';
|
||||
import { CanViewProjectQuery } from './can-view-project.query';
|
||||
|
||||
@QueryHandler(CanViewProjectQuery)
|
||||
export class CanViewProjectQueryHandler
|
||||
implements IQueryHandler<CanViewProjectQuery, boolean>
|
||||
{
|
||||
constructor(private readonly access: ReadAccess) {}
|
||||
|
||||
async execute(query: CanViewProjectQuery): Promise<boolean> {
|
||||
return this.access.canView(query.projectId, query.viewerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
|
||||
export class CanViewProjectQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
import { GetActivityQuery } from './get-activity.query';
|
||||
|
||||
@QueryHandler(GetActivityQuery)
|
||||
export class GetActivityQueryHandler
|
||||
implements IQueryHandler<GetActivityQuery, ActivityReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
return this.findActivity.find({
|
||||
projectId: query.projectId,
|
||||
issueId: query.issueId,
|
||||
limit: Math.min(query.limit, 200),
|
||||
before: query.before,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetActivityQuery extends Query<ActivityReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly issueId?: string,
|
||||
public readonly limit = 50,
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
public readonly before?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueStatus } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { BoardColumn, GetBoardQuery } from './get-board.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
const BOARD_COLUMNS: IssueStatus[] = [
|
||||
IssueStatus.BACKLOG,
|
||||
IssueStatus.TODO,
|
||||
IssueStatus.IN_PROGRESS,
|
||||
IssueStatus.IN_REVIEW,
|
||||
IssueStatus.READY_FOR_QA,
|
||||
IssueStatus.IN_QA,
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.DONE,
|
||||
];
|
||||
|
||||
@QueryHandler(GetBoardQuery)
|
||||
export class GetBoardQueryHandler
|
||||
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
||||
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
||||
return Promise.all(
|
||||
BOARD_COLUMNS.map(async (status) => {
|
||||
const page = await this.findIssues.search({
|
||||
...query.filter,
|
||||
statuses: [status],
|
||||
limit: Math.min(query.perColumn, 200),
|
||||
offset: 0,
|
||||
});
|
||||
return { status, issues: page.items, total: page.total };
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
|
||||
import { IssueFilter } from '../ports/find-issues.repository';
|
||||
|
||||
export interface BoardColumn {
|
||||
status: IssueStatus;
|
||||
issues: IssueReadModel[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Kanban board: one column per status. */
|
||||
export class GetBoardQuery extends Query<BoardColumn[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
public readonly perColumn = 50,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
import { GetCommentsQuery } from './get-comments.query';
|
||||
|
||||
@QueryHandler(GetCommentsQuery)
|
||||
export class GetCommentsQueryHandler
|
||||
implements IQueryHandler<GetCommentsQuery, Page<CommentReadModel>>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetCommentsQuery): Promise<Page<CommentReadModel>> {
|
||||
const issue = this.access.assertFound(
|
||||
await this.findIssues.findById(query.issueId),
|
||||
'Issue',
|
||||
query.issueId,
|
||||
);
|
||||
await this.access.assertCanView(issue.projectId, query.viewerId);
|
||||
return this.findComments.findByIssue(
|
||||
issue.id,
|
||||
Math.min(query.limit, 200),
|
||||
query.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Page } from '../ports/find-issues.repository';
|
||||
|
||||
export class GetCommentsQuery extends Query<Page<CommentReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly limit = 50,
|
||||
public readonly offset = 0,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user