feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I follow, status categories, priorities, bugs only, overdue and text. They are private (Mongo my_views, no history), listed and deleted by their owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views), and run as the cross-project search, which gains project, priority, bugsOnly and overdue filters (also in MCP my_issues). The home page shows them as a bar above «my work», with a drawer to save a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,11 @@ export interface CrossProjectIssueFilter {
|
||||
watcherId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Project id → the only type ids taken there. */
|
||||
typesByProject?: Record<string, string[]>;
|
||||
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
||||
dueBefore?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Saved views of «my work»: one person's preferences, no history. */
|
||||
export abstract class MyViewsRepository {
|
||||
/** By name. */
|
||||
abstract findByOwner(ownerId: string): Promise<MyViewReadModel[]>;
|
||||
abstract findById(id: string): Promise<MyViewReadModel | null>;
|
||||
abstract countByOwner(ownerId: string): Promise<number>;
|
||||
abstract insert(view: MyViewReadModel): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { SearchMyIssuesQuery } from './search-my-issues.query';
|
||||
@@ -15,13 +19,43 @@ export class SearchMyIssuesQueryHandler
|
||||
|
||||
/** Membership is the access rule: only projects the viewer belongs to. */
|
||||
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
|
||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||
const { filter } = query;
|
||||
const projects = (
|
||||
await this.findProjects.findByMember(query.viewerId)
|
||||
).filter(
|
||||
(p) => !filter.projectIds?.length || filter.projectIds.includes(p.id),
|
||||
);
|
||||
// Bugs are each project's types drawn with the bug icon.
|
||||
const bugTypes = filter.bugsOnly
|
||||
? Object.fromEntries(
|
||||
projects.map((p) => [
|
||||
p.id,
|
||||
(p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes
|
||||
.filter((t) => t.icon === 'bug')
|
||||
.map((t) => t.id),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
const open = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
return this.findIssues.searchAcross({
|
||||
projectIds: projects.map((project) => project.id),
|
||||
priorities: filter.priorities?.length ? filter.priorities : undefined,
|
||||
typesByProject: bugTypes,
|
||||
dueBefore: filter.overdue
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: query.filter.categories,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
open.includes(c),
|
||||
)
|
||||
: query.filter.categories,
|
||||
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
||||
limit: Math.min(query.filter.limit, 200),
|
||||
offset: query.filter.offset,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
/** Only these of my projects. */
|
||||
projectIds?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only tickets of a type drawn with the bug icon (in each project). */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
SavedFilterCriteriaReadModel,
|
||||
SavedFilterReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DeleteMyViewCommand,
|
||||
GetMyViewsQuery,
|
||||
SaveMyViewCommand,
|
||||
} from './saved-filters/my-views';
|
||||
import {
|
||||
DeleteSavedFilterCommand,
|
||||
GetSavedFiltersQuery,
|
||||
@@ -18,7 +25,7 @@ export interface SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaReadModel;
|
||||
}
|
||||
|
||||
/** Saved board views: mine and the ones shared with the project. */
|
||||
/** Saved views: the board's (mine and shared) and my own of «my work». */
|
||||
@Injectable()
|
||||
export class SavedFiltersService {
|
||||
constructor(
|
||||
@@ -47,4 +54,22 @@ export class SavedFiltersService {
|
||||
new DeleteSavedFilterCommand(actorId, filterId),
|
||||
);
|
||||
}
|
||||
|
||||
/** My views of «my work», by name. */
|
||||
myViews(ownerId: string): Promise<MyViewReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyViewsQuery(ownerId));
|
||||
}
|
||||
|
||||
saveMyView(
|
||||
ownerId: string,
|
||||
input: { name: string; criteria: MyViewCriteriaReadModel },
|
||||
): Promise<{ id: string }> {
|
||||
return this.commands.execute(
|
||||
new SaveMyViewCommand(ownerId, input.name, input.criteria),
|
||||
);
|
||||
}
|
||||
|
||||
deleteMyView(ownerId: string, viewId: string): Promise<void> {
|
||||
return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId));
|
||||
}
|
||||
}
|
||||
|
||||
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { MyViewsRepository } from '../ports/my-views.repository';
|
||||
|
||||
/*
|
||||
* Saved views of «my work»: across my projects, private, no history (like
|
||||
* the board's saved views and notification settings).
|
||||
*/
|
||||
|
||||
/** A person keeps at most this many. */
|
||||
export const MAX_MY_VIEWS = 20;
|
||||
|
||||
export class SaveMyViewCommand extends Command<{ id: string }> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly name: string,
|
||||
public readonly criteria: MyViewCriteriaReadModel,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteMyViewCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly viewId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyViewsQuery extends Query<MyViewReadModel[]> {
|
||||
constructor(public readonly ownerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops empty criteria, trims the text and deduplicates lists. */
|
||||
export function normalizeMyView(
|
||||
criteria: MyViewCriteriaReadModel,
|
||||
): MyViewCriteriaReadModel {
|
||||
const list = <T extends string>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
const out: MyViewCriteriaReadModel = {
|
||||
projectIds: list(criteria.projectIds),
|
||||
assignedToMe: criteria.assignedToMe || undefined,
|
||||
watching: criteria.watching || undefined,
|
||||
categories: list(criteria.categories),
|
||||
priorities: list(criteria.priorities),
|
||||
bugsOnly: criteria.bugsOnly || undefined,
|
||||
overdue: criteria.overdue || undefined,
|
||||
search: criteria.search?.trim().slice(0, 200) || undefined,
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(out).filter(([, value]) => value !== undefined),
|
||||
) as MyViewCriteriaReadModel;
|
||||
}
|
||||
|
||||
@CommandHandler(SaveMyViewCommand)
|
||||
export class SaveMyViewCommandHandler
|
||||
implements ICommandHandler<SaveMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: SaveMyViewCommand): Promise<{ id: string }> {
|
||||
const name = command.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 60,
|
||||
'A view needs a name (up to 60 characters)',
|
||||
);
|
||||
if ((await this.views.countByOwner(command.ownerId)) >= MAX_MY_VIEWS)
|
||||
throw new BusinessRuleError(
|
||||
`You already keep ${MAX_MY_VIEWS} views; delete one first`,
|
||||
);
|
||||
const id = randomUUID();
|
||||
await this.views.insert({
|
||||
id,
|
||||
ownerId: command.ownerId,
|
||||
name,
|
||||
criteria: normalizeMyView(command.criteria),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteMyViewCommand)
|
||||
export class DeleteMyViewCommandHandler
|
||||
implements ICommandHandler<DeleteMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: DeleteMyViewCommand): Promise<void> {
|
||||
const view = await this.views.findById(command.viewId);
|
||||
// Someone else's view does not exist, as far as this person knows.
|
||||
if (!view || view.ownerId !== command.ownerId)
|
||||
throw new NotFoundError('View', command.viewId);
|
||||
await this.views.delete(view.id);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyViewsQuery)
|
||||
export class GetMyViewsQueryHandler
|
||||
implements IQueryHandler<GetMyViewsQuery, MyViewReadModel[]>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
execute(query: GetMyViewsQuery): Promise<MyViewReadModel[]> {
|
||||
return this.views.findByOwner(query.ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
export const MY_VIEWS = [
|
||||
SaveMyViewCommandHandler,
|
||||
DeleteMyViewCommandHandler,
|
||||
GetMyViewsQueryHandler,
|
||||
];
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './notifications/notifications.commands';
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
@@ -101,6 +102,7 @@ const SERVICES = [
|
||||
...SAGAS,
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...MY_VIEWS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
Reference in New Issue
Block a user