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, @InjectModel(ReadModels.Activity) private readonly activity: Model, @InjectModel(ReadModels.Timeline) private readonly timeline: Model, @InjectModel(ReadModels.SavedFilter) private readonly savedFilters: Model, ) { 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 & Partial) { 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, @InjectModel(ReadModels.Timeline) private readonly timeline: Model, ) { 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 & 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>, ) { 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, ) { 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, ) { 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, ) { super(); } async upsert({ id, version, ...fields }: Pick & Partial) { 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, ) { super(); } async upsert({ id, version, ...fields }: Pick & Partial) { 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, ) { super(); } async insert({ id, ...fields }: ActivityReadModel): Promise { 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, ) { super(); } async record({ id, ...fields }: IssueTimelineEntry): Promise { await this.timeline.updateOne( { _id: id }, { $setOnInsert: fields }, { upsert: true }, ); } }