Files
task-stream/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts
Josep Piguillem 298383f19f 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>
2026-10-08 14:14:38 +02:00

321 lines
8.3 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
UpsertMaterializedActivityRepository,
UpsertMaterializedCommentRepository,
UpsertMaterializedIssueRepository,
UpsertMaterializedProjectRepository,
UpsertMaterializedReleaseRepository,
UpsertMaterializedTimelineRepository,
} from '@task-stream/api/tracker/application';
import {
ActivityReadModel,
AttachmentReadModel,
ChecklistItemReadModel,
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
IssueTimelineEntry,
ProjectReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
ActivityDoc,
CommentDoc,
IssueDoc,
ProjectDoc,
ReadModels,
ReleaseDoc,
SavedFilterDoc,
TimelineDoc,
} from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
/* Materialized views written by the event handlers (MongoDB). */
@Injectable()
export class MongoUpsertMaterializedProjectRepository extends UpsertMaterializedProjectRepository {
constructor(
@InjectModel(ReadModels.Project)
private readonly projects: Model<ProjectDoc>,
@InjectModel(ReadModels.Activity)
private readonly activity: Model<ActivityDoc>,
@InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>,
@InjectModel(ReadModels.SavedFilter)
private readonly savedFilters: Model<SavedFilterDoc>,
) {
super();
}
async remove(projectId: string) {
await Promise.all([
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 }),
]);
}
async upsert({
id,
version,
...fields
}: Pick<ProjectReadModel, 'id' | 'version'> & Partial<ProjectReadModel>) {
const memberIds = fields.members
? { memberIds: fields.members.map((m) => m.user.id) }
: {};
await upsertIfNewer(this.projects, id, version, {
...fields,
...memberIds,
});
}
}
@Injectable()
export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIssueRepository {
constructor(
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
@InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>,
) {
super();
}
async remove(issueId: string) {
await Promise.all([
this.issues.deleteOne({ _id: issueId }),
this.issues.updateMany(
{
$or: [
{ 'inwardLinks.issueId': issueId },
{ 'outwardLinks.issueId': issueId },
],
},
{ $pull: { inwardLinks: { issueId }, outwardLinks: { issueId } } },
),
this.timeline.deleteMany({ issueId }),
]);
}
async upsert({
id,
version,
...fields
}: Pick<IssueReadModel, 'id' | 'version'> &
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
>
>) {
await upsertIfNewer(this.issues, id, version, fields, {
outwardLinks: [],
inwardLinks: [],
attachments: [],
watchers: [],
commentCount: 0,
});
}
async addWatcher(issueId: string, user: UserRefReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'watchers.id': { $ne: user.id } },
{ $push: { watchers: user } },
);
}
async removeWatcher(issueId: string, userId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { watchers: { id: userId } } },
);
}
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': { $ne: item.id } },
{ $push: { checklist: item } },
);
}
async updateChecklistItem(
issueId: string,
itemId: string,
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
) {
const set = Object.fromEntries(
Object.entries(changes).map(([field, value]) => [
`checklist.$.${field}`,
value,
]),
);
if (Object.keys(set).length === 0) return;
await this.issues.updateOne(
{ _id: issueId, 'checklist.id': itemId },
{ $set: set },
);
}
async removeChecklistItem(issueId: string, itemId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { checklist: { id: itemId } } },
);
}
async renameParent(parentId: string, title: string) {
await this.issues.updateMany(
{ 'parent.id': parentId },
{ $set: { 'parent.title': title } },
);
}
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
{ $push: { attachments: attachment } },
);
}
async removeAttachment(issueId: string, attachmentId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { attachments: { id: attachmentId } } },
);
}
async addLink(issueId: string, link: IssueLinkReadModel) {
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne(
{
_id: issueId,
[field]: {
$not: { $elemMatch: { type: link.type, issueId: link.issueId } },
},
},
{ $push: { [field]: link } },
);
}
async removeLink(
issueId: string,
link: Omit<IssueLinkReadModel, 'issueKey'>,
) {
const field = link.direction === 'OUTWARD' ? 'outwardLinks' : 'inwardLinks';
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { [field]: { type: link.type, issueId: link.issueId } } },
);
}
async setCommentCount(issueId: string, commentCount: number) {
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } });
}
async recategorize(
projectId: string,
categories: Record<string, StatusCategory>,
) {
const ops = Object.entries(categories).map(([status, category]) => ({
updateMany: {
filter: { projectId, status, statusCategory: { $ne: category } },
update: { $set: { statusCategory: category } },
},
}));
if (ops.length) await this.issues.bulkWrite(ops);
}
async renameRelease(releaseId: string, name: string) {
await this.issues.updateMany(
{ 'release.id': releaseId },
{ $set: { 'release.name': name } },
);
}
}
@Injectable()
export class MongoUpsertMaterializedCommentRepository extends UpsertMaterializedCommentRepository {
constructor(
@InjectModel(ReadModels.Comment)
private readonly comments: Model<CommentDoc>,
) {
super();
}
async upsert({
id,
version,
...fields
}: Pick<CommentReadModel, 'id' | 'version'> & Partial<CommentReadModel>) {
await upsertIfNewer(this.comments, id, version, fields);
}
async remove(id: string) {
await this.comments.deleteOne({ _id: id });
}
}
@Injectable()
export class MongoUpsertMaterializedReleaseRepository extends UpsertMaterializedReleaseRepository {
constructor(
@InjectModel(ReadModels.Release)
private readonly releases: Model<ReleaseDoc>,
) {
super();
}
async upsert({
id,
version,
...fields
}: Pick<ReleaseReadModel, 'id' | 'version'> & Partial<ReleaseReadModel>) {
await upsertIfNewer(this.releases, id, version, fields);
}
async remove(releaseId: string) {
await this.releases.deleteOne({ _id: releaseId });
}
}
@Injectable()
export class MongoUpsertMaterializedActivityRepository extends UpsertMaterializedActivityRepository {
constructor(
@InjectModel(ReadModels.Activity)
private readonly activity: Model<ActivityDoc>,
) {
super();
}
async insert({ id, ...fields }: ActivityReadModel): Promise<boolean> {
const result = await this.activity.updateOne(
{ _id: id },
{ $setOnInsert: fields },
{ upsert: true },
);
return result.upsertedCount > 0;
}
}
@Injectable()
export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterializedTimelineRepository {
constructor(
@InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>,
) {
super();
}
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
await this.timeline.updateOne(
{ _id: id },
{ $setOnInsert: fields },
{ upsert: true },
);
}
}