feat: public projects, project deletion and sagas
Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
public project without a token: project, board, tickets, comments,
history, releases, activity, tracking and statistics, over GraphQL and
REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
invalid tokens still rejected. ReadAccess lets anonymous viewers read
public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
notice, sign-in hints), public badge, visibility switch in settings,
no realtime subscriptions without a session.
Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
/api/projects/{project}?confirm=KEY). The key is freed; the read model
drops the project and members are notified (web goes home).
Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
the event store (EventRecorder); the tracker unassigns that user's open
tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
concurrency conflicts retried. Affected streams come from the event store
(TrackerStreamsRepository).
Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,14 @@ Casos de uso del tracker (CQRS).
|
||||
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
|
||||
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
|
||||
- `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`);
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
|
||||
(`ReadAccess`: miembros, o cualquiera si el proyecto es público; `viewerId` null = anónimo);
|
||||
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
|
||||
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
|
||||
notificaciones en tiempo real y feed de actividad.
|
||||
- `sagas/`: `ProjectDeletionSaga` (cascada al borrar un proyecto y sus tickets), `ReleaseArchivedSaga` (los tickets sin
|
||||
terminar salen de una release archivada) y `UserDeactivatedSaga` (desasigna los tickets abiertos de un usuario que
|
||||
Pireon desactiva). Las ejecuta el `EventsBridge`; sus comandos son internos e idempotentes. Tests en `sagas.spec.ts`.
|
||||
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
|
||||
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
|
||||
- `TrackerApplicationModule.withInfrastructure(...)`.
|
||||
|
||||
@@ -18,6 +18,13 @@ 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';
|
||||
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
|
||||
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
|
||||
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
|
||||
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
|
||||
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
||||
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
@@ -40,4 +47,12 @@ export const COMMAND_HANDLERS = [
|
||||
UpdateReleaseCommandHandler,
|
||||
PublishReleaseCommandHandler,
|
||||
ArchiveReleaseCommandHandler,
|
||||
DeleteProjectCommandHandler,
|
||||
// Emitted by sagas only (cascades and reactions to other aggregates).
|
||||
DeleteIssueCommandHandler,
|
||||
DeleteReleaseCommandHandler,
|
||||
PurgeCommentCommandHandler,
|
||||
DropLinksToIssueCommandHandler,
|
||||
DetachIssueFromReleaseCommandHandler,
|
||||
UnassignIssueCommandHandler,
|
||||
];
|
||||
|
||||
@@ -19,6 +19,7 @@ export class CreateProjectCommandHandler
|
||||
key: command.key,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
isPublic: command.isPublic,
|
||||
ownerId: command.actorId,
|
||||
});
|
||||
await this.projectKeys.reserve(project.key, project.id);
|
||||
|
||||
@@ -7,6 +7,7 @@ export class CreateProjectCommand extends Command<CommandResult> {
|
||||
public readonly key: string,
|
||||
public readonly name: string,
|
||||
public readonly description?: string | null,
|
||||
public readonly isPublic = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue } from '@task-stream/api/tracker/domain';
|
||||
import { DeleteIssueCommand } from './delete-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DeleteIssueCommand)
|
||||
export class DeleteIssueCommandHandler
|
||||
implements ICommandHandler<DeleteIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
if (!issue) return;
|
||||
issue.delete(command.actorId);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a ticket of a deleted project. */
|
||||
export class DeleteIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteProjectCommand } from './delete-project.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Tickets, comments and releases follow through ProjectDeletionSaga. */
|
||||
@CommandHandler(DeleteProjectCommand)
|
||||
export class DeleteProjectCommandHandler
|
||||
implements ICommandHandler<DeleteProjectCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly projectKeys: ProjectKeysRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteProjectCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.delete(command.actorId, command.confirmKey);
|
||||
await project.commitAndWait();
|
||||
// The key can be used again by a new project.
|
||||
await this.projectKeys.release(project.key, project.id);
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** The owner deletes a project, typing its key to confirm. */
|
||||
export class DeleteProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly confirmKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Release } from '@task-stream/api/tracker/domain';
|
||||
import { DeleteReleaseCommand } from './delete-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DeleteReleaseCommand)
|
||||
export class DeleteReleaseCommandHandler
|
||||
implements ICommandHandler<DeleteReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteReleaseCommand): Promise<void> {
|
||||
const release = await this.access.live(command.releaseId, Release);
|
||||
if (!release) return;
|
||||
release.delete(command.actorId);
|
||||
await release.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a release of a deleted project. */
|
||||
export class DeleteReleaseCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DetachIssueFromReleaseCommand } from './detach-issue-from-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DetachIssueFromReleaseCommand)
|
||||
export class DetachIssueFromReleaseCommandHandler
|
||||
implements ICommandHandler<DetachIssueFromReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DetachIssueFromReleaseCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
if (!issue || issue.releaseId !== command.releaseId) return;
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
// Finished tickets stay: they are what the release shipped.
|
||||
if (!project || project.workflow.isFinished(issue.status)) return;
|
||||
issue.setRelease(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: takes an unfinished ticket out of an archived release. */
|
||||
export class DetachIssueFromReleaseCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DropLinksToIssueCommand } from './drop-links-to-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DropLinksToIssueCommand)
|
||||
export class DropLinksToIssueCommandHandler
|
||||
implements ICommandHandler<DropLinksToIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DropLinksToIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone, or about to be (its project was deleted too): nothing to tidy.
|
||||
if (!issue || !(await this.access.live(issue.projectId, Project))) return;
|
||||
issue.unlinkAllTo(command.actorId, command.targetIssueId);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: removes the links of `issueId` that point to a deleted ticket. */
|
||||
export class DropLinksToIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly targetIssueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,10 @@ export * from './update-issue.command';
|
||||
export * from './update-project.command';
|
||||
export * from './update-project-settings.command';
|
||||
export * from './update-release.command';
|
||||
export * from './delete-project.command';
|
||||
export * from './delete-issue.command';
|
||||
export * from './delete-release.command';
|
||||
export * from './purge-comment.command';
|
||||
export * from './drop-links-to-issue.command';
|
||||
export * from './detach-issue-from-release.command';
|
||||
export * from './unassign-issue.command';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Comment } from '@task-stream/api/tracker/domain';
|
||||
import { PurgeCommentCommand } from './purge-comment.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(PurgeCommentCommand)
|
||||
export class PurgeCommentCommandHandler
|
||||
implements ICommandHandler<PurgeCommentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: PurgeCommentCommand): Promise<void> {
|
||||
const comment = await this.access.live(command.commentId, Comment);
|
||||
if (!comment || comment.deleted) return;
|
||||
comment.delete(command.actorId, true);
|
||||
await comment.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a comment of a deleted ticket. */
|
||||
export class PurgeCommentCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue } from '@task-stream/api/tracker/domain';
|
||||
import { UnassignIssueCommand } from './unassign-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UnassignIssueCommand)
|
||||
export class UnassignIssueCommandHandler
|
||||
implements ICommandHandler<UnassignIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UnassignIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Someone may have reassigned it meanwhile: only undo our user's assignment.
|
||||
if (!issue || issue.assigneeId !== command.userId) return;
|
||||
issue.assign(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: unassigns a ticket if it is still assigned to `userId` (deactivated in Pireon). */
|
||||
export class UnassignIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly userId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ export class UpdateProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly changes: { name?: string; description?: string | null },
|
||||
public readonly changes: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
|
||||
type Aggregate = VersionedAggregateRoot & {
|
||||
createdAt?: string;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
/** Loads aggregates from the event store, failing with domain errors. */
|
||||
@Injectable()
|
||||
export class WriteAccess {
|
||||
@@ -78,13 +83,31 @@ export class WriteAccess {
|
||||
}
|
||||
}
|
||||
|
||||
private async load<T extends VersionedAggregateRoot & { createdAt?: string }>(
|
||||
/**
|
||||
* For saga commands, which must be idempotent: the aggregate, or null when
|
||||
* it does not exist or was deleted (nothing left to do).
|
||||
*/
|
||||
async live<T extends Aggregate>(
|
||||
id: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await this.load(id, AggregateCls);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async load<T extends Aggregate>(
|
||||
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);
|
||||
// The stream exists but belongs to another kind of aggregate, or the
|
||||
// aggregate was deleted: same answer, it is not there.
|
||||
if (!aggregate.createdAt || aggregate.deleted)
|
||||
throw new NotFoundError(AggregateCls.name, id);
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CommentsService {
|
||||
}
|
||||
|
||||
list(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
ReleaseUpdatedEvent,
|
||||
TrackerEvent,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SYSTEM_ACTOR_ID,
|
||||
SYSTEM_ACTOR_NAME,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -80,12 +82,16 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
? 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)}`,
|
||||
@@ -93,9 +99,9 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
projectId: event.projectId,
|
||||
issueId,
|
||||
issueKey,
|
||||
actor: users.of(event.actorId),
|
||||
actor: actorOf(event.actorId),
|
||||
summary: describeEvent(event, {
|
||||
actor: users.of(event.actorId).name,
|
||||
actor: actorOf(event.actorId).name,
|
||||
user: (id) => users.ofNullable(id)?.name ?? '?',
|
||||
issueKey: issueKey ?? '',
|
||||
projectKey: project?.key ?? '',
|
||||
|
||||
@@ -21,10 +21,16 @@ import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
|
||||
import { ReadModelNotifier } from './read-model-notifier';
|
||||
import { IssueTimelineEventHandler } from './issue-timeline.event-handler';
|
||||
import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler';
|
||||
import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
|
||||
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
|
||||
import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
ActivityEventHandler,
|
||||
ProjectDeletedEventHandler,
|
||||
IssueDeletedEventHandler,
|
||||
ReleaseDeletedEventHandler,
|
||||
CommentAddedEventHandler,
|
||||
CommentDeletedEventHandler,
|
||||
CommentEditedEventHandler,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
|
||||
/** Removes the ticket, its timeline and the links other tickets show to it. */
|
||||
@EventsHandler(IssueDeletedEvent)
|
||||
export class IssueDeletedEventHandler
|
||||
implements IEventHandler<IssueDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueDeletedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.remove(event.aggregateId);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export class ProjectCreatedEventHandler
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
isPublic: project.isPublic ?? false,
|
||||
owner: users.of(project.ownerId),
|
||||
members: project.members.map((m) => ({
|
||||
user: users.of(m.userId),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* Removes the project (and its activity and timeline) from the read model,
|
||||
* and tells its members, whose project lists drop it. Tickets, comments and
|
||||
* releases go as the cascade saga deletes them.
|
||||
*/
|
||||
@EventsHandler(ProjectDeletedEvent)
|
||||
export class ProjectDeletedEventHandler
|
||||
implements IEventHandler<ProjectDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectDeletedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
||||
if (!project) return; // Already removed (event handled twice).
|
||||
await this.changeFeed.publishProject(
|
||||
{
|
||||
action: event.constructor.name,
|
||||
version: versionOf(event),
|
||||
actorId: event.actorId,
|
||||
project: { ...project, version: versionOf(event) },
|
||||
},
|
||||
event.memberIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
|
||||
@EventsHandler(ReleaseDeletedEvent)
|
||||
export class ReleaseDeletedEventHandler
|
||||
implements IEventHandler<ReleaseDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseDeletedEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.remove(event.aggregateId);
|
||||
}
|
||||
}
|
||||
@@ -126,15 +126,18 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<IssueReadModel> {
|
||||
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
|
||||
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
findMany(viewerId: string, ids: string[]): Promise<IssueReadModel[]> {
|
||||
findMany(viewerId: string | null, ids: string[]): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||
}
|
||||
|
||||
search(viewerId: string, filter: IssueFilter): Promise<Page<IssueReadModel>> {
|
||||
search(
|
||||
viewerId: string | null,
|
||||
filter: IssueFilter,
|
||||
): Promise<Page<IssueReadModel>> {
|
||||
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
|
||||
}
|
||||
|
||||
@@ -147,7 +150,7 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
activity(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
params: {
|
||||
projectId: string;
|
||||
issueId?: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export abstract class FindIssuesRepository {
|
||||
filter: CrossProjectIssueFilter,
|
||||
): Promise<Page<IssueReadModel>>;
|
||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/** Every ticket of a project (statistics). */
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
abstract releaseProgress(
|
||||
|
||||
@@ -8,4 +8,6 @@ export abstract class FindProjectsRepository {
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectRole | null>;
|
||||
/** Projects anyone can read, by name. */
|
||||
abstract findPublic(): Promise<ProjectReadModel[]>;
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './user-directory';
|
||||
export * from './release-issues.repository';
|
||||
export * from './upsert-materialized-timeline.repository';
|
||||
export * from './find-timeline.repository';
|
||||
export * from './tracker-streams.repository';
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
export abstract class ProjectKeysRepository {
|
||||
/** Throws BusinessRuleError if the key is already taken. */
|
||||
abstract reserve(key: string, projectId: string): Promise<void>;
|
||||
/** Frees the key of a deleted project (only if still held by it). */
|
||||
abstract release(key: string, projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Write-side lookups over the event store (the source of truth, unlike the
|
||||
* eventually-consistent read model): which streams belong to a project or a
|
||||
* ticket. The cascade saga uses them to delete everything that hangs off a
|
||||
* deleted project.
|
||||
*/
|
||||
export abstract class TrackerStreamsRepository {
|
||||
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
projectId: string,
|
||||
categories: Record<string, StatusCategory>,
|
||||
): Promise<void>;
|
||||
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
|
||||
abstract remove(issueId: string): Promise<void>;
|
||||
/** Keeps the denormalised release name of every issue in sync. */
|
||||
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ export abstract class UpsertMaterializedProjectRepository {
|
||||
project: Pick<ProjectReadModel, 'id' | 'version'> &
|
||||
Partial<ProjectReadModel>,
|
||||
): Promise<void>;
|
||||
/** The project was deleted: its view, activity and timeline go. */
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ export abstract class UpsertMaterializedReleaseRepository {
|
||||
release: Pick<ReleaseReadModel, 'id' | 'version'> &
|
||||
Partial<ReleaseReadModel>,
|
||||
): Promise<void>;
|
||||
abstract remove(releaseId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { DeleteProjectCommand } from './commands/delete-project.command';
|
||||
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
|
||||
import { UpdateProjectCommand } from './commands/update-project.command';
|
||||
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
|
||||
@@ -20,6 +21,7 @@ import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
||||
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||
import { GetProjectQuery } from './queries/get-project.query';
|
||||
import { GetReleasesQuery } from './queries/get-releases.query';
|
||||
|
||||
@@ -32,7 +34,12 @@ export class ProjectsService {
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
input: { key: string; name: string; description?: string | null },
|
||||
input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateProjectCommand(
|
||||
@@ -40,6 +47,7 @@ export class ProjectsService {
|
||||
input.key,
|
||||
input.name,
|
||||
input.description,
|
||||
input.isPublic ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -47,13 +55,32 @@ export class ProjectsService {
|
||||
update(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
changes: { name?: string; description?: string | null },
|
||||
changes: { name?: string; description?: string | null; isPublic?: boolean },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateProjectCommand(actorId, projectId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner only, typing the key to confirm. Tickets, comments and releases
|
||||
* are deleted right after by ProjectDeletionSaga; the key is freed.
|
||||
*/
|
||||
delete(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
confirmKey: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteProjectCommand(actorId, projectId, confirmKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Projects anyone can read, also without signing in. */
|
||||
findPublic(): Promise<ProjectReadModel[]> {
|
||||
return this.queryBus.execute(new GetPublicProjectsQuery());
|
||||
}
|
||||
|
||||
/** Statuses, transitions, ticket types and areas (owners and admins). */
|
||||
updateSettings(
|
||||
actorId: string,
|
||||
@@ -109,16 +136,19 @@ export class ProjectsService {
|
||||
return this.queryBus.execute(new GetMyProjectsQuery(viewerId));
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<ProjectReadModel> {
|
||||
findOne(viewerId: string | null, idOrKey: string): Promise<ProjectReadModel> {
|
||||
return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
stats(viewerId: string, projectId: string): Promise<ProjectStatsReadModel> {
|
||||
stats(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<ProjectStatsReadModel> {
|
||||
return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
board(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
perColumn?: number,
|
||||
): Promise<BoardColumn[]> {
|
||||
@@ -127,13 +157,16 @@ export class ProjectsService {
|
||||
);
|
||||
}
|
||||
|
||||
releases(viewerId: string, projectId: string): Promise<ReleaseReadModel[]> {
|
||||
releases(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<ReleaseReadModel[]> {
|
||||
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||
analytics(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
days: number,
|
||||
): Promise<ProjectAnalyticsReadModel> {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Query } from '@nestjs/cqrs';
|
||||
|
||||
export class CanViewProjectQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetActivityQuery extends Query<ActivityReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly issueId?: string,
|
||||
public readonly limit = 50,
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface BoardColumn {
|
||||
/** Kanban board: one column per status of the project (cancelled ones aside). */
|
||||
export class GetBoardQuery extends Query<BoardColumn[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
public readonly perColumn = 50,
|
||||
) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Page } from '../ports/find-issues.repository';
|
||||
|
||||
export class GetCommentsQuery extends Query<Page<CommentReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly issueId: string,
|
||||
public readonly limit = 50,
|
||||
public readonly offset = 0,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetIssueQuery extends Query<IssueReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
/** Issue id or key ("TS-42"). */
|
||||
public readonly idOrKey: string,
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
import { GetIssuesByIdsQuery } from './get-issues-by-ids.query';
|
||||
|
||||
@QueryHandler(GetIssuesByIdsQuery)
|
||||
@@ -10,14 +10,19 @@ export class GetIssuesByIdsQueryHandler
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIssuesByIdsQuery): Promise<IssueReadModel[]> {
|
||||
const issues = await this.findIssues.findByIds([...new Set(query.ids)]);
|
||||
const visible = new Set(
|
||||
(await this.findProjects.findByMember(query.viewerId)).map((p) => p.id),
|
||||
);
|
||||
return issues.filter((issue) => visible.has(issue.projectId));
|
||||
// Linked tickets may sit in other projects: keep the ones the viewer can read.
|
||||
const visible = new Map<string, boolean>();
|
||||
for (const projectId of new Set(issues.map((issue) => issue.projectId))) {
|
||||
visible.set(
|
||||
projectId,
|
||||
await this.access.canView(projectId, query.viewerId),
|
||||
);
|
||||
}
|
||||
return issues.filter((issue) => visible.get(issue.projectId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetIssuesByIdsQuery extends Query<IssueReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly ids: string[],
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain';
|
||||
/** Statistics of a project over the last `days` days. */
|
||||
export class GetProjectAnalyticsQuery extends Query<ProjectAnalyticsReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly days: number,
|
||||
) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ProjectStatsReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetProjectStatsQuery extends Query<ProjectStatsReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetProjectQuery extends Query<ProjectReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
/** Project id or key ("TS"). */
|
||||
public readonly idOrKey: string,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { GetPublicProjectsQuery } from './get-public-projects.query';
|
||||
|
||||
@QueryHandler(GetPublicProjectsQuery)
|
||||
export class GetPublicProjectsQueryHandler
|
||||
implements IQueryHandler<GetPublicProjectsQuery, ProjectReadModel[]>
|
||||
{
|
||||
constructor(private readonly findProjects: FindProjectsRepository) {}
|
||||
|
||||
execute(): Promise<ProjectReadModel[]> {
|
||||
return this.findProjects.findPublic();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Projects anyone can read (no sign-in needed). */
|
||||
export class GetPublicProjectsQuery extends Query<ProjectReadModel[]> {}
|
||||
@@ -4,7 +4,8 @@ import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export class GetReleaseBurnupQuery extends Query<ReleaseBurnupReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseProgressReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleaseProgressQuery extends Query<ReleaseProgressReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleaseQuery extends Query<ReleaseReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleasesQuery extends Query<ReleaseReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from './get-releases.query';
|
||||
export * from './get-upcoming-releases.query';
|
||||
export * from './search-issues.query';
|
||||
export * from './search-my-issues.query';
|
||||
export * from './get-public-projects.query';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GetPublicProjectsQueryHandler } from './get-public-projects.query-handler';
|
||||
import { CanViewProjectQueryHandler } from './can-view-project.query-handler';
|
||||
import { GetActivityQueryHandler } from './get-activity.query-handler';
|
||||
import { GetBoardQueryHandler } from './get-board.query-handler';
|
||||
@@ -17,6 +18,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
GetPublicProjectsQueryHandler,
|
||||
CanViewProjectQueryHandler,
|
||||
GetActivityQueryHandler,
|
||||
GetBoardQueryHandler,
|
||||
|
||||
@@ -3,23 +3,38 @@ import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
/** Authorisation for the read side, based on the projected memberships. */
|
||||
/**
|
||||
* Authorisation for the read side, based on the projected memberships:
|
||||
* members read their projects; anyone (also anonymous, `viewerId` null)
|
||||
* reads public ones.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReadAccess {
|
||||
constructor(private readonly findProjects: FindProjectsRepository) {}
|
||||
|
||||
/** The viewer's role, or null when they read a public project as an outsider. */
|
||||
async assertCanView(
|
||||
projectId: string,
|
||||
viewerId: string,
|
||||
): Promise<ProjectRole> {
|
||||
const role = await this.findProjects.findRole(projectId, viewerId);
|
||||
viewerId: string | null,
|
||||
): Promise<ProjectRole | null> {
|
||||
const role = viewerId
|
||||
? await this.findProjects.findRole(projectId, viewerId)
|
||||
: null;
|
||||
if (role) return role;
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
// Same answer for "doesn't exist" and "not yours": don't leak projects.
|
||||
if (!role) throw new NotFoundError('Project', projectId);
|
||||
return role;
|
||||
if (!project?.isPublic) throw new NotFoundError('Project', projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
async canView(projectId: string, viewerId: string): Promise<boolean> {
|
||||
return (await this.findProjects.findRole(projectId, viewerId)) !== null;
|
||||
async canView(projectId: string, viewerId: string | null): Promise<boolean> {
|
||||
try {
|
||||
await this.assertCanView(projectId, viewerId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
assertFound<T>(value: T | null, entity: string, id: string): T {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { IssueFilter, Page } from '../ports/find-issues.repository';
|
||||
|
||||
export class SearchIssuesQuery extends Query<Page<IssueReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly filter: IssueFilter,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -70,7 +70,10 @@ export class ReleasesService {
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string, releaseId: string): Promise<ReleaseReadModel> {
|
||||
findOne(
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseReadModel> {
|
||||
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId));
|
||||
}
|
||||
|
||||
@@ -80,7 +83,7 @@ export class ReleasesService {
|
||||
}
|
||||
|
||||
progress(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel> {
|
||||
return this.queryBus.execute(
|
||||
@@ -89,7 +92,10 @@ export class ReleasesService {
|
||||
}
|
||||
|
||||
/** Scope vs finished tickets of the release, day by day. */
|
||||
burnup(viewerId: string, releaseId: string): Promise<ReleaseBurnupReadModel> {
|
||||
burnup(
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseBurnupReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetReleaseBurnupQuery(viewerId, releaseId),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueDeletedEvent,
|
||||
ProjectDeletedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
|
||||
/**
|
||||
* Deleting a project cascades, one aggregate per command (each stream ends
|
||||
* with its own "deleted" event):
|
||||
*
|
||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||
* of other projects that pointed to it)
|
||||
*
|
||||
* Who to delete comes from the event store, not the read model. The changes
|
||||
* are attributed to whoever deleted the project.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProjectDeletionSaga {
|
||||
constructor(private readonly streams: TrackerStreamsRepository) {}
|
||||
|
||||
@Saga()
|
||||
projectDeleted = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(ProjectDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.contentsOf(event.projectId)).pipe(
|
||||
mergeMap(({ issueIds, releaseIds }) => [
|
||||
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
|
||||
...releaseIds.map(
|
||||
(id) => new DeleteReleaseCommand(event.actorId, id),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@Saga()
|
||||
issueDeleted = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(IssueDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.dependentsOf(event.aggregateId)).pipe(
|
||||
mergeMap(({ commentIds, linkingIds }) => [
|
||||
...commentIds.map(
|
||||
(id) => new PurgeCommentCommand(event.actorId, id),
|
||||
),
|
||||
...linkingIds.map(
|
||||
(id) =>
|
||||
new DropLinksToIssueCommand(
|
||||
event.actorId,
|
||||
id,
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
private async contentsOf(projectId: string) {
|
||||
const [issueIds, releaseIds] = await Promise.all([
|
||||
this.streams.issueIdsOfProject(projectId),
|
||||
this.streams.releaseIdsOfProject(projectId),
|
||||
]);
|
||||
return { issueIds, releaseIds };
|
||||
}
|
||||
|
||||
private async dependentsOf(issueId: string) {
|
||||
const [commentIds, linkingIds] = await Promise.all([
|
||||
this.streams.commentIdsOfIssue(issueId),
|
||||
this.streams.issueIdsLinkingTo(issueId),
|
||||
]);
|
||||
return {
|
||||
commentIds,
|
||||
linkingIds: linkingIds.filter((id) => id !== issueId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import { ReleaseArchivedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { DetachIssueFromReleaseCommand } from '../commands/detach-issue-from-release.command';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
|
||||
/**
|
||||
* An archived release no longer accepts changes, so its unfinished tickets
|
||||
* would be stuck in it: they leave it (back to "no release"), attributed to
|
||||
* whoever archived it. Finished tickets stay, as the record of what shipped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReleaseArchivedSaga {
|
||||
constructor(private readonly releaseIssues: ReleaseIssuesRepository) {}
|
||||
|
||||
@Saga()
|
||||
releaseArchived = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(ReleaseArchivedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.releaseIssues.findIssueIds(event.aggregateId)).pipe(
|
||||
mergeMap((issueIds) =>
|
||||
issueIds.map(
|
||||
(issueId) =>
|
||||
new DetachIssueFromReleaseCommand(
|
||||
event.actorId,
|
||||
issueId,
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
237
libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
Normal file
237
libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CqrsModule,
|
||||
EventBus,
|
||||
ICommand,
|
||||
ISaga,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
AggregateRehydrator,
|
||||
EventStore,
|
||||
} from '@task-stream/api/shared/application';
|
||||
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueLinkType,
|
||||
ProjectRole,
|
||||
Release,
|
||||
TRACKER_FACTORIES,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
InMemoryEventStore,
|
||||
InMemoryEventStorePublisher,
|
||||
InMemoryIssueNumbers,
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseIssues,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { AddProjectMemberCommand } from '../commands/add-project-member.command';
|
||||
import { ArchiveReleaseCommand } from '../commands/archive-release.command';
|
||||
import { ChangeIssueStatusCommand } from '../commands/change-issue-status.command';
|
||||
import { COMMAND_HANDLERS } from '../commands/command-handlers';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { CreateProjectCommand } from '../commands/create-project.command';
|
||||
import { CreateReleaseCommand } from '../commands/create-release.command';
|
||||
import { DeleteProjectCommand } from '../commands/delete-project.command';
|
||||
import { LinkIssuesCommand } from '../commands/link-issues.command';
|
||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const users = new FakeUserDirectory().add('owner').add('dev').add('qa');
|
||||
// Read model double for the user saga: open tickets are tracked by the test.
|
||||
const openAssigned = new Map<string, string[]>();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
ProjectDeletionSaga,
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
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),
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
findOpenAssignedTo: async (userId: string) =>
|
||||
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).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);
|
||||
const sagas: ISaga[] = [
|
||||
...Object.values(moduleRef.get(ProjectDeletionSaga)),
|
||||
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
|
||||
...Object.values(moduleRef.get(UserDeactivatedSaga)),
|
||||
].filter((value): value is ISaga => typeof value === 'function');
|
||||
|
||||
/** What the events bridge does: every stored event through every saga, until quiet. */
|
||||
let cursor = 0;
|
||||
const react = async (event: object) => {
|
||||
for (const saga of sagas) {
|
||||
const commands = await lastValueFrom(saga(of(event)).pipe(toArray()));
|
||||
for (const command of commands)
|
||||
await commandBus.execute(command as ICommand);
|
||||
}
|
||||
};
|
||||
const drain = async () => {
|
||||
while (cursor < store.events.length) {
|
||||
const stored = store.events[cursor++];
|
||||
const [event] = (await store.getEventsByStreamId(stored.streamId))
|
||||
.filter((e) => e.position === stored.position)
|
||||
.map((e) => e.data);
|
||||
await react(event);
|
||||
}
|
||||
};
|
||||
|
||||
const access = moduleRef.get(WriteAccess);
|
||||
const rehydrator = moduleRef.get(AggregateRehydrator);
|
||||
return { run, drain, react, access, rehydrator, openAssigned };
|
||||
}
|
||||
|
||||
const ticket = (projectId: string, title: string) =>
|
||||
new CreateIssueCommand('owner', projectId, 'TASK', title, {});
|
||||
|
||||
describe('sagas', () => {
|
||||
it('deleting a project cascades to its tickets, comments, releases and links', async () => {
|
||||
const { run, drain, access, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const api = (await run(new CreateProjectCommand('owner', 'API', 'Api'))).id;
|
||||
const a = (await run(ticket(web, 'A'))).id;
|
||||
const b = (await run(ticket(web, 'B'))).id;
|
||||
const c = (await run(ticket(api, 'C'))).id;
|
||||
const comments = [
|
||||
(await run(new AddCommentCommand('owner', a, 'one'))).id,
|
||||
(await run(new AddCommentCommand('owner', a, 'two'))).id,
|
||||
];
|
||||
await run(new LinkIssuesCommand('owner', a, IssueLinkType.BLOCKS, b));
|
||||
await run(new LinkIssuesCommand('owner', c, IssueLinkType.DEPENDS_ON, a));
|
||||
const release = (
|
||||
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
|
||||
).id;
|
||||
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('dev', web, 'WEB')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new DeleteProjectCommand('owner', web, 'web'));
|
||||
await drain();
|
||||
|
||||
for (const [id, Cls] of [
|
||||
[a, Issue],
|
||||
[b, Issue],
|
||||
[release, Release],
|
||||
[comments[0], Comment],
|
||||
[comments[1], Comment],
|
||||
] as const) {
|
||||
expect(await access.live(id, Cls as never)).toBeNull();
|
||||
}
|
||||
// The ticket of the other project no longer points to the deleted one.
|
||||
expect((await rehydrator.rehydrate(c, Issue)).links).toEqual([]);
|
||||
// Commands on the deleted project fail as if it never existed.
|
||||
await expect(run(ticket(web, 'late'))).rejects.toThrow(NotFoundError);
|
||||
// And its key is free again.
|
||||
await expect(
|
||||
run(new CreateProjectCommand('owner', 'WEB', 'Web again')),
|
||||
).resolves.toMatchObject({ key: 'WEB' });
|
||||
});
|
||||
|
||||
it('only the owner deletes a project', async () => {
|
||||
const { run } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('owner', web, 'NOPE')),
|
||||
).rejects.toThrow(/WEB/);
|
||||
await run(
|
||||
new AddProjectMemberCommand('owner', web, 'dev', ProjectRole.ADMIN),
|
||||
);
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('dev', web, 'WEB')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('archiving a release lets its unfinished tickets go', async () => {
|
||||
const { run, drain, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const release = (
|
||||
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
|
||||
).id;
|
||||
const open = (
|
||||
await run(
|
||||
new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, release),
|
||||
)
|
||||
).id;
|
||||
const done = (
|
||||
await run(
|
||||
new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, release),
|
||||
)
|
||||
).id;
|
||||
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
|
||||
await run(new ChangeIssueStatusCommand('owner', done, status));
|
||||
|
||||
await run(new ArchiveReleaseCommand('owner', release));
|
||||
await drain();
|
||||
|
||||
expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBeNull();
|
||||
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
|
||||
});
|
||||
|
||||
it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
|
||||
const { run, react, rehydrator, openAssigned } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
for (const user of ['dev', 'qa'])
|
||||
await run(
|
||||
new AddProjectMemberCommand('owner', web, user, ProjectRole.MEMBER),
|
||||
);
|
||||
const mine = (await run(ticket(web, 'mine'))).id;
|
||||
const moved = (await run(ticket(web, 'moved'))).id;
|
||||
await run(new AssignIssueCommand('owner', mine, 'dev'));
|
||||
// The read model still lists it as dev's, but it was reassigned meanwhile.
|
||||
await run(new AssignIssueCommand('owner', moved, 'qa'));
|
||||
openAssigned.set('dev', [mine, moved]);
|
||||
|
||||
await react(new UserDeactivatedEvent('dev'));
|
||||
|
||||
const unassigned = await rehydrator.rehydrate(mine, Issue);
|
||||
expect(unassigned.assigneeId).toBeNull();
|
||||
expect((await rehydrator.rehydrate(moved, Issue)).assigneeId).toBe('qa');
|
||||
});
|
||||
});
|
||||
10
libs/api/tracker/application/src/lib/sagas/sagas.ts
Normal file
10
libs/api/tracker/application/src/lib/sagas/sagas.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
|
||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||
export const SAGAS = [
|
||||
ProjectDeletionSaga,
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { UnassignIssueCommand } from '../commands/unassign-issue.command';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
|
||||
/**
|
||||
* Pireon deactivated a user (identity context, `user.deactivated` webhook):
|
||||
* their open tickets in every project become unassigned, so nobody waits on
|
||||
* someone who is gone. Done by the system actor ("TaskStream").
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserDeactivatedSaga {
|
||||
constructor(private readonly findIssues: FindIssuesRepository) {}
|
||||
|
||||
@Saga()
|
||||
userDeactivated = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(UserDeactivatedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.findIssues.findOpenAssignedTo(event.userId)).pipe(
|
||||
mergeMap((issues) =>
|
||||
issues.map(
|
||||
(issue) =>
|
||||
new UnassignIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
issue.id,
|
||||
event.userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ProjectsService } from './projects.service';
|
||||
import { QUERY_HANDLERS } from './queries/query-handlers';
|
||||
import { ReadAccess } from './queries/read-access';
|
||||
import { ReleasesService } from './releases.service';
|
||||
import { SAGAS } from './sagas/sagas';
|
||||
import { WorkflowsService } from './workflows.service';
|
||||
|
||||
const SERVICES = [
|
||||
@@ -41,6 +42,7 @@ const SERVICES = [
|
||||
...COMMAND_HANDLERS,
|
||||
...QUERY_HANDLERS,
|
||||
...EVENT_HANDLERS,
|
||||
...SAGAS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IssueNumbersRepository } from '../lib/ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../lib/ports/project-keys.repository';
|
||||
import { ReleaseIssuesRepository } from '../lib/ports/release-issues.repository';
|
||||
import { ReleaseNamesRepository } from '../lib/ports/release-names.repository';
|
||||
import { TrackerStreamsRepository } from '../lib/ports/tracker-streams.repository';
|
||||
import {
|
||||
DirectoryUser,
|
||||
UserDirectory,
|
||||
@@ -128,6 +129,9 @@ export class InMemoryProjectKeys extends ProjectKeysRepository {
|
||||
throw new BusinessRuleError(`Project key ${key} is already taken`);
|
||||
this.keys.add(key);
|
||||
}
|
||||
async release(key: string) {
|
||||
this.keys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryIssueNumbers extends IssueNumbersRepository {
|
||||
@@ -189,3 +193,54 @@ export class InMemoryReleaseIssues extends ReleaseIssuesRepository {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Same lookups as the Postgres adapter, over the in-memory events. */
|
||||
export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
||||
constructor(private readonly store: InMemoryEventStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private streams(
|
||||
type: string,
|
||||
matches: (data: Record<string, unknown>) => boolean,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
this.store.events
|
||||
.filter(
|
||||
(e) =>
|
||||
e.type === type && matches(e.data as Record<string, unknown>),
|
||||
)
|
||||
.map((e) => e.streamId),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async issueIdsOfProject(projectId: string) {
|
||||
return this.streams(
|
||||
'IssueCreatedEvent',
|
||||
(d) => d['projectId'] === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
async releaseIdsOfProject(projectId: string) {
|
||||
return this.streams(
|
||||
'ReleaseCreatedEvent',
|
||||
(d) => d['projectId'] === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
async commentIdsOfIssue(issueId: string) {
|
||||
return this.streams(
|
||||
'CommentAddedEvent',
|
||||
(d) => (d['comment'] as { issueId: string }).issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
async issueIdsLinkingTo(issueId: string) {
|
||||
return this.streams(
|
||||
'IssueLinkedEvent',
|
||||
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user