Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
|
import {
|
|
ActivityReadModel,
|
|
CommentAddedEvent,
|
|
CommentDeletedEvent,
|
|
CommentEditedEvent,
|
|
IssueAssignedEvent,
|
|
IssueAttachmentAddedEvent,
|
|
IssueAttachmentRemovedEvent,
|
|
IssueChecklistItemAddedEvent,
|
|
IssueChecklistItemRemovedEvent,
|
|
IssueChecklistItemUpdatedEvent,
|
|
IssueParentChangedEvent,
|
|
IssueCreatedEvent,
|
|
IssueLinkedEvent,
|
|
IssueReleaseChangedEvent,
|
|
IssueStatusChangedEvent,
|
|
IssueUnlinkedEvent,
|
|
IssueUpdatedEvent,
|
|
ProjectCreatedEvent,
|
|
ProjectMemberAddedEvent,
|
|
ProjectMemberRemovedEvent,
|
|
ProjectMemberRoleChangedEvent,
|
|
ProjectUpdatedEvent,
|
|
ReleaseArchivedEvent,
|
|
ReleaseCreatedEvent,
|
|
ReleaseShippedEvent,
|
|
ReleaseUpdatedEvent,
|
|
TrackerEvent,
|
|
DEFAULT_PROJECT_SETTINGS,
|
|
SYSTEM_ACTOR_ID,
|
|
SYSTEM_ACTOR_NAME,
|
|
ProjectSettingsChangedEvent,
|
|
ProjectWorkflow,
|
|
} 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,
|
|
ProjectSettingsChangedEvent,
|
|
ProjectMemberAddedEvent,
|
|
ProjectMemberRemovedEvent,
|
|
ProjectMemberRoleChangedEvent,
|
|
IssueCreatedEvent,
|
|
IssueUpdatedEvent,
|
|
IssueStatusChangedEvent,
|
|
IssueAssignedEvent,
|
|
IssueLinkedEvent,
|
|
IssueUnlinkedEvent,
|
|
IssueReleaseChangedEvent,
|
|
IssueAttachmentAddedEvent,
|
|
IssueAttachmentRemovedEvent,
|
|
IssueParentChangedEvent,
|
|
IssueChecklistItemAddedEvent,
|
|
IssueChecklistItemUpdatedEvent,
|
|
IssueChecklistItemRemovedEvent,
|
|
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);
|
|
// The project was deleted (cascade in progress): no feed to write to.
|
|
if (!project) return;
|
|
const mentioned = [
|
|
event.actorId,
|
|
(event as { userId?: string }).userId,
|
|
(event as { assigneeId?: string }).assigneeId,
|
|
];
|
|
const users = await this.users.refs(mentioned);
|
|
const actorOf = (id: string) =>
|
|
id === SYSTEM_ACTOR_ID ? { id, name: SYSTEM_ACTOR_NAME } : users.of(id);
|
|
|
|
const activity: ActivityReadModel = {
|
|
id: `${event.aggregateId}:${versionOf(event)}`,
|
|
type: event.constructor.name,
|
|
projectId: event.projectId,
|
|
issueId,
|
|
issueKey,
|
|
actor: actorOf(event.actorId),
|
|
summary: describeEvent(event, {
|
|
actor: actorOf(event.actorId).name,
|
|
user: (id) => users.ofNullable(id)?.name ?? '?',
|
|
issueKey: issueKey ?? '',
|
|
projectKey: project?.key ?? '',
|
|
releaseName: release?.name ?? '',
|
|
workflow: new ProjectWorkflow(
|
|
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
|
),
|
|
}),
|
|
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;
|
|
}
|
|
}
|