import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { BusinessRuleError, ForbiddenError, NotFoundError, ValidationError, assertThat, } from '@task-stream/api/shared/domain'; import { IssuePriority, ProjectAnalyticsReadModel, ProjectInsightsReadModel, ProjectReadModel, ProjectRole, ProjectSettings, ProjectStatsReadModel, ReleaseReadModel, SYSTEM_ACTOR_ID, } from '@task-stream/api/tracker/domain'; import { CreateIssueCommand } from './commands/create-issue.command'; import { WriteAccess } from './commands/write-access'; import { FindProjectsRepository } from './ports/find-projects.repository'; import { PROPOSAL_LABEL, PROPOSAL_TYPE, ProposalKind } from './proposals'; import { AddProjectMemberCommand } from './commands/add-project-member.command'; import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command'; import { CommandResult } from './commands/command-result'; import { CreateProjectCommand } from './commands/create-project.command'; import { DeleteProjectCommand } from './commands/delete-project.command'; import { RemoveProjectMemberCommand } from './commands/remove-project-member.command'; import { UpdateProjectCommand } from './commands/update-project.command'; import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command'; import { IssueFilter } from './ports/find-issues.repository'; import { BoardColumn, GetBoardQuery } from './queries/get-board.query'; import { GetMyProjectsQuery } from './queries/get-my-projects.query'; import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query'; import { GetProjectInsightsQuery } from './queries/get-project-insights.query'; import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetPublicProjectsQuery } from './queries/get-public-projects.query'; import { GetProjectQuery } from './queries/get-project.query'; import { GetReleasesQuery } from './queries/get-releases.query'; import { CreateAutomationCommand, GetAutomationsQuery, UpdateAutomationCommand, } from './automations/automations'; import { Automation } from './ports/automations.repository'; @Injectable() export class ProjectsService { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, private readonly findProjects: FindProjectsRepository, private readonly access: WriteAccess, ) {} /** * A bug report or an idea from anyone, for a public project that takes * them. It becomes a ticket labelled «propuesta», 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), signed with the name * they gave, if any. */ async propose( projectIdOrKey: string, kind: ProposalKind, title: string, description: string, author: string | null, ): Promise { const found = (await this.findProjects.findById(projectIdOrKey)) ?? (await this.findProjects.findByKey(projectIdOrKey.toUpperCase())); if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey); if (!found.acceptsProposals) throw new ForbiddenError(`${found.key} does not take proposals`); const cleanTitle = title.trim(); assertThat( cleanTitle.length >= 3 && cleanTitle.length <= 150, 'A proposal needs a title of 3 to 150 characters', ); const text = 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 === PROPOSAL_TYPE[kind])?.id ?? active[0]?.id; const signer = author?.trim().slice(0, 80); const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`; return this.commandBus.execute( new CreateIssueCommand( SYSTEM_ACTOR_ID, project.id, type, cleanTitle, { 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, ), ); } /** A public project's roadmap, releases, sprint and good first tickets (no token). */ showcase(project: string): Promise { return this.queryBus.execute(new GetShowcaseQuery(project)); } /** * A new project. With `templateId`, it starts with a copy of that * project's configuration (statuses, transitions, types, areas and custom * fields) and automation rules, taken once: later changes to either one * stay there. Only from a project the actor manages (rules are only shown * to managers); a rule that names someone who is not in the new project * is left out. */ async create( actorId: string, input: { key: string; name: string; description?: string | null; isPublic?: boolean; templateId?: string | null; }, ): Promise { const template = input.templateId ? await this.template(actorId, input.templateId) : null; const created: CommandResult = await this.commandBus.execute( new CreateProjectCommand( actorId, input.key, input.name, input.description, input.isPublic ?? false, ), ); if (!template) return created; const configured: CommandResult = await this.commandBus.execute( new UpdateProjectSettingsCommand( actorId, created.id, structuredClone(template.settings), ), ); for (const rule of template.automations) await this.copyRule(actorId, created.id, rule); return { ...configured, key: created.key }; } private async template( actorId: string, projectId: string, ): Promise<{ settings: ProjectSettings; automations: Automation[]; }> { const project = await this.access.project(projectId); project.assertCanManage(actorId); const automations: Automation[] = await this.queryBus.execute( new GetAutomationsQuery(actorId, projectId), ); return { settings: project.settings, automations }; } private async copyRule( actorId: string, projectId: string, rule: Automation, ): Promise { try { const copy: Automation = await this.commandBus.execute( new CreateAutomationCommand(actorId, projectId, { name: rule.name, trigger: rule.trigger, // Stored without conditions when it had none. conditions: rule.conditions ?? {}, action: rule.action, }), ); if (!rule.active) await this.commandBus.execute( new UpdateAutomationCommand(actorId, copy.id, { active: false }), ); } catch (error) { // Someone it assigns to is not a member here: that rule stays behind. if ( !(error instanceof ValidationError) && !(error instanceof BusinessRuleError) ) throw error; } } update( actorId: string, projectId: string, changes: { name?: string; description?: string | null; isPublic?: boolean; acceptsProposals?: boolean; }, ): Promise { return this.commandBus.execute( new UpdateProjectCommand(actorId, projectId, changes), ); } /** * Owner only, typing the key to confirm. Tickets, comments and releases * are deleted right after by ProjectDeletionSaga; the key is freed. */ delete( actorId: string, projectId: string, confirmKey: string, ): Promise { return this.commandBus.execute( new DeleteProjectCommand(actorId, projectId, confirmKey), ); } /** Projects anyone can read, also without signing in. */ findPublic(): Promise { return this.queryBus.execute(new GetPublicProjectsQuery()); } /** Statuses, transitions, ticket types and areas (owners and admins). */ updateSettings( actorId: string, projectId: string, settings: ProjectSettings, ): Promise { return this.commandBus.execute( new UpdateProjectSettingsCommand(actorId, projectId, settings), ); } addMember( actorId: string, input: { projectId: string; userId: string; role: ProjectRole }, accessToken?: string, ): Promise { return this.commandBus.execute( new AddProjectMemberCommand( actorId, input.projectId, input.userId, input.role, accessToken, ), ); } changeMemberRole( actorId: string, input: { projectId: string; userId: string; role: ProjectRole }, ): Promise { return this.commandBus.execute( new ChangeProjectMemberRoleCommand( actorId, input.projectId, input.userId, input.role, ), ); } removeMember( actorId: string, projectId: string, userId: string, ): Promise { return this.commandBus.execute( new RemoveProjectMemberCommand(actorId, projectId, userId), ); } findMine(viewerId: string): Promise { return this.queryBus.execute(new GetMyProjectsQuery(viewerId)); } findOne(viewerId: string | null, idOrKey: string): Promise { return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey)); } stats( viewerId: string | null, projectId: string, ): Promise { return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId)); } board( viewerId: string | null, filter: Omit, perColumn?: number, ): Promise { return this.queryBus.execute( new GetBoardQuery(viewerId, filter, perColumn), ); } releases( viewerId: string | null, projectId: string, ): Promise { return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId)); } /** Statistics over the last `days` days (created vs resolved, flow, times, QA). */ /** The dashboard's reading; with `since`, what changed after it too. */ insights( viewerId: string | null, projectId: string, since: Date | null = null, ): Promise { return this.queryBus.execute( new GetProjectInsightsQuery(viewerId, projectId, since), ); } analytics( viewerId: string | null, projectId: string, days: number, ): Promise { return this.queryBus.execute( new GetProjectAnalyticsQuery(viewerId, projectId, days), ); } }