feat: saved board views and richer board filters

The board now filters by text, type, priority and unassigned tickets as
well. Any combination can be saved as a named view, private or shared with
the project, and opened again with one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:14:38 +02:00
parent 0606f53026
commit 298383f19f
46 changed files with 2922 additions and 26 deletions

View File

@@ -9,4 +9,6 @@ export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/saved-filters/saved-filters';
export * from './lib/tracker-application.module';

View File

@@ -20,3 +20,4 @@ export * from './tracker-streams.repository';
export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';
export * from './saved-filters.repository';

View File

@@ -0,0 +1,14 @@
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
/** Saved board views: per person (or shared with a project), no history. */
export abstract class SavedFiltersRepository {
/** Whoever's own views of the project, then the shared ones, by name. */
abstract findVisible(
projectId: string,
userId: string,
): Promise<SavedFilterReadModel[]>;
abstract findById(id: string): Promise<SavedFilterReadModel | null>;
abstract countOwned(projectId: string, userId: string): Promise<number>;
abstract insert(filter: SavedFilterReadModel): Promise<void>;
abstract delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
SavedFilterCriteriaReadModel,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import {
DeleteSavedFilterCommand,
GetSavedFiltersQuery,
SaveFilterCommand,
} from './saved-filters/saved-filters';
export interface SaveFilterInput {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteriaReadModel;
}
/** Saved board views: mine and the ones shared with the project. */
@Injectable()
export class SavedFiltersService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
list(viewerId: string, projectId: string): Promise<SavedFilterReadModel[]> {
return this.queryBus.execute(new GetSavedFiltersQuery(viewerId, projectId));
}
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
return this.commandBus.execute(
new SaveFilterCommand(
actorId,
input.projectId,
input.name,
input.shared,
input.criteria,
),
);
}
delete(actorId: string, filterId: string): Promise<void> {
return this.commandBus.execute(
new DeleteSavedFilterCommand(actorId, filterId),
);
}
}

View File

@@ -0,0 +1,145 @@
import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
ForbiddenError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectRole,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadAccess } from '../queries/read-access';
import {
DeleteSavedFilterCommand,
GetSavedFiltersQuery,
MAX_SAVED_FILTERS,
SAVED_FILTERS,
SaveFilterCommand,
normalizeCriteria,
} from './saved-filters';
class InMemorySavedFilters extends SavedFiltersRepository {
readonly rows = new Map<string, SavedFilterReadModel>();
async findVisible(projectId: string, userId: string) {
return [...this.rows.values()].filter(
(f) => f.projectId === projectId && (f.owner.id === userId || f.shared),
);
}
async findById(id: string) {
return this.rows.get(id) ?? null;
}
async countOwned(projectId: string, userId: string) {
return [...this.rows.values()].filter(
(f) => f.projectId === projectId && f.owner.id === userId,
).length;
}
async insert(filter: SavedFilterReadModel) {
this.rows.set(filter.id, filter);
}
async delete(id: string) {
this.rows.delete(id);
}
}
const ROLES: Record<string, ProjectRole> = {
owner: ProjectRole.OWNER,
dev: ProjectRole.MEMBER,
qa: ProjectRole.MEMBER,
pm: ProjectRole.VIEWER,
};
async function setup() {
const rows = new InMemorySavedFilters();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...SAVED_FILTERS,
ReadAccess,
{ provide: SavedFiltersRepository, useValue: rows },
{
provide: UserDirectory,
useValue: new FakeUserDirectory().add('owner').add('dev').add('qa'),
},
{
provide: FindProjectsRepository,
useValue: {
findRole: async (_projectId: string, userId: string) =>
ROLES[userId] ?? null,
findById: async () => ({ isPublic: true }),
},
},
],
}).compile();
await moduleRef.init();
const commands = moduleRef.get(CommandBus);
const queries = moduleRef.get(QueryBus);
const save = (actor: string, name: string, shared = false) =>
commands.execute(
new SaveFilterCommand(actor, 'p1', name, shared, {
priorities: [IssuePriority.HIGH, IssuePriority.HIGH],
labels: [' UI ', 'ui'],
search: ' ',
}),
);
const list = (viewer: string) =>
queries.execute(new GetSavedFiltersQuery(viewer, 'p1'));
const remove = (actor: string, id: string) =>
commands.execute(new DeleteSavedFilterCommand(actor, id));
return { rows, save, list, remove };
}
describe('saved board views', () => {
it('keeps private views private and shares the shared ones', async () => {
const { save, list } = await setup();
await save('dev', 'Mis bugs');
await save('qa', 'Para QA', true);
expect((await list('dev')).map((f) => f.name).sort()).toEqual([
'Mis bugs',
'Para QA',
]);
expect((await list('qa')).map((f) => f.name)).toEqual(['Para QA']);
const [mine] = await list('dev');
expect(mine.criteria).toEqual({
priorities: [IssuePriority.HIGH],
labels: ['ui'],
});
});
it('lets viewers keep views only for themselves, and outsiders none', async () => {
const { save } = await setup();
await expect(save('pm', 'Mía')).resolves.toHaveProperty('id');
await expect(save('pm', 'De todos', true)).rejects.toThrow(ForbiddenError);
await expect(save('stranger', 'Nope')).rejects.toThrow(ForbiddenError);
});
it('deletes: the author always, an admin a shared one, nobody else', async () => {
const { save, remove, rows } = await setup();
const { id: priv } = await save('dev', 'Privada');
const { id: shared } = await save('dev', 'Compartida', true);
await expect(remove('qa', priv)).rejects.toThrow(NotFoundError);
await expect(remove('qa', shared)).rejects.toThrow(ForbiddenError);
await remove('owner', shared);
await remove('dev', priv);
expect(rows.rows.size).toBe(0);
});
it('caps how many views a person keeps per project', async () => {
const { save } = await setup();
for (let i = 0; i < MAX_SAVED_FILTERS; i++) await save('dev', `#${i}`);
await expect(save('dev', 'Una más')).rejects.toThrow(BusinessRuleError);
});
it('drops empty criteria', () => {
expect(
normalizeCriteria({ types: [], assigneeId: 'u', unassigned: true }),
).toEqual({ unassigned: true });
});
});

View File

@@ -0,0 +1,187 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
ProjectRole,
SavedFilterCriteriaReadModel,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadAccess } from '../queries/read-access';
/*
* Saved board views. Preferences, like notification settings: they concern
* whoever saved them (or the project, when shared) and need no history.
*/
/** A person keeps at most this many views per project. */
export const MAX_SAVED_FILTERS = 50;
const EDITORS = [ProjectRole.OWNER, ProjectRole.ADMIN, ProjectRole.MEMBER];
const MANAGERS = [ProjectRole.OWNER, ProjectRole.ADMIN];
export class SaveFilterCommand extends Command<{ id: string }> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly name: string,
/** Visible to the whole project, or just to whoever saved it. */
public readonly shared: boolean,
public readonly criteria: SavedFilterCriteriaReadModel,
) {
super();
}
}
export class DeleteSavedFilterCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly filterId: string,
) {
super();
}
}
export class GetSavedFiltersQuery extends Query<SavedFilterReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
/** Drops empty criteria, trims text and deduplicates lists. */
export function normalizeCriteria(
criteria: SavedFilterCriteriaReadModel,
): SavedFilterCriteriaReadModel {
const list = <T extends string>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
const out: SavedFilterCriteriaReadModel = {
types: list(criteria.types),
areas: list(criteria.areas),
priorities: list(criteria.priorities),
labels: list(
criteria.labels?.map((l) => l.trim().toLowerCase()).filter(Boolean),
),
assigneeId: criteria.unassigned
? undefined
: criteria.assigneeId || undefined,
unassigned: criteria.unassigned || undefined,
releaseId: criteria.releaseId || undefined,
search: criteria.search?.trim() || undefined,
};
return Object.fromEntries(
Object.entries(out).filter(([, value]) => value !== undefined),
) as SavedFilterCriteriaReadModel;
}
@CommandHandler(SaveFilterCommand)
export class SaveFilterCommandHandler
implements ICommandHandler<SaveFilterCommand>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
private readonly users: UserDirectory,
) {}
async execute(command: SaveFilterCommand): Promise<{ id: string }> {
const role = await this.access.assertCanView(
command.projectId,
command.actorId,
);
if (!role)
throw new ForbiddenError('Only project members can save board views');
if (command.shared && !EDITORS.includes(role))
throw new ForbiddenError('Viewers can only save views for themselves');
const name = command.name.trim();
assertThat(
name.length > 0 && name.length <= 60,
'A view needs a name (up to 60 characters)',
);
const owned = await this.filters.countOwned(
command.projectId,
command.actorId,
);
if (owned >= MAX_SAVED_FILTERS)
throw new BusinessRuleError(
`You already keep ${MAX_SAVED_FILTERS} views in this project; delete one first`,
);
const users = await this.users.refs([command.actorId]);
const id = randomUUID();
await this.filters.insert({
id,
projectId: command.projectId,
owner: users.of(command.actorId),
name,
shared: command.shared,
criteria: normalizeCriteria(command.criteria),
createdAt: new Date().toISOString(),
});
return { id };
}
}
@CommandHandler(DeleteSavedFilterCommand)
export class DeleteSavedFilterCommandHandler
implements ICommandHandler<DeleteSavedFilterCommand>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
) {}
async execute(command: DeleteSavedFilterCommand): Promise<void> {
const filter = await this.filters.findById(command.filterId);
const mine = filter?.owner.id === command.actorId;
// Someone else's private view: as if it did not exist.
if (!filter || (!mine && !filter.shared))
throw new NotFoundError('SavedFilter', command.filterId);
if (!mine) {
const role = await this.access.assertCanView(
filter.projectId,
command.actorId,
);
if (!role || !MANAGERS.includes(role))
throw new ForbiddenError(
'Only whoever saved a shared view, or a project admin, can delete it',
);
}
await this.filters.delete(filter.id);
}
}
@QueryHandler(GetSavedFiltersQuery)
export class GetSavedFiltersQueryHandler
implements IQueryHandler<GetSavedFiltersQuery, SavedFilterReadModel[]>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetSavedFiltersQuery): Promise<SavedFilterReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.filters.findVisible(query.projectId, query.viewerId);
}
}
export const SAVED_FILTERS = [
SaveFilterCommandHandler,
DeleteSavedFilterCommandHandler,
GetSavedFiltersQueryHandler,
];

View File

@@ -29,6 +29,9 @@ import {
UpdateNotificationSettingsCommandHandler,
} from './notifications/notifications.commands';
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
Notifier,
@@ -47,6 +50,7 @@ const SERVICES = [
ChangesService,
WorkflowsService,
NotificationsService,
SavedFiltersService,
];
/**
@@ -68,6 +72,7 @@ const SERVICES = [
...EVENT_HANDLERS,
...SAGAS,
...NOTIFICATIONS,
...SAVED_FILTERS,
],
exports: SERVICES,
})