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:
2026-10-15 14:22:20 +02:00
parent e14f445e6b
commit 5b0396bc10
46 changed files with 2648 additions and 35 deletions

View File

@@ -29,4 +29,6 @@ export * from './lib/webhooks/webhook-events';
export * from './lib/git/git';
export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/tracker-application.module';

View File

@@ -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;

View File

@@ -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>;
}

View File

@@ -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,

View File

@@ -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;
}

View File

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

View 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,
];

View File

@@ -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,

View File

@@ -1,4 +1,5 @@
import { IssuePriority } from '../issue/issue.enums';
import { StatusCategory } from '../project/project-settings';
import { UserRefReadModel } from './user-ref.read-model';
/** What a saved view of the board narrows the tickets to. Empty: everything. */
@@ -27,3 +28,31 @@ export class SavedFilterReadModel {
criteria!: SavedFilterCriteriaReadModel;
createdAt!: string;
}
/**
* What a view of «my work» narrows to, across the projects I belong to.
* Empty: every ticket of them.
*/
export class MyViewCriteriaReadModel {
/** Only these projects (empty: all of mine). */
projectIds?: string[];
assignedToMe?: boolean;
/** Only the tickets I follow. */
watching?: boolean;
categories?: StatusCategory[];
priorities?: IssuePriority[];
/** Only tickets of a type drawn with the bug icon. */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
search?: string;
}
/** A named view of «my work», private to whoever saved it. */
export class MyViewReadModel {
id!: string;
ownerId!: string;
name!: string;
criteria!: MyViewCriteriaReadModel;
createdAt!: string;
}

View File

@@ -172,14 +172,27 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };
if (filter.priorities?.length)
query['priority'] = { $in: filter.priorities };
if (filter.dueBefore)
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
const and: Record<string, unknown>[] = [];
if (filter.typesByProject)
and.push({
$or: Object.entries(filter.typesByProject).map(
([projectId, types]) => ({
projectId,
type: { $in: types },
}),
),
});
if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query['$or'] = [
{ key: pattern },
{ title: pattern },
{ description: pattern },
];
and.push({
$or: [{ key: pattern }, { title: pattern }, { description: pattern }],
});
}
if (and.length) query['$and'] = and;
const [docs, total] = await Promise.all([
this.issues
.find(query)

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { MyViewsRepository } from '@task-stream/api/tracker/application';
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { MyViewDoc, ReadModels, fromDoc } from './read-model.schemas';
@Injectable()
export class MongoMyViewsRepository extends MyViewsRepository {
constructor(
@InjectModel(ReadModels.MyView)
private readonly views: Model<MyViewDoc>,
) {
super();
}
async findByOwner(ownerId: string): Promise<MyViewReadModel[]> {
const docs = await this.views
.find({ ownerId })
.sort({ name: 1 })
.lean<MyViewDoc[]>();
return docs.map(toView);
}
async findById(id: string): Promise<MyViewReadModel | null> {
const doc = await this.views.findOne({ id }).lean<MyViewDoc>();
return doc ? toView(doc) : null;
}
countByOwner(ownerId: string): Promise<number> {
return this.views.countDocuments({ ownerId });
}
async insert(view: MyViewReadModel): Promise<void> {
await this.views.create(view);
}
async delete(id: string): Promise<void> {
await this.views.deleteOne({ id });
}
}
/** Mongo leaves empty objects out: a view with no criteria has none. */
function toView(doc: MyViewDoc): MyViewReadModel {
const view = fromDoc(doc) as MyViewReadModel;
return { ...view, criteria: view.criteria ?? {} };
}

View File

@@ -12,6 +12,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
SavedFilterReadModel,
MyViewReadModel,
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
@@ -40,6 +41,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type MyViewDoc = Doc<MyViewReadModel>;
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc {
@@ -94,6 +96,7 @@ export const ReadModels = {
Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
MyView: 'MyView',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -302,6 +305,15 @@ export const SavedFilterSchema =
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 });
@Schema({ ...LOOSE, collection: 'my_views' })
class MyViewDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) ownerId!: string;
@Prop({ type: String }) name!: string;
@Prop({ type: String }) createdAt!: string;
}
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -390,6 +402,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
];

View File

@@ -11,6 +11,7 @@ import {
PushSubscriptionsRepository,
PushSender,
SavedFiltersRepository,
MyViewsRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -72,6 +73,7 @@ import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -143,6 +145,7 @@ const PORTS = [
},
{ provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -784,3 +784,39 @@ export class PushSubscriptionInput {
@Field() @IsString() @MaxLength(200) p256dh!: string;
@Field() @IsString() @MaxLength(100) auth!: string;
}
@InputType()
export class MyViewCriteriaInput {
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsUUID('all', { each: true })
projectIds?: string[];
@Field({ nullable: true }) @IsOptional() @IsBoolean() assignedToMe?: boolean;
@Field({ nullable: true }) @IsOptional() @IsBoolean() watching?: boolean;
@Field(() => [StatusCategory], { nullable: true })
@IsOptional()
@IsEnum(StatusCategory, { each: true })
categories?: StatusCategory[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@Field({ nullable: true }) @IsOptional() @IsBoolean() bugsOnly?: boolean;
@Field({ nullable: true }) @IsOptional() @IsBoolean() overdue?: boolean;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string | null;
}
@InputType()
export class SaveMyViewInput {
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
@Field(() => MyViewCriteriaInput)
@ValidateNested()
@Type(() => MyViewCriteriaInput)
criteria!: MyViewCriteriaInput;
}

View File

@@ -20,6 +20,7 @@ import {
WorkflowsService,
} from '@task-stream/api/tracker/application';
import {
IssuePriority,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -121,6 +122,26 @@ export class IssueResolver {
description: 'Matches key, title or description (global search)',
})
search?: string | null,
@Args('projectIds', {
type: () => [ID],
nullable: true,
description: 'Only these of my projects',
})
projectIds?: string[] | null,
@Args('priorities', { type: () => [IssuePriority], nullable: true })
priorities?: IssuePriority[] | null,
@Args('bugsOnly', {
type: () => Boolean,
nullable: true,
description: 'Only types drawn with the bug icon',
})
bugsOnly?: boolean | null,
@Args('overdue', {
type: () => Boolean,
nullable: true,
description: 'Only open tickets past their due date',
})
overdue?: boolean | null,
) {
return this.issuesService.mine(me.id, {
assignedToMe,
@@ -128,6 +149,10 @@ export class IssueResolver {
statuses: statuses ?? undefined,
categories: categories ?? undefined,
search: search ?? undefined,
projectIds: projectIds ?? undefined,
priorities: priorities ?? undefined,
bugsOnly: bugsOnly ?? undefined,
overdue: overdue ?? undefined,
limit,
offset,
});

View File

@@ -2,8 +2,8 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterInput } from './inputs';
import { SavedFilterGql } from './types';
import { SaveFilterInput, SaveMyViewInput } from './inputs';
import { MyViewGql, SavedFilterGql } from './types';
/** Saved views of a project's board. */
@Resolver(() => SavedFilterGql)
@@ -41,4 +41,36 @@ export class SavedFilterResolver {
await this.filters.delete(me.id, id);
return true;
}
@Query(() => [MyViewGql], {
description: 'My saved views of my work, across my projects',
})
myViews(@CurrentUser() me: User) {
return this.filters.myViews(me.id);
}
@Mutation(() => ID, { description: 'Save a view of my work under a name' })
async saveMyView(
@CurrentUser() me: User,
@Args('input') input: SaveMyViewInput,
) {
return (
await this.filters.saveMyView(me.id, {
name: input.name,
criteria: {
...input.criteria,
search: input.criteria.search ?? undefined,
},
})
).id;
}
@Mutation(() => Boolean, { description: 'Delete one of my views' })
async deleteMyView(
@CurrentUser() me: User,
@Args('id', { type: () => ID }) id: string,
) {
await this.filters.deleteMyView(me.id, id);
return true;
}
}

View File

@@ -456,6 +456,34 @@ export class SavedFilterGql {
@Field() createdAt!: string;
}
@ObjectType('MyViewCriteria', {
description:
'What a view of my work narrows to; empty: every ticket of my projects',
})
export class MyViewCriteriaGql {
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
projectIds?: string[];
@Field({ nullable: true }) assignedToMe?: boolean;
@Field({ nullable: true }) watching?: boolean;
@Field(() => [StatusCategory], { nullable: true })
categories?: StatusCategory[];
@Field(() => [IssuePriority], { nullable: true })
priorities?: IssuePriority[];
@Field({ nullable: true, description: 'Only types with the bug icon' })
bugsOnly?: boolean;
@Field({ nullable: true, description: 'Only open tickets past due' })
overdue?: boolean;
@Field(() => String, { nullable: true }) search?: string | null;
}
@ObjectType('MyView', { description: 'A named view of my work (private)' })
export class MyViewGql {
@Field(() => ID) id!: string;
@Field() name!: string;
@Field(() => MyViewCriteriaGql) criteria!: MyViewCriteriaGql;
@Field() createdAt!: string;
}
@ObjectType('IssuePage')
export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[];

View File

@@ -500,6 +500,38 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsString()
@MaxLength(200)
search?: string;
@ApiPropertyOptional({
type: [String],
description:
'Only these of my projects (ids). Repeat or separate with commas',
})
@IsOptional()
@Transform(toList)
@IsUUID('all', { each: true })
project?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@Transform(toList)
@IsEnum(IssuePriority, { each: true })
priority?: IssuePriority[];
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
bugsOnly?: boolean;
@ApiPropertyOptional({ description: 'Only open tickets past their due date' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
overdue?: boolean;
}
export class UpcomingReleasesQueryDto {
@@ -898,6 +930,69 @@ export class SavedFilterCriteriaDto {
search?: string | null;
}
export class MyViewCriteriaDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Only these of my projects (empty: all)',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsUUID('all', { each: true })
projectIds?: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() assignedToMe?: boolean;
@ApiPropertyOptional({ description: 'Only the tickets I follow' })
@IsOptional()
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
@IsOptional()
@IsEnum(StatusCategory, { each: true })
categories?: StatusCategory[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
@IsOptional()
@IsBoolean()
bugsOnly?: boolean;
@ApiPropertyOptional({ description: 'Only open tickets past due' })
@IsOptional()
@IsBoolean()
overdue?: boolean;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}
export class SaveMyViewDto {
@ApiProperty({
example: 'Bugs críticos de todos mis proyectos',
maxLength: 60,
})
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@ApiProperty({ type: MyViewCriteriaDto })
@ValidateNested()
@Type(() => MyViewCriteriaDto)
criteria!: MyViewCriteriaDto;
}
export class SaveFilterDto {
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
@IsString()

View File

@@ -526,6 +526,37 @@ export class SavedFilterDto {
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class MyViewCriteriaResponseDto {
@ApiPropertyOptional({ type: [String], format: 'uuid' })
projectIds?: string[];
@ApiPropertyOptional() assignedToMe?: boolean;
@ApiPropertyOptional() watching?: boolean;
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
categories?: StatusCategory[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
priorities?: IssuePriority[];
@ApiPropertyOptional() bugsOnly?: boolean;
@ApiPropertyOptional() overdue?: boolean;
@ApiPropertyOptional() search?: string;
}
export class MyViewDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'Bugs críticos de todos mis proyectos' })
name!: string;
@ApiProperty({ type: MyViewCriteriaResponseDto })
criteria!: MyViewCriteriaResponseDto;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class SavedFilterIdDto {
@ApiProperty({ format: 'uuid' }) id!: string;
}

View File

@@ -132,6 +132,10 @@ export class IssuesController {
statuses: query.status,
categories: query.category,
search: query.search,
projectIds: query.project,
priorities: query.priority,
bugsOnly: query.bugsOnly,
overdue: query.overdue,
limit: query.limit,
offset: query.offset,
});

View File

@@ -26,8 +26,12 @@ import {
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterDto } from './dto/requests.dto';
import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto';
import { SaveFilterDto, SaveMyViewDto } from './dto/requests.dto';
import {
MyViewDto,
SavedFilterDto,
SavedFilterIdDto,
} from './dto/responses.dto';
import { Refs } from './refs';
/** Saved views of a project's board: mine, and the ones shared with it. */
@@ -93,4 +97,39 @@ export class SavedFiltersController {
) {
await this.filters.delete(me.id, id);
}
@Get('me/views')
@ApiOperation({
summary: 'My saved views of my work, across my projects (by name)',
})
@ApiOkResponse({ type: [MyViewDto] })
@ApiReadErrors()
myViews(@CurrentUser() me: User) {
return this.filters.myViews(me.id);
}
@Post('me/views')
@ApiOperation({
summary: 'Save a view of my work under a name',
description:
'Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).',
})
@ApiCreatedResponse({ type: SavedFilterIdDto })
@ApiCommandErrors()
saveMyView(@CurrentUser() me: User, @Body() body: SaveMyViewDto) {
return this.filters.saveMyView(me.id, body);
}
@Delete('me/views/:id')
@HttpCode(204)
@ApiParam({ name: 'id', format: 'uuid' })
@ApiOperation({ summary: 'Delete one of my views' })
@ApiNoContentResponse()
@ApiCommandErrors()
async deleteMyView(
@CurrentUser() me: User,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.filters.deleteMyView(me.id, id);
}
}