Formatted text
- Descriptions, comments, replies and release notes render Markdown,
sanitised with DOMPurify (tested against scripts, handlers and
javascript: links). A pasted image is uploaded and written as
; the web fetches it with the reader's session,
so it works on private projects. Every text box has a preview. The
attachment upload now answers its attachmentId.
List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
text, open/closed, type, priority and assignee; status, priority and
assignee change in place; bulk edit (the backlog's bar, now shared).
Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
when reading with one query per board or page; shown on cards, list
rows and the ticket.
Public proposals
- A public project can take bug reports and ideas from anyone
(acceptsProposals): POST /api/explore/projects/{project}/proposals and
the form at /p/:key/proponer. They become tickets labelled «propuesta»,
opened by TaskStream; a honeypot field and 5 per address every 15
minutes keep spam out. No e-mail is asked: it would be public.
Votes
- Anyone signed in votes on a public project's tickets, members on a
private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
voted on each ticket, buttons on the ticket and the roadmap, a column
in the list. The showcase carries votes and the open proposals.
MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
351 lines
9.7 KiB
TypeScript
351 lines
9.7 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ActivityReadModel,
|
|
EpicReadModel,
|
|
IssueDetails,
|
|
IssueLinkType,
|
|
IssueReadModel,
|
|
} from '@task-stream/api/tracker/domain';
|
|
import { AssignIssueCommand } from './commands/assign-issue.command';
|
|
import { DuplicateIssueCommand } from './commands/duplicate-issue.command';
|
|
import { VoteIssueCommand } from './commands/vote-issue.command';
|
|
import {
|
|
AttachFileCommand,
|
|
AttachmentResult,
|
|
IncomingFile,
|
|
} from './commands/attach-file.command';
|
|
import { DetachFileCommand } from './commands/detach-file.command';
|
|
import { WatchIssueCommand } from './commands/watch-issue.command';
|
|
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
|
import { RankIssueCommand } from './commands/rank-issue.command';
|
|
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
|
|
import { GetTrashQuery } from './queries/get-trash.query';
|
|
import {
|
|
BulkChange,
|
|
BulkResult,
|
|
BulkUpdateIssuesCommand,
|
|
} from './commands/bulk-update-issues';
|
|
import {
|
|
AddChecklistItemCommand,
|
|
RemoveChecklistItemCommand,
|
|
UpdateChecklistItemCommand,
|
|
} from './commands/change-checklist.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 {
|
|
AttachmentDownload,
|
|
GetAttachmentQuery,
|
|
} from './queries/get-attachment.query';
|
|
import { GetIssueQuery } from './queries/get-issue.query';
|
|
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
|
import { SearchIssuesQuery } from './queries/search-issues.query';
|
|
import { GetEpicsQuery } from './queries/get-epics.query';
|
|
import {
|
|
MyIssuesFilter,
|
|
SearchMyIssuesQuery,
|
|
} from './queries/search-my-issues.query';
|
|
|
|
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
|
projectId: string;
|
|
/** A type id of the project's settings. */
|
|
type: string;
|
|
title: string;
|
|
assigneeId?: string | null;
|
|
releaseId?: string | null;
|
|
/** Epic or ticket the new one breaks down. */
|
|
parentId?: 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,
|
|
parentId,
|
|
...details
|
|
} = input;
|
|
return this.commandBus.execute(
|
|
new CreateIssueCommand(
|
|
actorId,
|
|
projectId,
|
|
type,
|
|
title,
|
|
details,
|
|
assigneeId,
|
|
releaseId,
|
|
parentId,
|
|
),
|
|
);
|
|
}
|
|
|
|
update(
|
|
actorId: string,
|
|
issueId: string,
|
|
changes: IssueDetails,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new UpdateIssueCommand(actorId, issueId, changes),
|
|
);
|
|
}
|
|
|
|
changeStatus(
|
|
actorId: string,
|
|
issueId: string,
|
|
status: string,
|
|
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),
|
|
);
|
|
}
|
|
|
|
/** Attaches a file to the ticket, or to one of its comments. */
|
|
attach(
|
|
actorId: string,
|
|
issueId: string,
|
|
file: IncomingFile,
|
|
commentId: string | null = null,
|
|
): Promise<AttachmentResult> {
|
|
return this.commandBus.execute(
|
|
new AttachFileCommand(actorId, issueId, file, commentId),
|
|
);
|
|
}
|
|
|
|
detach(
|
|
actorId: string,
|
|
issueId: string,
|
|
attachmentId: string,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new DetachFileCommand(actorId, issueId, attachmentId),
|
|
);
|
|
}
|
|
|
|
/** The actor follows (or stops following) the ticket. */
|
|
watch(
|
|
actorId: string,
|
|
issueId: string,
|
|
watching: boolean,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new WatchIssueCommand(actorId, issueId, actorId, watching),
|
|
);
|
|
}
|
|
|
|
/** A ticket's file, with its bytes, for whoever can read the ticket. */
|
|
download(
|
|
viewerId: string | null,
|
|
issueId: string,
|
|
attachmentId: string,
|
|
): Promise<AttachmentDownload> {
|
|
return this.queryBus.execute(
|
|
new GetAttachmentQuery(viewerId, issueId, attachmentId),
|
|
);
|
|
}
|
|
|
|
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
|
|
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
|
}
|
|
|
|
findMany(viewerId: string | null, ids: string[]): Promise<IssueReadModel[]> {
|
|
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
|
}
|
|
|
|
/** Places the ticket under `parentId` (null: back to the top). */
|
|
setParent(
|
|
actorId: string,
|
|
issueId: string,
|
|
parentId: string | null,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new SetIssueParentCommand(actorId, issueId, parentId),
|
|
);
|
|
}
|
|
|
|
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
|
|
rank(
|
|
actorId: string,
|
|
issueId: string,
|
|
above: string | null,
|
|
below: string | null,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new RankIssueCommand(actorId, issueId, above, below),
|
|
);
|
|
}
|
|
|
|
/** The same change on several tickets; each one may succeed or not. */
|
|
bulkUpdate(
|
|
actorId: string,
|
|
issueIds: string[],
|
|
change: BulkChange,
|
|
): Promise<BulkResult> {
|
|
return this.commandBus.execute(
|
|
new BulkUpdateIssuesCommand(actorId, issueIds, change),
|
|
);
|
|
}
|
|
|
|
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
|
trash(actorId: string, issueId: string): Promise<CommandResult> {
|
|
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
|
}
|
|
|
|
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
|
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
|
}
|
|
|
|
/** Votes for the ticket (`on`) or takes the vote back. */
|
|
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
|
|
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
|
|
}
|
|
|
|
/** A new ticket like this one (what it says, not where it stands). */
|
|
duplicate(actorId: string, issueId: string): Promise<CommandResult> {
|
|
return this.commandBus.execute(new DuplicateIssueCommand(actorId, issueId));
|
|
}
|
|
|
|
trashed(
|
|
viewerId: string | null,
|
|
projectId: string,
|
|
): Promise<IssueReadModel[]> {
|
|
return this.queryBus.execute(new GetTrashQuery(viewerId, projectId));
|
|
}
|
|
|
|
addChecklistItem(
|
|
actorId: string,
|
|
issueId: string,
|
|
text: string,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new AddChecklistItemCommand(actorId, issueId, text),
|
|
);
|
|
}
|
|
|
|
updateChecklistItem(
|
|
actorId: string,
|
|
issueId: string,
|
|
itemId: string,
|
|
changes: { text?: string; done?: boolean },
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
|
|
);
|
|
}
|
|
|
|
removeChecklistItem(
|
|
actorId: string,
|
|
issueId: string,
|
|
itemId: string,
|
|
): Promise<CommandResult> {
|
|
return this.commandBus.execute(
|
|
new RemoveChecklistItemCommand(actorId, issueId, itemId),
|
|
);
|
|
}
|
|
|
|
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
|
|
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
|
|
}
|
|
|
|
search(
|
|
viewerId: string | null,
|
|
filter: IssueFilter,
|
|
): Promise<Page<IssueReadModel>> {
|
|
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
|
|
}
|
|
|
|
/** Across all my projects: assigned to me, or any ticket (e.g. waiting for QA). */
|
|
mine(
|
|
viewerId: string,
|
|
filter: MyIssuesFilter,
|
|
): Promise<Page<IssueReadModel>> {
|
|
return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter));
|
|
}
|
|
|
|
activity(
|
|
viewerId: string | null,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|