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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './activity.read-model';
|
||||
export * from './project-stats.read-model';
|
||||
export * from './analytics.read-model';
|
||||
export * from './notification.read-model';
|
||||
export * from './saved-filter.read-model';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
/** What a saved view of the board narrows the tickets to. Empty: everything. */
|
||||
export class SavedFilterCriteriaReadModel {
|
||||
/** Ticket type ids of the project's settings. */
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string | null;
|
||||
unassigned?: boolean;
|
||||
releaseId?: string | null;
|
||||
labels?: string[];
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named set of board filters. Private to whoever saved it, or shared with
|
||||
* the whole project. Preferences, not history: kept outside the event store.
|
||||
*/
|
||||
export class SavedFilterReadModel {
|
||||
id!: string;
|
||||
projectId!: string;
|
||||
owner!: UserRefReadModel;
|
||||
name!: string;
|
||||
shared!: boolean;
|
||||
criteria!: SavedFilterCriteriaReadModel;
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SavedFilterReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
@@ -26,6 +27,7 @@ export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
email: boolean;
|
||||
@@ -40,6 +42,7 @@ export const ReadModels = {
|
||||
Timeline: 'IssueTimelineReadModel',
|
||||
Notification: 'NotificationReadModel',
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -160,6 +163,20 @@ export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
||||
{ ...loose, collection: 'notification_settings' },
|
||||
);
|
||||
|
||||
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: String,
|
||||
owner: userRef,
|
||||
name: String,
|
||||
shared: Boolean,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'saved_filters' },
|
||||
);
|
||||
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
@@ -169,6 +186,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
];
|
||||
|
||||
export function fromDoc<T extends { _id: string }>(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { SavedFiltersRepository } from '@task-stream/api/tracker/application';
|
||||
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels, SavedFilterDoc, fromDoc } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoSavedFiltersRepository extends SavedFiltersRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.SavedFilter)
|
||||
private readonly filters: Model<SavedFilterDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findVisible(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<SavedFilterReadModel[]> {
|
||||
const docs = await this.filters
|
||||
.find({ projectId, $or: [{ 'owner.id': userId }, { shared: true }] })
|
||||
.sort({ shared: 1, name: 1 })
|
||||
.lean<SavedFilterDoc[]>();
|
||||
return docs.map((doc) => toFilter(doc));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SavedFilterReadModel | null> {
|
||||
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
|
||||
return doc ? toFilter(doc) : null;
|
||||
}
|
||||
|
||||
countOwned(projectId: string, userId: string): Promise<number> {
|
||||
return this.filters.countDocuments({ projectId, 'owner.id': userId });
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
|
||||
await this.filters.create({ _id: id, ...rest });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.filters.deleteOne({ _id: id });
|
||||
}
|
||||
}
|
||||
|
||||
function toFilter(doc: SavedFilterDoc): SavedFilterReadModel {
|
||||
const filter = fromDoc(doc) as SavedFilterReadModel;
|
||||
return { ...filter, criteria: filter.criteria ?? {} };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ProjectDoc,
|
||||
ReadModels,
|
||||
ReleaseDoc,
|
||||
SavedFilterDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
@@ -44,6 +45,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
private readonly activity: Model<ActivityDoc>,
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
@InjectModel(ReadModels.SavedFilter)
|
||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -53,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
this.projects.deleteOne({ _id: projectId }),
|
||||
this.activity.deleteMany({ projectId }),
|
||||
this.timeline.deleteMany({ projectId }),
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChangeFeed,
|
||||
NotificationMailer,
|
||||
NotificationsRepository,
|
||||
SavedFiltersRepository,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
@@ -56,6 +57,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||
|
||||
@@ -107,6 +109,7 @@ const PORTS = [
|
||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
|
||||
@@ -181,6 +181,57 @@ export class ChangeIssueStatusInput {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SavedFilterCriteriaInput {
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
types?: string[];
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
areas?: string[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
releaseId?: string | null;
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
labels?: string[];
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SaveFilterInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
|
||||
@Field({
|
||||
defaultValue: false,
|
||||
description: 'Visible to the whole project (members, not viewers, share)',
|
||||
})
|
||||
@IsBoolean()
|
||||
shared!: boolean;
|
||||
@Field(() => SavedFilterCriteriaInput)
|
||||
@ValidateNested()
|
||||
@Type(() => SavedFilterCriteriaInput)
|
||||
criteria!: SavedFilterCriteriaInput;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class RankIssueInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
IssuePriority,
|
||||
ProjectReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
|
||||
import {
|
||||
@@ -108,6 +111,20 @@ export class ProjectResolver {
|
||||
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
|
||||
@Args('area', { type: () => String, nullable: true }) area?: string,
|
||||
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
|
||||
@Args('types', { type: () => [String], nullable: true })
|
||||
types?: string[] | null,
|
||||
@Args('priorities', { type: () => [IssuePriority], nullable: true })
|
||||
priorities?: IssuePriority[] | null,
|
||||
@Args('labels', { type: () => [String], nullable: true })
|
||||
labels?: string[] | null,
|
||||
@Args('search', {
|
||||
type: () => String,
|
||||
nullable: true,
|
||||
description: 'Matches key, title or description',
|
||||
})
|
||||
search?: string | null,
|
||||
@Args('unassigned', { type: () => Boolean, nullable: true })
|
||||
unassigned?: boolean | null,
|
||||
) {
|
||||
return this.projects.board(
|
||||
viewerOf(me),
|
||||
@@ -116,6 +133,11 @@ export class ProjectResolver {
|
||||
releaseId,
|
||||
assigneeId,
|
||||
areas: area ? [area] : undefined,
|
||||
types: types ?? undefined,
|
||||
priorities: priorities ?? undefined,
|
||||
labels: labels ?? undefined,
|
||||
search: search?.slice(0, 200) ?? undefined,
|
||||
unassigned: unassigned ?? undefined,
|
||||
},
|
||||
perColumn,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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';
|
||||
|
||||
/** Saved views of a project's board. */
|
||||
@Resolver(() => SavedFilterGql)
|
||||
export class SavedFilterResolver {
|
||||
constructor(private readonly filters: SavedFiltersService) {}
|
||||
|
||||
@Query(() => [SavedFilterGql], {
|
||||
description: "My views of the project's board, then the shared ones",
|
||||
})
|
||||
savedFilters(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.filters.list(me.id, projectId);
|
||||
}
|
||||
|
||||
@Mutation(() => ID, {
|
||||
description:
|
||||
'Save the current board filters under a name (members of the project)',
|
||||
})
|
||||
async saveFilter(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: SaveFilterInput,
|
||||
) {
|
||||
return (await this.filters.save(me.id, input)).id;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a view: whoever saved it, or an admin if it is shared',
|
||||
})
|
||||
async deleteSavedFilter(
|
||||
@CurrentUser() me: User,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
) {
|
||||
await this.filters.delete(me.id, id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,35 @@ export class EpicGql {
|
||||
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
|
||||
}
|
||||
|
||||
@ObjectType('SavedFilterCriteria', {
|
||||
description: 'What a saved view narrows the board to; empty: everything',
|
||||
})
|
||||
export class SavedFilterCriteriaGql {
|
||||
@Field(() => [String], { nullable: true }) types?: string[];
|
||||
@Field(() => [String], { nullable: true }) areas?: string[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
priorities?: IssuePriority[];
|
||||
@Field(() => ID, { nullable: true }) assigneeId?: string | null;
|
||||
@Field({ nullable: true }) unassigned?: boolean;
|
||||
@Field(() => ID, { nullable: true }) releaseId?: string | null;
|
||||
@Field(() => [String], { nullable: true }) labels?: string[];
|
||||
@Field(() => String, { nullable: true }) search?: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('SavedFilter', {
|
||||
description:
|
||||
'A named view of the board: private to whoever saved it, or shared with the project',
|
||||
})
|
||||
export class SavedFilterGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field(() => UserRefGql) owner!: UserRefGql;
|
||||
@Field() name!: string;
|
||||
@Field() shared!: boolean;
|
||||
@Field(() => SavedFilterCriteriaGql) criteria!: SavedFilterCriteriaGql;
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssuePage')
|
||||
export class IssuePageGql {
|
||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||
|
||||
@@ -251,6 +251,40 @@ export class BoardQueryDto {
|
||||
@Matches(SETTING_ID)
|
||||
area?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
type?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priority?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsString({ each: true })
|
||||
label?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets without assignee' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -589,6 +623,76 @@ export class SetIssueReleaseDto {
|
||||
releaseId!: string | null;
|
||||
}
|
||||
|
||||
export class SavedFilterCriteriaDto {
|
||||
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
types?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
areas?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Pireon user id',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
labels?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export class SaveFilterDto {
|
||||
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Visible to the whole project (members, not viewers, share)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
shared = false;
|
||||
|
||||
@ApiProperty({ type: SavedFilterCriteriaDto })
|
||||
@ValidateNested()
|
||||
@Type(() => SavedFilterCriteriaDto)
|
||||
criteria!: SavedFilterCriteriaDto;
|
||||
}
|
||||
|
||||
export class RankIssueDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
|
||||
@@ -191,6 +191,41 @@ export class IssueDto {
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class SavedFilterCriteriaResponseDto {
|
||||
@ApiPropertyOptional({ type: [String] }) types?: string[];
|
||||
@ApiPropertyOptional({ type: [String] }) areas?: string[];
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
priorities?: IssuePriority[];
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
assigneeId?: string | null;
|
||||
@ApiPropertyOptional() unassigned?: boolean;
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||
releaseId?: string | null;
|
||||
@ApiPropertyOptional({ type: [String] }) labels?: string[];
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export class SavedFilterDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
|
||||
@ApiProperty({ example: 'Bugs críticos de frontend' }) name!: string;
|
||||
@ApiProperty({ description: 'Visible to the whole project' })
|
||||
shared!: boolean;
|
||||
@ApiProperty({ type: SavedFilterCriteriaResponseDto })
|
||||
criteria!: SavedFilterCriteriaResponseDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
}
|
||||
|
||||
export class SavedFilterIdDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
}
|
||||
|
||||
export class IssuePageDto {
|
||||
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
|
||||
@ApiProperty({ description: 'Total matches (for pagination)' })
|
||||
|
||||
@@ -182,6 +182,11 @@ export class ProjectsController {
|
||||
releaseId: query.releaseId,
|
||||
assigneeId: query.assigneeId,
|
||||
areas: query.area ? [query.area] : undefined,
|
||||
types: query.type,
|
||||
priorities: query.priority,
|
||||
labels: query.label,
|
||||
search: query.search,
|
||||
unassigned: query.unassigned,
|
||||
},
|
||||
query.perColumn,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
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 { Refs } from './refs';
|
||||
|
||||
/** Saved views of a project's board: mine, and the ones shared with it. */
|
||||
@ApiTags('Saved filters')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class SavedFiltersController {
|
||||
constructor(
|
||||
private readonly filters: SavedFiltersService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/filters')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "My views of the project's board, then the shared ones",
|
||||
})
|
||||
@ApiOkResponse({ type: [SavedFilterDto] })
|
||||
@ApiReadErrors()
|
||||
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.filters.list(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Post('projects/:project/filters')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: 'Save board filters under a name',
|
||||
description:
|
||||
'Project members only. `shared` makes it visible to the whole project (not for viewers).',
|
||||
})
|
||||
@ApiCreatedResponse({ type: SavedFilterIdDto })
|
||||
@ApiCommandErrors()
|
||||
async save(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: SaveFilterDto,
|
||||
) {
|
||||
return this.filters.save(me.id, {
|
||||
...body,
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('filters/:id')
|
||||
@HttpCode(204)
|
||||
@ApiParam({ name: 'id', format: 'uuid' })
|
||||
@ApiOperation({
|
||||
summary: 'Delete a saved view (whoever saved it, or an admin if shared)',
|
||||
})
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
await this.filters.delete(me.id, id);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
import { NotificationsController } from './http/notifications.controller';
|
||||
import { SavedFiltersController } from './http/saved-filters.controller';
|
||||
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -45,6 +47,7 @@ import { NotificationsController } from './http/notifications.controller';
|
||||
ReleasesController,
|
||||
ExploreController,
|
||||
NotificationsController,
|
||||
SavedFiltersController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -54,6 +57,7 @@ import { NotificationsController } from './http/notifications.controller';
|
||||
ReleaseResolver,
|
||||
SubscriptionResolver,
|
||||
NotificationResolver,
|
||||
SavedFilterResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
Reference in New Issue
Block a user