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
; 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>
This commit is contained in:
86
libs/api/tracker/application/src/lib/blockers.service.ts
Normal file
86
libs/api/tracker/application/src/lib/blockers.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||
|
||||
/** A ticket another one waits for. */
|
||||
export interface Blocker {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const FINISHED = new Set<StatusCategory>([
|
||||
StatusCategory.DONE,
|
||||
StatusCategory.CANCELLED,
|
||||
]);
|
||||
|
||||
/**
|
||||
* What a ticket waits for: the tickets that block it and the ones it
|
||||
* depends on, while they are open. Links keep no status, so it is worked
|
||||
* out when reading; the lookups of one moment (a board, a page of a list)
|
||||
* go to the read model as one query, not one per ticket.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BlockersService {
|
||||
private batch: {
|
||||
ids: Set<string>;
|
||||
result: Promise<Map<string, IssueReadModel>>;
|
||||
} | null = null;
|
||||
|
||||
constructor(private readonly findIssues: FindIssuesRepository) {}
|
||||
|
||||
async blockedBy(issue: IssueReadModel): Promise<Blocker[]> {
|
||||
const ids = blockerIdsOf(issue);
|
||||
if (!ids.length) return [];
|
||||
const found = await this.load(ids);
|
||||
return ids
|
||||
.map((id) => found.get(id))
|
||||
.filter(
|
||||
(blocker): blocker is IssueReadModel =>
|
||||
!!blocker &&
|
||||
!blocker.trashedAt &&
|
||||
!FINISHED.has(blocker.statusCategory),
|
||||
)
|
||||
.map(({ id, key, title }) => ({ id, key, title }));
|
||||
}
|
||||
|
||||
private load(ids: string[]): Promise<Map<string, IssueReadModel>> {
|
||||
if (!this.batch) {
|
||||
const pending = new Set<string>();
|
||||
const result = new Promise<Map<string, IssueReadModel>>(
|
||||
(resolve, reject) =>
|
||||
setImmediate(() => {
|
||||
this.batch = null;
|
||||
this.findIssues
|
||||
.findByIds([...pending])
|
||||
.then((issues) => resolve(new Map(issues.map((i) => [i.id, i]))))
|
||||
.catch(reject);
|
||||
}),
|
||||
);
|
||||
this.batch = { ids: pending, result };
|
||||
}
|
||||
for (const id of ids) this.batch.ids.add(id);
|
||||
return this.batch.result;
|
||||
}
|
||||
}
|
||||
|
||||
/** "X blocks this" and "this depends on X": X is what it waits for. */
|
||||
export function blockerIdsOf(issue: IssueReadModel): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
(issue.links ?? [])
|
||||
.filter(
|
||||
(link) =>
|
||||
(link.type === IssueLinkType.BLOCKS &&
|
||||
link.direction === 'INWARD') ||
|
||||
(link.type === IssueLinkType.DEPENDS_ON &&
|
||||
link.direction === 'OUTWARD'),
|
||||
)
|
||||
.map((link) => link.issueId),
|
||||
),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user