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:
@@ -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)
|
||||
|
||||
@@ -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 ?? {} };
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user