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,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);
}
}