first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# web-tracker-domain
Modelo del tracker en el cliente.
- **Import**: `@task-stream/web/tracker/domain`
- **Capa**: `domain` · **Tags**: `scope:web-tracker`, `type:domain`
## Contenido
- Modelos (`Project`, `IssueSummary`, `Issue`, `Release`…), enums e inputs.
- Workflow (`REASON_REQUIRED`, `BOARD_COLUMNS`).
- Puertos: `ProjectsGateway`, `IssuesGateway`, `CommentsGateway`, `ReleasesGateway`, `ActivityGateway`, `UsersGateway`, `TrackerRealtimeGateway`.
## Comandos
```sh
npx nx lint web-tracker-domain
npx nx test web-tracker-domain # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'web-tracker-domain',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/web/tracker/domain',
};

View File

@@ -0,0 +1,9 @@
{
"name": "web-tracker-domain",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tracker/domain/src",
"projectType": "library",
"tags": ["scope:web-tracker", "type:domain"],
"// targets": "to see all targets run: nx show project web-tracker-domain --web",
"targets": {}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/models/enums';
export * from './lib/models/models';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';

View File

@@ -0,0 +1,57 @@
export enum IssueType {
BUG = 'BUG',
FEATURE = 'FEATURE',
UI_CHANGE = 'UI_CHANGE',
TASK = 'TASK',
QA = 'QA',
}
export enum IssueArea {
FRONTEND = 'FRONTEND',
BACKEND = 'BACKEND',
MOBILE = 'MOBILE',
QA = 'QA',
DESIGN = 'DESIGN',
DEVOPS = 'DEVOPS',
}
export enum IssuePriority {
LOWEST = 'LOWEST',
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export enum IssueStatus {
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
READY_FOR_QA = 'READY_FOR_QA',
IN_QA = 'IN_QA',
QA_FAILED = 'QA_FAILED',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
export enum IssueLinkType {
BLOCKS = 'BLOCKS',
DEPENDS_ON = 'DEPENDS_ON',
TESTS = 'TESTS',
DUPLICATES = 'DUPLICATES',
RELATES_TO = 'RELATES_TO',
}
export enum ProjectRole {
OWNER = 'OWNER',
ADMIN = 'ADMIN',
MEMBER = 'MEMBER',
VIEWER = 'VIEWER',
}
export enum ReleaseStatus {
PLANNED = 'PLANNED',
RELEASED = 'RELEASED',
ARCHIVED = 'ARCHIVED',
}

View File

@@ -0,0 +1,65 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
} from './enums';
export interface CreateProjectInput {
key: string;
name: string;
description?: string | null;
}
export interface CreateIssueInput {
projectId: string;
type: IssueType;
title: string;
description?: string | null;
area?: IssueArea | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
assigneeId?: string | null;
releaseId?: string | null;
}
export type UpdateIssueInput = Partial<
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
> & {
issueId: string;
};
export interface ChangeStatusInput {
issueId: string;
status: IssueStatus;
reason?: string | null;
}
export interface LinkIssuesInput {
issueId: string;
type: IssueLinkType;
targetIssueId: string;
}
export interface MemberInput {
projectId: string;
userId: string;
role: ProjectRole;
}
export interface CreateReleaseInput {
projectId: string;
name: string;
description?: string | null;
targetDate?: string | null;
}
export interface BoardFilter {
projectId: string;
assigneeId?: string;
releaseId?: string;
area?: IssueArea;
}

View File

@@ -0,0 +1,131 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
} from './enums';
export interface UserRef {
id: string;
name: string;
}
export interface ProjectMember {
user: UserRef;
role: ProjectRole;
}
export interface Project {
id: string;
key: string;
name: string;
description: string | null;
owner: UserRef;
members: ProjectMember[];
updatedAt: string;
version: number;
}
export interface IssueLink {
type: IssueLinkType;
/** OUTWARD: this issue → other. INWARD: other → this issue. */
direction: 'OUTWARD' | 'INWARD';
issueId: string;
issueKey: string;
}
/** What boards and lists show. */
export interface IssueSummary {
id: string;
projectId: string;
key: string;
type: IssueType;
area: IssueArea | null;
title: string;
status: IssueStatus;
priority: IssuePriority;
assignee: UserRef | null;
release: { id: string; name: string } | null;
labels: string[];
commentCount: number;
updatedAt: string;
version: number;
}
export interface Issue extends IssueSummary {
description: string | null;
reporter: UserRef;
figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
allowedTransitions: IssueStatus[];
createdAt: string;
}
export interface Comment {
id: string;
issueId: string;
author: UserRef;
body: string;
createdAt: string;
editedAt: string | null;
}
export interface Release {
id: string;
projectId: string;
name: string;
description: string | null;
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
progress: { total: number; finished: number; percent: number };
}
export interface Activity {
id: string;
type: string;
issueId: string | null;
issueKey: string | null;
actor: UserRef;
summary: string;
occurredAt: string;
}
export interface CountBy<T = string> {
key: T;
count: number;
}
export interface ProjectStats {
total: number;
open: number;
byStatus: CountBy<IssueStatus>[];
byType: CountBy[];
byPriority: CountBy[];
byArea: CountBy[];
byAssignee: { user: UserRef | null; count: number }[];
}
export interface BoardColumn {
status: IssueStatus;
issues: IssueSummary[];
total: number;
}
export interface UserDirectoryEntry {
id: string;
name: string;
email: string;
}
/** Ack of a command: the read model catches up asynchronously. */
export interface CommandResult {
id: string;
key?: string | null;
version: number;
}

View File

@@ -0,0 +1,50 @@
import { IssuePriority, IssueStatus, IssueType } from './enums';
/** Statuses that require an explanation (the API rejects them without one). */
export const REASON_REQUIRED: readonly IssueStatus[] = [
IssueStatus.QA_FAILED,
IssueStatus.CANCELLED,
];
export const BOARD_COLUMNS: readonly IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
export const STATUS_LABEL: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'To do',
[IssueStatus.IN_PROGRESS]: 'In progress',
[IssueStatus.IN_REVIEW]: 'In review',
[IssueStatus.READY_FOR_QA]: 'Ready for QA',
[IssueStatus.IN_QA]: 'In QA',
[IssueStatus.QA_FAILED]: 'QA failed',
[IssueStatus.DONE]: 'Done',
[IssueStatus.CANCELLED]: 'Cancelled',
};
export const TYPE_LABEL: Record<IssueType, string> = {
[IssueType.BUG]: 'Bug',
[IssueType.FEATURE]: 'Feature',
[IssueType.UI_CHANGE]: 'UI change',
[IssueType.TASK]: 'Task',
[IssueType.QA]: 'QA',
};
export const PRIORITY_ORDER: readonly IssuePriority[] = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
export function isFinished(status: IssueStatus): boolean {
return status === IssueStatus.DONE || status === IssueStatus.CANCELLED;
}

View File

@@ -0,0 +1,108 @@
import { Observable } from 'rxjs';
import {
Activity,
BoardColumn,
Comment,
CommandResult,
Issue,
IssueSummary,
Project,
ProjectStats,
Release,
UserDirectoryEntry,
} from '../models/models';
import {
BoardFilter,
ChangeStatusInput,
CreateIssueInput,
CreateProjectInput,
CreateReleaseInput,
LinkIssuesInput,
MemberInput,
UpdateIssueInput,
} from '../models/inputs';
/*
* Outbound ports of the tracker. The application layer depends on these
* abstract classes; the infrastructure layer implements them over GraphQL.
*/
export abstract class ProjectsGateway {
abstract myProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
abstract create(input: CreateProjectInput): Observable<CommandResult>;
abstract addMember(input: MemberInput): Observable<CommandResult>;
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
abstract removeMember(
projectId: string,
userId: string,
): Observable<CommandResult>;
}
export abstract class IssuesGateway {
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
abstract issue(idOrKey: string): Observable<Issue>;
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
abstract create(input: CreateIssueInput): Observable<CommandResult>;
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
abstract assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult>;
abstract link(input: LinkIssuesInput): Observable<CommandResult>;
abstract unlink(input: LinkIssuesInput): Observable<CommandResult>;
abstract setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
}
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
}
export abstract class ReleasesGateway {
abstract list(projectId: string): Observable<Release[]>;
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
abstract publish(
releaseId: string,
force: boolean,
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
}
export abstract class ActivityGateway {
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
}
export abstract class UsersGateway {
abstract search(term: string): Observable<UserDirectoryEntry[]>;
}
/** Realtime (GraphQL subscriptions over WebSocket). */
export interface IssueChanged {
action: string;
version: number;
actorId: string;
issue: IssueSummary;
}
export interface CommentChanged {
action: string;
commentId: string;
comment: Comment | null;
}
export abstract class TrackerRealtimeGateway {
abstract issueChanges(projectId: string): Observable<IssueChanged>;
abstract commentChanges(issueId: string): Observable<CommentChanged>;
abstract activity(projectId: string): Observable<Activity>;
abstract releaseChanges(projectId: string): Observable<Release>;
abstract projectChanges(projectId: string): Observable<Project>;
abstract myProjectsChanges(): Observable<Project>;
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}