first commit
This commit is contained in:
22
libs/api/tracker/presenters/README.md
Normal file
22
libs/api/tracker/presenters/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# api-tracker-presenters
|
||||
|
||||
API GraphQL del tracker.
|
||||
|
||||
- **Import**: `@task-stream/api/tracker/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-tracker`, `type:presenters`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
|
||||
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
|
||||
|
||||
Operaciones disponibles: [API](../../../../docs/03-api.md#32-graphql).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-tracker-presenters
|
||||
npx nx test api-tracker-presenters # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/tracker/presenters/eslint.config.mjs
Normal file
3
libs/api/tracker/presenters/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/tracker/presenters/jest.config.cts
Normal file
11
libs/api/tracker/presenters/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-tracker-presenters',
|
||||
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/api/tracker/presenters',
|
||||
};
|
||||
12
libs/api/tracker/presenters/project.json
Normal file
12
libs/api/tracker/presenters/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-tracker-presenters",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:presenters"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
1
libs/api/tracker/presenters/src/index.ts
Normal file
1
libs/api/tracker/presenters/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/tracker-presenters.module';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Args, ID, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { CommandResultGql, CommentGql } from './types';
|
||||
|
||||
@Resolver(() => CommentGql)
|
||||
export class CommentResolver {
|
||||
constructor(private readonly comments: CommentsService) {}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
addComment(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('body') body: string,
|
||||
) {
|
||||
return this.comments.add(me.id, issueId, body);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
editComment(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('body') body: string,
|
||||
) {
|
||||
return this.comments.edit(me.id, commentId, body);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, { description: 'Author or project admin' })
|
||||
deleteComment(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
) {
|
||||
return this.comments.delete(me.id, commentId);
|
||||
}
|
||||
}
|
||||
27
libs/api/tracker/presenters/src/lib/graphql/enums.ts
Normal file
27
libs/api/tracker/presenters/src/lib/graphql/enums.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
OUTWARD = 'OUTWARD',
|
||||
INWARD = 'INWARD',
|
||||
}
|
||||
|
||||
registerEnumType(IssueType, { name: 'IssueType' });
|
||||
registerEnumType(IssueArea, {
|
||||
name: 'IssueArea',
|
||||
description: 'Team/layer: links FE ↔ BE ↔ QA work',
|
||||
});
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(IssueStatus, { name: 'IssueStatus' });
|
||||
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
||||
239
libs/api/tracker/presenters/src/lib/graphql/inputs.ts
Normal file
239
libs/api/tracker/presenters/src/lib/graphql/inputs.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Field, ID, InputType, Int } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateProjectInput {
|
||||
@Field({ description: '2-10 chars, e.g. "TS" → tickets TS-1, TS-2...' })
|
||||
@Matches(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
|
||||
key!: string;
|
||||
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(120) name!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateProjectInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ProjectMemberInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => ID, { description: 'Pireon user id' })
|
||||
@IsString()
|
||||
userId!: string;
|
||||
@Field(() => ProjectRole, { defaultValue: ProjectRole.MEMBER })
|
||||
@IsEnum(ProjectRole)
|
||||
role!: ProjectRole;
|
||||
}
|
||||
|
||||
@InputType({ isAbstract: true })
|
||||
abstract class IssueFieldsInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50000)
|
||||
description?: string | null;
|
||||
|
||||
@Field(() => IssueArea, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea | null;
|
||||
|
||||
@Field(() => IssuePriority, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
priority?: IssuePriority;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(40, { each: true })
|
||||
labels?: string[];
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Figma frame for UI changes',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl({ protocols: ['https'] })
|
||||
figmaUrl?: string | null;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
storyPoints?: number | null;
|
||||
|
||||
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
|
||||
| string
|
||||
| null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateIssueInput extends IssueFieldsInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => IssueType) @IsEnum(IssueType) type!: IssueType;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(255) title!: string;
|
||||
@Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?:
|
||||
| string
|
||||
| null;
|
||||
@Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?:
|
||||
| string
|
||||
| null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateIssueInput extends IssueFieldsInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => IssueType, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType)
|
||||
type?: IssueType;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ChangeIssueStatusInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => IssueStatus) @IsEnum(IssueStatus) status!: IssueStatus;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Required for QA_FAILED and CANCELLED',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class LinkIssuesInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => IssueLinkType) @IsEnum(IssueLinkType) type!: IssueLinkType;
|
||||
@Field(() => ID) @IsUUID() targetIssueId!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class IssueFilterInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => [IssueStatus], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueStatus, { each: true })
|
||||
statuses?: IssueStatus[];
|
||||
@Field(() => [IssueType], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType, { each: true })
|
||||
types?: IssueType[];
|
||||
@Field(() => [IssueArea], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea, { each: true })
|
||||
areas?: IssueArea[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
releaseId?: string;
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
labels?: string[];
|
||||
@Field({ nullable: true, description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateReleaseInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field({ description: 'e.g. v1.4.0' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
targetDate?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateReleaseInput {
|
||||
@Field(() => ID) @IsUUID() releaseId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name?: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
targetDate?: string | null;
|
||||
}
|
||||
192
libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
Normal file
192
libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssueDetails,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
ChangeIssueStatusInput,
|
||||
CreateIssueInput,
|
||||
IssueFilterInput,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from './inputs';
|
||||
import {
|
||||
ActivityGql,
|
||||
CommandResultGql,
|
||||
CommentPageGql,
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
} from './types';
|
||||
|
||||
/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */
|
||||
function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
|
||||
const details = { ...input };
|
||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
||||
if (details[field] === null) delete details[field];
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
@Resolver(() => IssueGql)
|
||||
export class IssueResolver {
|
||||
constructor(
|
||||
private readonly issuesService: IssuesService,
|
||||
private readonly commentsService: CommentsService,
|
||||
) {}
|
||||
|
||||
@Query(() => IssueGql)
|
||||
issue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('idOrKey', { description: 'Issue id or key (e.g. "TS-42")' })
|
||||
idOrKey: string,
|
||||
) {
|
||||
return this.issuesService.findOne(me.id, idOrKey);
|
||||
}
|
||||
|
||||
@Query(() => IssuePageGql, { name: 'issues' })
|
||||
searchIssues(
|
||||
@CurrentUser() me: User,
|
||||
@Args('filter') filter: IssueFilterInput,
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.issuesService.search(me.id, { ...filter, limit, offset });
|
||||
}
|
||||
|
||||
@Query(() => [ActivityGql], {
|
||||
description: 'Project activity feed (newest first)',
|
||||
})
|
||||
activity(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('issueId', { type: () => ID, nullable: true }) issueId?: string,
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
|
||||
@Args('before', { nullable: true, description: 'ISO date cursor' })
|
||||
before?: string,
|
||||
) {
|
||||
return this.issuesService.activity(me.id, {
|
||||
projectId,
|
||||
issueId,
|
||||
limit,
|
||||
before,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [IssueStatus], {
|
||||
description: 'Statuses this ticket can move to next',
|
||||
})
|
||||
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] {
|
||||
return IssueWorkflow.allowedFrom(issue.status);
|
||||
}
|
||||
|
||||
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
|
||||
linkedIssues(@CurrentUser() me: User, @Parent() issue: IssueReadModel) {
|
||||
if (issue.links.length === 0) return [];
|
||||
return this.issuesService.findMany(
|
||||
me.id,
|
||||
issue.links.map((l) => l.issueId),
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => CommentPageGql)
|
||||
comments(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() issue: IssueReadModel,
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.commentsService.list(me.id, issue.id, limit, offset);
|
||||
}
|
||||
|
||||
@ResolveField(() => [ActivityGql], { description: 'History of this ticket' })
|
||||
history(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() issue: IssueReadModel,
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||
) {
|
||||
return this.issuesService.activity(me.id, {
|
||||
projectId: issue.projectId,
|
||||
issueId: issue.id,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
createIssue(@CurrentUser() me: User, @Args('input') input: CreateIssueInput) {
|
||||
return this.issuesService.create(me.id, withoutNulls(input));
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
updateIssue(@CurrentUser() me: User, @Args('input') input: UpdateIssueInput) {
|
||||
const { issueId, ...changes } = withoutNulls(input);
|
||||
return this.issuesService.update(me.id, issueId, changes);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Move a ticket through the workflow (incl. QA)',
|
||||
})
|
||||
changeIssueStatus(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: ChangeIssueStatusInput,
|
||||
) {
|
||||
return this.issuesService.changeStatus(
|
||||
me.id,
|
||||
input.issueId,
|
||||
input.status,
|
||||
input.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Assign (or unassign with null) a project member',
|
||||
})
|
||||
assignIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('assigneeId', { type: () => ID, nullable: true })
|
||||
assigneeId: string | null,
|
||||
) {
|
||||
return this.issuesService.assign(me.id, issueId, assigneeId ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE',
|
||||
})
|
||||
linkIssues(@CurrentUser() me: User, @Args('input') input: LinkIssuesInput) {
|
||||
return this.issuesService.link(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
unlinkIssues(@CurrentUser() me: User, @Args('input') input: LinkIssuesInput) {
|
||||
return this.issuesService.unlink(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Put a ticket in a release (null removes it)',
|
||||
})
|
||||
setIssueRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('releaseId', { type: () => ID, nullable: true })
|
||||
releaseId: string | null,
|
||||
) {
|
||||
return this.issuesService.setRelease(me.id, issueId, releaseId ?? null);
|
||||
}
|
||||
}
|
||||
125
libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
Normal file
125
libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
||||
import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CreateProjectInput,
|
||||
ProjectMemberInput,
|
||||
UpdateProjectInput,
|
||||
} from './inputs';
|
||||
import {
|
||||
BoardColumnGql,
|
||||
CommandResultGql,
|
||||
ProjectGql,
|
||||
ProjectStatsGql,
|
||||
ReleaseGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => ProjectGql)
|
||||
export class ProjectResolver {
|
||||
constructor(private readonly projects: ProjectsService) {}
|
||||
|
||||
@Query(() => [ProjectGql], { description: 'Projects where I am a member' })
|
||||
myProjects(@CurrentUser() me: User) {
|
||||
return this.projects.findMine(me.id);
|
||||
}
|
||||
|
||||
@Query(() => ProjectGql)
|
||||
project(
|
||||
@CurrentUser() me: User,
|
||||
@Args('idOrKey', { description: 'Project id or key (e.g. "TS")' })
|
||||
idOrKey: string,
|
||||
) {
|
||||
return this.projects.findOne(me.id, idOrKey);
|
||||
}
|
||||
|
||||
@ResolveField(() => [ReleaseGql])
|
||||
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
|
||||
return this.projects.releases(me.id, project.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => ProjectStatsGql, {
|
||||
description: 'Project tracking dashboard',
|
||||
})
|
||||
stats(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
|
||||
return this.projects.stats(me.id, project.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [BoardColumnGql], {
|
||||
description: 'Kanban board, one column per status',
|
||||
})
|
||||
board(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() project: ProjectReadModel,
|
||||
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
|
||||
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
|
||||
@Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea,
|
||||
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
|
||||
) {
|
||||
return this.projects.board(
|
||||
me.id,
|
||||
{
|
||||
projectId: project.id,
|
||||
releaseId,
|
||||
assigneeId,
|
||||
areas: area ? [area] : undefined,
|
||||
},
|
||||
perColumn,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
createProject(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateProjectInput,
|
||||
) {
|
||||
return this.projects.create(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
updateProject(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateProjectInput,
|
||||
) {
|
||||
const { projectId, ...changes } = input;
|
||||
return this.projects.update(me.id, projectId, changes);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Add a Pireon user to the project',
|
||||
})
|
||||
addProjectMember(
|
||||
@CurrentUser() me: User,
|
||||
@AccessToken() token: string,
|
||||
@Args('input') input: ProjectMemberInput,
|
||||
) {
|
||||
return this.projects.addMember(me.id, input, token);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
changeProjectMemberRole(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: ProjectMemberInput,
|
||||
) {
|
||||
return this.projects.changeMemberRole(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
removeProjectMember(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('userId', { type: () => ID }) userId: string,
|
||||
) {
|
||||
return this.projects.removeMember(me.id, projectId, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
ReleasesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
|
||||
import {
|
||||
CommandResultGql,
|
||||
IssuePageGql,
|
||||
ReleaseGql,
|
||||
ReleaseProgressGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => ReleaseGql)
|
||||
export class ReleaseResolver {
|
||||
constructor(
|
||||
private readonly releases: ReleasesService,
|
||||
private readonly issuesService: IssuesService,
|
||||
) {}
|
||||
|
||||
@Query(() => ReleaseGql)
|
||||
release(@CurrentUser() me: User, @Args('id', { type: () => ID }) id: string) {
|
||||
return this.releases.findOne(me.id, id);
|
||||
}
|
||||
|
||||
@ResolveField(() => ReleaseProgressGql)
|
||||
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
|
||||
return this.releases.progress(me.id, release.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => IssuePageGql, {
|
||||
description: 'Tickets included in this release',
|
||||
})
|
||||
issues(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() release: ReleaseReadModel,
|
||||
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.issuesService.search(me.id, {
|
||||
projectId: release.projectId,
|
||||
releaseId: release.id,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
createRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateReleaseInput,
|
||||
) {
|
||||
return this.releases.create(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
updateRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateReleaseInput,
|
||||
) {
|
||||
const { releaseId, ...changes } = input;
|
||||
return this.releases.update(me.id, releaseId, changes);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Ship the release. Fails while it has unfinished tickets unless force = true.',
|
||||
})
|
||||
publishRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
@Args('force', { defaultValue: false }) force: boolean,
|
||||
) {
|
||||
return this.releases.publish(me.id, releaseId, force);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
archiveRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
) {
|
||||
return this.releases.archive(me.id, releaseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Args, ID, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { ChangesService } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityGql,
|
||||
CommentChangeGql,
|
||||
IssueChangeGql,
|
||||
ProjectChangeGql,
|
||||
ReleaseChangeGql,
|
||||
} from './types';
|
||||
|
||||
const asIs = { resolve: <T>(payload: T) => payload };
|
||||
|
||||
/**
|
||||
* Realtime API (graphql-ws). Events are emitted after the read model is
|
||||
* updated, so a client can refetch and always see the new state.
|
||||
*/
|
||||
@Resolver()
|
||||
export class SubscriptionResolver {
|
||||
constructor(private readonly changes: ChangesService) {}
|
||||
|
||||
@Subscription(() => IssueChangeGql, {
|
||||
...asIs,
|
||||
description:
|
||||
'Tickets created/updated/moved in a project (board in realtime)',
|
||||
})
|
||||
issueChanged(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.changes.watchIssues(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => CommentChangeGql, asIs)
|
||||
commentChanged(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
) {
|
||||
return this.changes.watchComments(me.id, issueId);
|
||||
}
|
||||
|
||||
@Subscription(() => ActivityGql, {
|
||||
...asIs,
|
||||
description: 'Live activity feed',
|
||||
})
|
||||
activityAdded(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.changes.watchActivity(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => ReleaseChangeGql, asIs)
|
||||
releaseChanged(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.changes.watchReleases(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => ProjectChangeGql, asIs)
|
||||
projectChanged(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.changes.watchProject(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => ProjectChangeGql, {
|
||||
...asIs,
|
||||
description: 'Projects I am added to / removed from',
|
||||
})
|
||||
myProjectsChanged(@CurrentUser() me: User) {
|
||||
return this.changes.watchMyProjects(me.id);
|
||||
}
|
||||
}
|
||||
223
libs/api/tracker/presenters/src/lib/graphql/types.ts
Normal file
223
libs/api/tracker/presenters/src/lib/graphql/types.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { LinkDirection } from './enums';
|
||||
|
||||
@ObjectType('UserRef')
|
||||
export class UserRefGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() name!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectMember')
|
||||
export class ProjectMemberGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field(() => ProjectRole) role!: ProjectRole;
|
||||
}
|
||||
|
||||
@ObjectType('Project')
|
||||
export class ProjectGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field(() => UserRefGql) owner!: UserRefGql;
|
||||
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
|
||||
@Field() createdAt!: string;
|
||||
@Field() updatedAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('IssueLink')
|
||||
export class IssueLinkGql {
|
||||
@Field(() => IssueLinkType) type!: IssueLinkType;
|
||||
@Field(() => LinkDirection, {
|
||||
description: 'OUTWARD: this issue → other. INWARD: other → this issue.',
|
||||
})
|
||||
direction!: LinkDirection;
|
||||
@Field(() => ID) issueId!: string;
|
||||
@Field() issueKey!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseRef')
|
||||
export class ReleaseRefGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() name!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Issue')
|
||||
export class IssueGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field() projectKey!: string;
|
||||
@Field(() => Int) number!: number;
|
||||
@Field({ description: 'Human key, e.g. TS-42' }) key!: string;
|
||||
@Field(() => IssueType) type!: IssueType;
|
||||
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field(() => IssueStatus) status!: IssueStatus;
|
||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||
@Field(() => UserRefGql) reporter!: UserRefGql;
|
||||
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
||||
@Field(() => ReleaseRefGql, { nullable: true })
|
||||
release!: ReleaseRefGql | null;
|
||||
@Field(() => [String]) labels!: string[];
|
||||
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
|
||||
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
|
||||
@Field(() => Int) commentCount!: number;
|
||||
@Field() createdAt!: string;
|
||||
@Field() updatedAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('IssuePage')
|
||||
export class IssuePageGql {
|
||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||
@Field(() => Int) total!: number;
|
||||
}
|
||||
|
||||
@ObjectType('BoardColumn')
|
||||
export class BoardColumnGql {
|
||||
@Field(() => IssueStatus) status!: IssueStatus;
|
||||
@Field(() => [IssueGql]) issues!: IssueGql[];
|
||||
@Field(() => Int) total!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) issueId!: string;
|
||||
@Field(() => UserRefGql) author!: UserRefGql;
|
||||
@Field() body!: string;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('CommentPage')
|
||||
export class CommentPageGql {
|
||||
@Field(() => [CommentGql]) items!: CommentGql[];
|
||||
@Field(() => Int) total!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Release')
|
||||
export class ReleaseGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => ReleaseStatus) status!: ReleaseStatus;
|
||||
@Field(() => String, { nullable: true }) releasedAt!: string | null;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('CountByStatus')
|
||||
export class CountByStatusGql {
|
||||
@Field(() => IssueStatus) key!: IssueStatus;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('CountByKey')
|
||||
export class CountByKeyGql {
|
||||
@Field() key!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('CountByAssignee')
|
||||
export class CountByAssigneeGql {
|
||||
@Field(() => UserRefGql, { nullable: true, description: 'Null = unassigned' })
|
||||
user!: UserRefGql | null;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseProgress')
|
||||
export class ReleaseProgressGql {
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) finished!: number;
|
||||
@Field(() => Int) percent!: number;
|
||||
@Field(() => [CountByStatusGql]) byStatus!: CountByStatusGql[];
|
||||
}
|
||||
|
||||
@ObjectType('ProjectStats')
|
||||
export class ProjectStatsGql {
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) open!: number;
|
||||
@Field(() => [CountByStatusGql]) byStatus!: CountByStatusGql[];
|
||||
@Field(() => [CountByKeyGql]) byType!: CountByKeyGql[];
|
||||
@Field(() => [CountByKeyGql]) byPriority!: CountByKeyGql[];
|
||||
@Field(() => [CountByKeyGql]) byArea!: CountByKeyGql[];
|
||||
@Field(() => [CountByAssigneeGql], { description: 'Open tickets per person' })
|
||||
byAssignee!: CountByAssigneeGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Activity')
|
||||
export class ActivityGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() type!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field(() => ID, { nullable: true }) issueId!: string | null;
|
||||
@Field(() => String, { nullable: true }) issueKey!: string | null;
|
||||
@Field(() => UserRefGql) actor!: UserRefGql;
|
||||
@Field() summary!: string;
|
||||
@Field() occurredAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('CommandResult', {
|
||||
description:
|
||||
'Writes are acknowledged immediately; the read model catches up asynchronously. ' +
|
||||
'Wait for a subscription event with version >= this version to see the change.',
|
||||
})
|
||||
export class CommandResultGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => String, { nullable: true }) key?: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
/* Subscription payloads */
|
||||
|
||||
@ObjectType('IssueChange')
|
||||
export class IssueChangeGql {
|
||||
@Field() action!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => IssueGql) issue!: IssueGql;
|
||||
}
|
||||
|
||||
@ObjectType('CommentChange')
|
||||
export class CommentChangeGql {
|
||||
@Field() action!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => ID) issueId!: string;
|
||||
@Field(() => ID) commentId!: string;
|
||||
@Field(() => CommentGql, { nullable: true, description: 'Null when deleted' })
|
||||
comment!: CommentGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseChange')
|
||||
export class ReleaseChangeGql {
|
||||
@Field() action!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => ReleaseGql) release!: ReleaseGql;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectChange')
|
||||
export class ProjectChangeGql {
|
||||
@Field() action!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => ProjectGql) project!: ProjectGql;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import './graphql/enums';
|
||||
import { CommentResolver } from './graphql/comment.resolver';
|
||||
import { IssueResolver } from './graphql/issue.resolver';
|
||||
import { ProjectResolver } from './graphql/project.resolver';
|
||||
import { ReleaseResolver } from './graphql/release.resolver';
|
||||
import { SubscriptionResolver } from './graphql/subscription.resolver';
|
||||
|
||||
/** Driving adapters of the tracker context (GraphQL queries, mutations, subscriptions). */
|
||||
@Module({
|
||||
providers: [
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
ReleaseResolver,
|
||||
SubscriptionResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
static withApplication(
|
||||
applicationModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return { module: TrackerPresentersModule, imports: [applicationModule] };
|
||||
}
|
||||
}
|
||||
23
libs/api/tracker/presenters/tsconfig.json
Normal file
23
libs/api/tracker/presenters/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/tracker/presenters/tsconfig.lib.json
Normal file
23
libs/api/tracker/presenters/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/tracker/presenters/tsconfig.spec.json
Normal file
16
libs/api/tracker/presenters/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user