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

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

View File

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

View File

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

View File

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