import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; import { NotFoundError } from '@task-stream/api/shared/domain'; import { ReleaseStatus, StatusCategory } from '@task-stream/api/tracker/domain'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { PROPOSAL_LABEL } from '../proposals'; /** Tickets with this label are offered to newcomers on public pages. */ export const GOOD_FIRST_LABEL = 'good-first-issue'; /** * What a public project shows to the world in one call: where it is going * (epics with dates, releases), what it is doing (the sprint under way) and * where someone new could help. Paths are relative to the web app. */ export interface Showcase { project: { id: string; key: string; name: string; description: string | null; }; activeSprint: { name: string; goal: string | null; startDate: string; endDate: string; percent: number; } | null; roadmap: { key: string; title: string; startDate: string | null; dueDate: string | null; percent: number; done: boolean; /** How many want it. */ votes: number; path: string; }[]; releases: { name: string; status: ReleaseStatus; targetDate: string | null; releasedAt: string | null; percent: number; notes: string | null; }[]; goodFirst: { key: string; title: string; type: string; path: string }[]; /** Bug reports and ideas from the public: whether it takes them, where, and the open ones. */ proposals: { open: boolean; /** The form in the web app, while it takes them. */ path: string | null; /** Open proposals, most voted first. */ top: { key: string; title: string; type: string; votes: number; path: string; }[]; }; } export class GetShowcaseQuery extends Query { constructor(public readonly project: string) { super(); } } const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; const OPEN = [ StatusCategory.TODO, StatusCategory.IN_PROGRESS, StatusCategory.QA, ]; @QueryHandler(GetShowcaseQuery) export class GetShowcaseQueryHandler implements IQueryHandler { constructor( private readonly findProjects: FindProjectsRepository, private readonly findIssues: FindIssuesRepository, private readonly findReleases: FindReleasesRepository, private readonly findSprints: FindSprintsRepository, ) {} /** Public projects only: anything else is not there. */ async execute(query: GetShowcaseQuery): Promise { const project = (await this.findProjects.findById(query.project)) ?? (await this.findProjects.findByKey(query.project.toUpperCase())); if (!project?.isPublic) throw new NotFoundError('Project', query.project); const path = (key: string) => `/p/${project.key}/issues/${key}`; const [epics, releases, sprint, goodFirst, proposals] = await Promise.all([ this.findIssues.epics(project.id), this.findReleases.findByProject(project.id), this.findSprints.findActive(project.id), this.findIssues.search({ projectId: project.id, labels: [GOOD_FIRST_LABEL], categories: [StatusCategory.TODO], limit: 20, offset: 0, }), this.findIssues.search({ projectId: project.id, labels: [PROPOSAL_LABEL], categories: OPEN, limit: 100, offset: 0, }), ]); const visible = releases .filter((r) => r.status !== ReleaseStatus.ARCHIVED) .sort((a, b) => (b.releasedAt ?? b.targetDate ?? '').localeCompare( a.releasedAt ?? a.targetDate ?? '', ), ) .slice(0, 6); const progress = await Promise.all( visible.map((r) => this.findIssues.releaseProgress(r.id)), ); return { project: { id: project.id, key: project.key, name: project.name, description: project.description, }, activeSprint: sprint ? { name: sprint.name, goal: sprint.goal, startDate: sprint.startDate, endDate: sprint.endDate, percent: (await this.findIssues.sprintProgress(sprint.id)).percent, } : null, roadmap: epics.map(({ issue, progress: p }) => ({ key: issue.key, title: issue.title, startDate: issue.startDate, dueDate: issue.dueDate, percent: p.percent, done: FINISHED.includes(issue.statusCategory), votes: issue.votes, path: path(issue.key), })), releases: visible.map((r, i) => ({ name: r.name, status: r.status, targetDate: r.targetDate, releasedAt: r.releasedAt, percent: progress[i].percent, notes: r.status === ReleaseStatus.RELEASED ? r.notes : null, })), goodFirst: goodFirst.items.map((i) => ({ key: i.key, title: i.title, type: i.type, path: path(i.key), })), proposals: { open: project.acceptsProposals, path: project.acceptsProposals ? `/p/${project.key}/propose` : null, top: [...proposals.items] .sort((a, b) => b.votes - a.votes) .slice(0, 10) .map((i) => ({ key: i.key, title: i.title, type: i.type, votes: i.votes, path: path(i.key), })), }, }; } }