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,14 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReadAccess } from './read-access';
import { CanViewProjectQuery } from './can-view-project.query';
@QueryHandler(CanViewProjectQuery)
export class CanViewProjectQueryHandler
implements IQueryHandler<CanViewProjectQuery, boolean>
{
constructor(private readonly access: ReadAccess) {}
async execute(query: CanViewProjectQuery): Promise<boolean> {
return this.access.canView(query.projectId, query.viewerId);
}
}

View File

@@ -0,0 +1,10 @@
import { Query } from '@nestjs/cqrs';
export class CanViewProjectQuery extends Query<boolean> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -0,0 +1,25 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { ReadAccess } from './read-access';
import { GetActivityQuery } from './get-activity.query';
@QueryHandler(GetActivityQuery)
export class GetActivityQueryHandler
implements IQueryHandler<GetActivityQuery, ActivityReadModel[]>
{
constructor(
private readonly findActivity: FindActivityRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findActivity.find({
projectId: query.projectId,
issueId: query.issueId,
limit: Math.min(query.limit, 200),
before: query.before,
});
}
}

View File

@@ -0,0 +1,15 @@
import { Query } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export class GetActivityQuery extends Query<ActivityReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
public readonly issueId?: string,
public readonly limit = 50,
/** ISO date cursor: only activity strictly older than this. */
public readonly before?: string,
) {
super();
}
}

View File

@@ -0,0 +1,41 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './get-board.query';
import { ReadAccess } from './read-access';
const BOARD_COLUMNS: IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
@QueryHandler(GetBoardQuery)
export class GetBoardQueryHandler
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
return Promise.all(
BOARD_COLUMNS.map(async (status) => {
const page = await this.findIssues.search({
...query.filter,
statuses: [status],
limit: Math.min(query.perColumn, 200),
offset: 0,
});
return { status, issues: page.items, total: page.total };
}),
);
}
}

View File

@@ -0,0 +1,20 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
import { IssueFilter } from '../ports/find-issues.repository';
export interface BoardColumn {
status: IssueStatus;
issues: IssueReadModel[];
total: number;
}
/** Kanban board: one column per status. */
export class GetBoardQuery extends Query<BoardColumn[]> {
constructor(
public readonly viewerId: string,
public readonly filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
public readonly perColumn = 50,
) {
super();
}
}

View File

@@ -0,0 +1,31 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { ReadAccess } from './read-access';
import { GetCommentsQuery } from './get-comments.query';
@QueryHandler(GetCommentsQuery)
export class GetCommentsQueryHandler
implements IQueryHandler<GetCommentsQuery, Page<CommentReadModel>>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetCommentsQuery): Promise<Page<CommentReadModel>> {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
await this.access.assertCanView(issue.projectId, query.viewerId);
return this.findComments.findByIssue(
issue.id,
Math.min(query.limit, 200),
query.offset,
);
}
}

View File

@@ -0,0 +1,14 @@
import { Query } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { Page } from '../ports/find-issues.repository';
export class GetCommentsQuery extends Query<Page<CommentReadModel>> {
constructor(
public readonly viewerId: string,
public readonly issueId: string,
public readonly limit = 50,
public readonly offset = 0,
) {
super();
}
}

View File

@@ -0,0 +1,24 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { ReadAccess, UUID } from './read-access';
import { GetIssueQuery } from './get-issue.query';
@QueryHandler(GetIssueQuery)
export class GetIssueQueryHandler
implements IQueryHandler<GetIssueQuery, IssueReadModel>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetIssueQuery): Promise<IssueReadModel> {
const issue = UUID.test(query.idOrKey)
? await this.findIssues.findById(query.idOrKey)
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
await this.access.assertCanView(found.projectId, query.viewerId);
return found;
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssueQuery extends Query<IssueReadModel> {
constructor(
public readonly viewerId: string,
/** Issue id or key ("TS-42"). */
public readonly idOrKey: string,
) {
super();
}
}

View File

@@ -0,0 +1,23 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { GetIssuesByIdsQuery } from './get-issues-by-ids.query';
@QueryHandler(GetIssuesByIdsQuery)
export class GetIssuesByIdsQueryHandler
implements IQueryHandler<GetIssuesByIdsQuery, IssueReadModel[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
) {}
async execute(query: GetIssuesByIdsQuery): Promise<IssueReadModel[]> {
const issues = await this.findIssues.findByIds([...new Set(query.ids)]);
const visible = new Set(
(await this.findProjects.findByMember(query.viewerId)).map((p) => p.id),
);
return issues.filter((issue) => visible.has(issue.projectId));
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssuesByIdsQuery extends Query<IssueReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly ids: string[],
) {
super();
}
}

View File

@@ -0,0 +1,15 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { GetMyProjectsQuery } from './get-my-projects.query';
@QueryHandler(GetMyProjectsQuery)
export class GetMyProjectsQueryHandler
implements IQueryHandler<GetMyProjectsQuery, ProjectReadModel[]>
{
constructor(private readonly findProjects: FindProjectsRepository) {}
async execute(query: GetMyProjectsQuery): Promise<ProjectReadModel[]> {
return this.findProjects.findByMember(query.viewerId);
}
}

View File

@@ -0,0 +1,8 @@
import { Query } from '@nestjs/cqrs';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
export class GetMyProjectsQuery extends Query<ProjectReadModel[]> {
constructor(public readonly viewerId: string) {
super();
}
}

View File

@@ -0,0 +1,20 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ProjectStatsReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { ReadAccess } from './read-access';
import { GetProjectStatsQuery } from './get-project-stats.query';
@QueryHandler(GetProjectStatsQuery)
export class GetProjectStatsQueryHandler
implements IQueryHandler<GetProjectStatsQuery, ProjectStatsReadModel>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findIssues.stats(query.projectId);
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { ProjectStatsReadModel } from '@task-stream/api/tracker/domain';
export class GetProjectStatsQuery extends Query<ProjectStatsReadModel> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -0,0 +1,24 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess, UUID } from './read-access';
import { GetProjectQuery } from './get-project.query';
@QueryHandler(GetProjectQuery)
export class GetProjectQueryHandler
implements IQueryHandler<GetProjectQuery, ProjectReadModel>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetProjectQuery): Promise<ProjectReadModel> {
const project = UUID.test(query.idOrKey)
? await this.findProjects.findById(query.idOrKey)
: await this.findProjects.findByKey(query.idOrKey.toUpperCase());
const found = this.access.assertFound(project, 'Project', query.idOrKey);
await this.access.assertCanView(found.id, query.viewerId);
return found;
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
export class GetProjectQuery extends Query<ProjectReadModel> {
constructor(
public readonly viewerId: string,
/** Project id or key ("TS"). */
public readonly idOrKey: string,
) {
super();
}
}

View File

@@ -0,0 +1,29 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseProgressReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { ReadAccess } from './read-access';
import { GetReleaseProgressQuery } from './get-release-progress.query';
@QueryHandler(GetReleaseProgressQuery)
export class GetReleaseProgressQueryHandler
implements IQueryHandler<GetReleaseProgressQuery, ReleaseProgressReadModel>
{
constructor(
private readonly findReleases: FindReleasesRepository,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(
query: GetReleaseProgressQuery,
): Promise<ReleaseProgressReadModel> {
const release = this.access.assertFound(
await this.findReleases.findById(query.releaseId),
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId);
return this.findIssues.releaseProgress(release.id);
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { ReleaseProgressReadModel } from '@task-stream/api/tracker/domain';
export class GetReleaseProgressQuery extends Query<ReleaseProgressReadModel> {
constructor(
public readonly viewerId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,25 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { ReadAccess } from './read-access';
import { GetReleaseQuery } from './get-release.query';
@QueryHandler(GetReleaseQuery)
export class GetReleaseQueryHandler
implements IQueryHandler<GetReleaseQuery, ReleaseReadModel>
{
constructor(
private readonly findReleases: FindReleasesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetReleaseQuery): Promise<ReleaseReadModel> {
const release = this.access.assertFound(
await this.findReleases.findById(query.releaseId),
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId);
return release;
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export class GetReleaseQuery extends Query<ReleaseReadModel> {
constructor(
public readonly viewerId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,20 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { ReadAccess } from './read-access';
import { GetReleasesQuery } from './get-releases.query';
@QueryHandler(GetReleasesQuery)
export class GetReleasesQueryHandler
implements IQueryHandler<GetReleasesQuery, ReleaseReadModel[]>
{
constructor(
private readonly findReleases: FindReleasesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findReleases.findByProject(query.projectId);
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export class GetReleasesQuery extends Query<ReleaseReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -0,0 +1,13 @@
export * from './can-view-project.query';
export * from './get-activity.query';
export * from './get-board.query';
export * from './get-comments.query';
export * from './get-issue.query';
export * from './get-issues-by-ids.query';
export * from './get-my-projects.query';
export * from './get-project-stats.query';
export * from './get-project.query';
export * from './get-release-progress.query';
export * from './get-release.query';
export * from './get-releases.query';
export * from './search-issues.query';

View File

@@ -0,0 +1,29 @@
import { CanViewProjectQueryHandler } from './can-view-project.query-handler';
import { GetActivityQueryHandler } from './get-activity.query-handler';
import { GetBoardQueryHandler } from './get-board.query-handler';
import { GetCommentsQueryHandler } from './get-comments.query-handler';
import { GetIssueQueryHandler } from './get-issue.query-handler';
import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler';
import { GetReleaseProgressQueryHandler } from './get-release-progress.query-handler';
import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
export const QUERY_HANDLERS = [
CanViewProjectQueryHandler,
GetActivityQueryHandler,
GetBoardQueryHandler,
GetCommentsQueryHandler,
GetIssueQueryHandler,
GetIssuesByIdsQueryHandler,
GetMyProjectsQueryHandler,
GetProjectQueryHandler,
GetProjectStatsQueryHandler,
GetReleaseQueryHandler,
GetReleaseProgressQueryHandler,
GetReleasesQueryHandler,
SearchIssuesQueryHandler,
];

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
/** Authorisation for the read side, based on the projected memberships. */
@Injectable()
export class ReadAccess {
constructor(private readonly findProjects: FindProjectsRepository) {}
async assertCanView(
projectId: string,
viewerId: string,
): Promise<ProjectRole> {
const role = await this.findProjects.findRole(projectId, viewerId);
// Same answer for "doesn't exist" and "not yours": don't leak projects.
if (!role) throw new NotFoundError('Project', projectId);
return role;
}
async canView(projectId: string, viewerId: string): Promise<boolean> {
return (await this.findProjects.findRole(projectId, viewerId)) !== null;
}
assertFound<T>(value: T | null, entity: string, id: string): T {
if (!value) throw new NotFoundError(entity, id);
return value;
}
}
export const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -0,0 +1,23 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { ReadAccess } from './read-access';
import { SearchIssuesQuery } from './search-issues.query';
@QueryHandler(SearchIssuesQuery)
export class SearchIssuesQueryHandler
implements IQueryHandler<SearchIssuesQuery, Page<IssueReadModel>>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: SearchIssuesQuery): Promise<Page<IssueReadModel>> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
return this.findIssues.search({
...query.filter,
limit: Math.min(query.filter.limit, 200),
});
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueFilter, Page } from '../ports/find-issues.repository';
export class SearchIssuesQuery extends Query<Page<IssueReadModel>> {
constructor(
public readonly viewerId: string,
public readonly filter: IssueFilter,
) {
super();
}
}