Files
task-stream/libs/api/tracker/application/src/lib/issues.service.ts
Josep Piguillem f2001aca88 feat: formatted text, a list view, ticket details, public proposals and votes
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
  ![name](attachment:<id>); 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>
2026-10-09 21:36:11 +02:00

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,
),
);
}
}