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>
321 lines
8.3 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|