import { BusinessRuleError, ValidationError, assertThat, } from '@task-stream/api/shared/domain'; import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums'; /** * What a status means, whatever it is called in each project: it keeps * releases ("everything finished"), "waiting for QA" and statistics working * with custom statuses. */ export enum StatusCategory { TODO = 'TODO', IN_PROGRESS = 'IN_PROGRESS', QA = 'QA', DONE = 'DONE', CANCELLED = 'CANCELLED', } /** Colour tokens the web app knows (light and dark). */ export const SETTING_COLORS = [ 'slate', 'stone', 'violet', 'blue', 'sky', 'teal', 'green', 'amber', 'orange', 'red', 'pink', ] as const; export type SettingColor = (typeof SETTING_COLORS)[number]; /** Icons a ticket type can use (registered in the web app). */ export const TYPE_ICONS = [ 'bug', 'feature', 'palette', 'task', 'qa', 'flag', 'star-fill', 'rocket', 'document', 'light-bulb', 'key', 'support', ] as const; export type TypeIcon = (typeof TYPE_ICONS)[number]; export interface WorkflowStatus { /** Stable id stored on tickets, e.g. IN_REVIEW. */ id: string; name: string; category: StatusCategory; color: SettingColor; /** Entering it needs a reason (kept in the history). */ requiresReason: boolean; /** Archived: no new tickets or transitions into it; existing ones keep it. */ archived: boolean; /** * Work-in-progress limit: how many tickets the column should hold at most * (null: no limit). A warning, not a lock: the board flags a full column. */ wipLimit?: number | null; } export interface IssueTypeOption { id: string; name: string; icon: TypeIcon; color: SettingColor; archived: boolean; } export interface AreaOption { id: string; name: string; archived: boolean; } /** Per-project configuration of the tracker. */ export interface ProjectSettings { /** In board order. New tickets start in the first active TODO status. */ statuses: WorkflowStatus[]; /** Allowed moves: status id → status ids it can go to. */ transitions: Record; issueTypes: IssueTypeOption[]; areas: AreaOption[]; } const status = ( id: string, name: string, category: StatusCategory, color: SettingColor, requiresReason = false, ): WorkflowStatus => ({ id, name, category, color, requiresReason, archived: false, }); /** What every project starts with (and what projects created before settings had). */ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { statuses: [ status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'), status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'), status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'), status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'), status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'), status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'), status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true), status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'), status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true), ], // QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS. transitions: { [S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED], [S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED], [S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED], [S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE], [S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS], [S.IN_QA]: [S.DONE, S.QA_FAILED], [S.QA_FAILED]: [S.IN_PROGRESS, S.TODO], [S.DONE]: [S.TODO, S.IN_PROGRESS], [S.CANCELLED]: [S.BACKLOG], }, issueTypes: [ { id: IssueType.BUG, name: 'Bug', icon: 'bug', color: 'red', archived: false, }, { id: IssueType.FEATURE, name: 'Feature', icon: 'feature', color: 'violet', archived: false, }, { id: IssueType.UI_CHANGE, name: 'Cambio de UI', icon: 'palette', color: 'pink', archived: false, }, { id: IssueType.TASK, name: 'Tarea', icon: 'task', color: 'slate', archived: false, }, { id: IssueType.QA, name: 'QA', icon: 'qa', color: 'amber', archived: false, }, ], areas: [ { id: IssueArea.FRONTEND, name: 'Frontend', archived: false }, { id: IssueArea.BACKEND, name: 'Backend', archived: false }, { id: IssueArea.MOBILE, name: 'Mobile', archived: false }, { id: IssueArea.QA, name: 'QA', archived: false }, { id: IssueArea.DESIGN, name: 'Diseño', archived: false }, { id: IssueArea.DEVOPS, name: 'DevOps', archived: false }, ], }; export function defaultProjectSettings(): ProjectSettings { return structuredClone(DEFAULT_PROJECT_SETTINGS); } const ID = /^[A-Z][A-Z0-9_]{0,29}$/; const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; /** Read-only view of a project's workflow, used by tickets and queries. */ export class ProjectWorkflow { constructor(readonly settings: ProjectSettings) {} static default(): ProjectWorkflow { return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS); } status(id: string): WorkflowStatus | undefined { return this.settings.statuses.find((s) => s.id === id); } /** Most tickets the status should hold (null: no limit). */ wipLimit(id: string): number | null { return this.status(id)?.wipLimit ?? null; } /** Name to show, also for ids no longer configured. */ statusName(id: string): string { return ( this.status(id)?.name ?? DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ?? id ); } typeName(id: string): string { return ( this.settings.issueTypes.find((t) => t.id === id)?.name ?? DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ?? id ); } areaName(id: string): string { return ( this.settings.areas.find((a) => a.id === id)?.name ?? DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ?? id ); } categoryOf(id: string): StatusCategory { return ( this.status(id)?.category ?? DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ?? StatusCategory.TODO ); } isFinished(id: string): boolean { return FINISHED.includes(this.categoryOf(id)); } /** Where new tickets start: the first active status of category TODO. */ initialStatus(): string { const initial = this.settings.statuses.find( (s) => !s.archived && s.category === StatusCategory.TODO, ); if (!initial) throw new BusinessRuleError('The project has no active starting status'); return initial.id; } /** Active statuses a ticket can move to from `from`. */ allowedFrom(from: string): string[] { return (this.settings.transitions[from] ?? []).filter( (to) => this.status(to) && !this.status(to)?.archived, ); } canTransition(from: string, to: string): boolean { return this.allowedFrom(from).includes(to); } requiresReason(id: string): boolean { return this.status(id)?.requiresReason ?? false; } assertActiveType(id: string): void { const type = this.settings.issueTypes.find((t) => t.id === id); if (!type) throw new ValidationError(`Unknown ticket type ${id}`); if (type.archived) throw new BusinessRuleError(`Ticket type ${type.name} is archived`); } assertActiveArea(id: string | null | undefined): void { if (id === null || id === undefined) return; const area = this.settings.areas.find((a) => a.id === id); if (!area) throw new ValidationError(`Unknown area ${id}`); if (area.archived) throw new BusinessRuleError(`Area ${area.name} is archived`); } } /** * Checks and normalises new settings against the previous ones. Values can be * added, renamed, reordered or archived, never deleted: tickets, history and * statistics keep referring to them. */ export function validateProjectSettings( next: ProjectSettings, previous: ProjectSettings, ): ProjectSettings { const statuses = next.statuses.map((s) => ({ id: normId(s.id), name: normName(s.name), category: s.category, color: s.color, requiresReason: !!s.requiresReason, archived: !!s.archived, // Only when set: settings saved unchanged must compare equal. ...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}), })); const issueTypes = next.issueTypes.map((t) => ({ id: normId(t.id), name: normName(t.name), icon: t.icon, color: t.color, archived: !!t.archived, })); const areas = next.areas.map((a) => ({ id: normId(a.id), name: normName(a.name), archived: !!a.archived, })); for (const [label, list, before] of [ ['estado', statuses, previous.statuses], ['tipo', issueTypes, previous.issueTypes], ['área', areas, previous.areas], ] as const) { const ids = list.map((item) => item.id); const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`); const removed = before.find((item) => !ids.includes(item.id)); if (removed) { throw new BusinessRuleError( `No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`, ); } } for (const s of statuses) { assertThat( Object.values(StatusCategory).includes(s.category), `Categoría no válida en el estado ${s.name}`, ); assertThat( SETTING_COLORS.includes(s.color), `Color no válido en el estado ${s.name}`, ); assertThat( s.wipLimit === undefined || (Number.isInteger(s.wipLimit) && s.wipLimit >= 1 && s.wipLimit <= 999), `El límite WIP del estado ${s.name} tiene que ser un número entre 1 y 999`, ); } for (const t of issueTypes) { assertThat( TYPE_ICONS.includes(t.icon), `Icono no válido en el tipo ${t.name}`, ); assertThat( SETTING_COLORS.includes(t.color), `Color no válido en el tipo ${t.name}`, ); } const active = statuses.filter((s) => !s.archived); assertThat( active.some((s) => s.category === StatusCategory.TODO), 'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets', ); assertThat( active.some((s) => s.category === StatusCategory.DONE), 'Hace falta al menos un estado activo «hecho»', ); assertThat( issueTypes.some((t) => !t.archived), 'Hace falta al menos un tipo de ticket activo', ); const known = new Set(statuses.map((s) => s.id)); const transitions: Record = {}; for (const [from, targets] of Object.entries(next.transitions ?? {})) { const source = normId(from); assertThat( known.has(source), `Transición desde un estado que no existe: ${from}`, ); const to = [...new Set(targets.map(normId))]; for (const target of to) { assertThat( known.has(target), `Transición hacia un estado que no existe: ${target}`, ); assertThat( target !== source, `Un estado no puede pasar a sí mismo (${source})`, ); } if (to.length) transitions[source] = to; } return { statuses, transitions, issueTypes, areas }; } function normId(id: string): string { const value = (id ?? '').trim().toUpperCase(); assertThat( ID.test(value), `Id no válido «${id}»: mayúsculas, números y _, empezando por letra`, ); return value; } function normName(name: string): string { const value = (name ?? '').trim(); assertThat( value.length > 0 && value.length <= 40, 'Los nombres llevan 1-40 caracteres', ); return value; }