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>
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
import {
|
|
ForbiddenError,
|
|
NotFoundError,
|
|
assertThat,
|
|
} from '@task-stream/api/shared/domain';
|
|
import {
|
|
IssuePriority,
|
|
SYSTEM_ACTOR_ID,
|
|
} from '@task-stream/api/tracker/domain';
|
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
|
import { CommandResult } from './command-result';
|
|
import { CreateIssueCommand } from './create-issue.command';
|
|
import { ProposalKind, SubmitProposalCommand } from './submit-proposal.command';
|
|
import { WriteAccess } from './write-access';
|
|
|
|
/** The label every proposal carries, for the team to find and sort them. */
|
|
export const PROPOSAL_LABEL = 'propuesta';
|
|
|
|
/** Preferred ticket type of each kind, when the project has it active. */
|
|
const TYPE_OF: Record<ProposalKind, string> = { BUG: 'BUG', IDEA: 'FEATURE' };
|
|
|
|
/**
|
|
* Only a public project that takes proposals accepts one. It is opened by
|
|
* TaskStream (nobody signed in), in the first status, as a Bug or a Feature
|
|
* (or the first active type if the project has neither), and signed with
|
|
* the name they gave, if any.
|
|
*/
|
|
@CommandHandler(SubmitProposalCommand)
|
|
export class SubmitProposalCommandHandler
|
|
implements ICommandHandler<SubmitProposalCommand>
|
|
{
|
|
constructor(
|
|
private readonly findProjects: FindProjectsRepository,
|
|
private readonly access: WriteAccess,
|
|
private readonly commandBus: CommandBus,
|
|
) {}
|
|
|
|
async execute(command: SubmitProposalCommand): Promise<CommandResult> {
|
|
const found =
|
|
(await this.findProjects.findById(command.project)) ??
|
|
(await this.findProjects.findByKey(command.project.toUpperCase()));
|
|
if (!found?.isPublic) throw new NotFoundError('Project', command.project);
|
|
if (!found.acceptsProposals)
|
|
throw new ForbiddenError(`${found.key} does not take proposals`);
|
|
|
|
const title = command.title.trim();
|
|
assertThat(
|
|
title.length >= 3 && title.length <= 150,
|
|
'A proposal needs a title of 3 to 150 characters',
|
|
);
|
|
const text = command.description.trim();
|
|
assertThat(text.length <= 5000, 'A proposal is 5000 characters at most');
|
|
|
|
const project = await this.access.project(found.id);
|
|
const active = project.settings.issueTypes.filter((t) => !t.archived);
|
|
const type =
|
|
active.find((t) => t.id === TYPE_OF[command.kind])?.id ?? active[0]?.id;
|
|
const author = command.author?.trim().slice(0, 80);
|
|
const signature = `_Propuesta enviada desde el formulario público${author ? ` por ${author}` : ''}._`;
|
|
|
|
return this.commandBus.execute(
|
|
new CreateIssueCommand(
|
|
SYSTEM_ACTOR_ID,
|
|
project.id,
|
|
type,
|
|
title,
|
|
{
|
|
description: text ? `${text}\n\n${signature}` : signature,
|
|
labels: [PROPOSAL_LABEL],
|
|
priority: IssuePriority.MEDIUM,
|
|
},
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
// What they wrote is the content, not the type's template.
|
|
false,
|
|
),
|
|
);
|
|
}
|
|
}
|