import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { CrossProjectIssueFilter, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, FindProjectsRepository, FindReleasesRepository, FindTimelineRepository, IssueFilter, IssueSort, Page, } from '@task-stream/api/tracker/application'; import { ActivityReadModel, CommentReadModel, EpicReadModel, CountBy, defaultProjectSettings, IssueReadModel, IssueTimelineEntry, ProjectReadModel, ProjectRole, ProjectStatsReadModel, ReleaseProgressReadModel, SprintProgressReadModel, ReleaseReadModel, ReleaseStatus, StatusCategory, UserRefReadModel, } from '@task-stream/api/tracker/domain'; import { FilterQuery, Model } from 'mongoose'; import { ActivityDoc, CommentDoc, IssueDoc, ProjectDoc, ReadModels, ReleaseDoc, TimelineDoc, fromDoc, issueFromDoc, } from './read-model.schemas'; /* Query-side adapters (MongoDB). */ /** Comments projected before there were replies have neither field. */ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({ ...fromDoc(doc), replyTo: doc.replyTo ?? null, deleted: doc.deleted ?? false, reactions: doc.reactions ?? [], }); const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; function toProject(doc: ProjectDoc): ProjectReadModel { const { memberIds: _ignored, ...rest } = doc; // Projects projected before settings (or visibility) existed use the defaults. return { ...fromDoc(rest), isPublic: rest.isPublic ?? false, acceptsProposals: rest.acceptsProposals ?? false, settings: rest.settings ?? defaultProjectSettings(), }; } @Injectable() export class MongoFindProjectsRepository extends FindProjectsRepository { constructor( @InjectModel(ReadModels.Project) private readonly projects: Model, ) { super(); } async findById(id: string): Promise { const doc = await this.projects.findOne({ id: id }).lean(); return doc ? toProject(doc) : null; } async findByKey(key: string): Promise { const doc = await this.projects.findOne({ key }).lean(); return doc ? toProject(doc) : null; } async findByMember(userId: string): Promise { const docs = await this.projects .find({ memberIds: userId }) .sort({ name: 1 }) .lean(); return docs.map(toProject); } async findPublic(): Promise { const docs = await this.projects .find({ isPublic: true }) .sort({ name: 1 }) .lean(); return docs.map(toProject); } async findRole( projectId: string, userId: string, ): Promise { const doc = await this.projects .findOne({ id: projectId, memberIds: userId }, { members: 1 }) .lean>(); return doc?.members.find((m) => m.user.id === userId)?.role ?? null; } } @Injectable() export class MongoFindIssuesRepository extends FindIssuesRepository { constructor( @InjectModel(ReadModels.Issue) private readonly issues: Model, ) { super(); } async findById(id: string): Promise { const doc = await this.issues.findOne({ id: id }).lean(); return doc ? issueFromDoc(doc) : null; } async findByKey(key: string): Promise { const doc = await this.issues.findOne({ key }).lean(); return doc ? issueFromDoc(doc) : null; } async findByIds(ids: string[]): Promise { if (ids.length === 0) return []; const docs = await this.issues .find({ id: { $in: ids } }) .lean(); return docs.map(issueFromDoc); } async search(filter: IssueFilter): Promise> { const query = toMongoFilter(filter); const [docs, total] = await Promise.all([ this.issues .find(query) .sort( filter.sort === IssueSort.RANK ? { rank: 1, number: 1 } : { updatedAt: -1 }, ) .skip(filter.offset) .limit(filter.limit) .lean(), this.issues.countDocuments(query), ]); return { items: docs.map(issueFromDoc), total }; } async searchAcross( filter: CrossProjectIssueFilter, ): Promise> { if (filter.projectIds.length === 0) return { items: [], total: 0 }; const query: Record = { projectId: { $in: filter.projectIds }, trashedAt: null, }; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.categories?.length) query['statusCategory'] = { $in: filter.categories }; if (filter.search?.trim()) { const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i'); query['$or'] = [ { key: pattern }, { title: pattern }, { description: pattern }, ]; } const [docs, total] = await Promise.all([ this.issues .find(query) .sort({ updatedAt: -1 }) .skip(filter.offset) .limit(filter.limit) .lean(), this.issues.countDocuments(query), ]); return { items: docs.map(issueFromDoc), total }; } async stats(projectId: string): Promise { const countBy = (field: string) => [ { $group: { _id: `$${field}`, count: { $sum: 1 } } }, { $sort: { count: -1 as const } }, ]; const [facets] = await this.issues.aggregate<{ total: { count: number }[]; open: { count: number }[]; byStatus: { _id: string; count: number }[]; byType: { _id: string; count: number }[]; byPriority: { _id: string; count: number }[]; byArea: { _id: string | null; count: number }[]; byAssignee: { _id: UserRefReadModel | null; count: number }[]; }>([ { $match: { projectId, trashedAt: null } }, { $facet: { total: [{ $count: 'count' }], open: [ { $match: { statusCategory: { $nin: FINISHED } } }, { $count: 'count' }, ], byStatus: countBy('status'), byType: countBy('type'), byPriority: countBy('priority'), byArea: countBy('area'), byAssignee: [ { $match: { statusCategory: { $nin: FINISHED } } }, ...countBy('assignee'), ], }, }, ]); const keyed = (rows: { _id: unknown; count: number }[]): CountBy[] => rows.map((r) => ({ key: (r._id ?? 'NONE') as T, count: r.count })); return { total: facets.total[0]?.count ?? 0, open: facets.open[0]?.count ?? 0, byStatus: keyed(facets.byStatus), byType: keyed(facets.byType), byPriority: keyed(facets.byPriority), byArea: keyed(facets.byArea), byAssignee: facets.byAssignee.map((r) => ({ user: r._id?.id ? r._id : null, count: r.count, })), }; } async findOpenAssignedTo(userId: string): Promise { const docs = await this.issues .find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED }, trashedAt: null, }) .lean(); return docs.map(issueFromDoc); } async findDueBy(day: string): Promise { const docs = await this.issues .find({ dueDate: { $ne: null, $lte: `${day}\uffff` }, trashedAt: null, statusCategory: { $nin: FINISHED }, $or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }], }) .limit(5000) .lean(); return docs.map(issueFromDoc); } async findByProject(projectId: string): Promise { const docs = await this.issues .find({ projectId, trashedAt: null }) .lean(); return docs.map(issueFromDoc); } async findTrashed(projectId: string): Promise { const docs = await this.issues .find({ projectId, trashedAt: { $ne: null } }) .sort({ trashedAt: -1 }) .limit(500) .lean(); return docs.map(issueFromDoc); } async findTrashedBefore(before: string): Promise { const docs = await this.issues .find({ trashedAt: { $ne: null, $lt: before } }) .limit(1000) .lean(); return docs.map(issueFromDoc); } async epics(projectId: string): Promise { const finished = { $in: ['$statusCategory', FINISHED] }; const points = { $ifNull: ['$storyPoints', 0] }; const rows = await this.issues.aggregate<{ _id: string; total: number; finished: number; storyPoints: number; finishedStoryPoints: number; }>([ { $match: { projectId, 'parent.id': { $type: 'string' }, trashedAt: null, }, }, { $group: { _id: '$parent.id', total: { $sum: 1 }, finished: { $sum: { $cond: [finished, 1, 0] } }, storyPoints: { $sum: points }, finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } }, }, }, ]); if (rows.length === 0) return []; const progress = new Map(rows.map((row) => [row._id, row])); // Only the top of each tree: a ticket with subtasks under an epic is not one. const docs = await this.issues .find({ id: { $in: [...progress.keys()] }, 'parent.id': null, trashedAt: null, }) .sort({ number: 1 }) .lean(); return docs.map((doc) => { const { _id: _ignored, ...row } = progress.get(doc.id) ?? { _id: doc.id, total: 0, finished: 0, storyPoints: 0, finishedStoryPoints: 0, }; return { issue: issueFromDoc(doc), progress: { ...row, percent: row.total === 0 ? 0 : Math.round((row.finished / row.total) * 100), }, }; }); } async sprintProgress(sprintId: string): Promise { const finished = { $in: ['$statusCategory', FINISHED] }; const points = { $ifNull: ['$storyPoints', 0] }; const [row] = await this.issues.aggregate<{ total: number; finished: number; storyPoints: number; finishedStoryPoints: number; }>([ { $match: { 'sprint.id': sprintId, trashedAt: null } }, { $group: { _id: null, total: { $sum: 1 }, finished: { $sum: { $cond: [finished, 1, 0] } }, storyPoints: { $sum: points }, finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } }, }, }, ]); const total = row?.total ?? 0; return { total, finished: row?.finished ?? 0, percent: total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100), storyPoints: row?.storyPoints ?? 0, finishedStoryPoints: row?.finishedStoryPoints ?? 0, }; } async releaseProgress(releaseId: string): Promise { const rows = await this.issues.aggregate<{ _id: { status: string; category: StatusCategory }; count: number; }>([ { $match: { 'release.id': releaseId, trashedAt: null } }, { $group: { _id: { status: '$status', category: '$statusCategory' }, count: { $sum: 1 }, }, }, ]); const total = rows.reduce((sum, r) => sum + r.count, 0); const finished = rows .filter((r) => FINISHED.includes(r._id.category)) .reduce((sum, r) => sum + r.count, 0); const byStatus = new Map(); for (const r of rows) byStatus.set(r._id.status, (byStatus.get(r._id.status) ?? 0) + r.count); return { total, finished, percent: total === 0 ? 0 : Math.round((finished / total) * 100), byStatus: [...byStatus].map(([key, count]) => ({ key, count })), }; } } function toMongoFilter(filter: IssueFilter): FilterQuery { // Tickets in the trash are only listed by the trash itself. const query: FilterQuery = { projectId: filter.projectId, trashedAt: null, }; if (filter.statuses?.length) query.status = { $in: filter.statuses }; if (filter.categories?.length) query['statusCategory'] = { $in: filter.categories }; if (filter.types?.length) query['type'] = { $in: filter.types }; if (filter.areas?.length) query['area'] = { $in: filter.areas }; if (filter.priorities?.length) query['priority'] = { $in: filter.priorities }; if (filter.labels?.length) query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) }; if (filter.releaseId) query['release.id'] = filter.releaseId; if (filter.parentId) query['parent.id'] = filter.parentId; if (filter.dueFrom || filter.dueTo) query['dueDate'] = { $ne: null, ...(filter.dueFrom ? { $gte: filter.dueFrom } : {}), // Full ISO dates on the last day still count. ...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}), }; if (filter.noSprint) query['sprint'] = null; else if (filter.sprintId) query['sprint.id'] = filter.sprintId; if (filter.unassigned) query['assignee'] = null; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.watcherId) query['watchers.id'] = filter.watcherId; if (filter.search?.trim()) { const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i'); query.$or = [ { title: pattern }, { key: pattern }, { description: pattern }, ]; } return query; } @Injectable() export class MongoFindCommentsRepository extends FindCommentsRepository { constructor( @InjectModel(ReadModels.Comment) private readonly comments: Model, ) { super(); } async findById(id: string): Promise { const doc = await this.comments.findOne({ id: id }).lean(); return doc ? commentFromDoc(doc) : null; } async findByIssue( issueId: string, limit: number, offset: number, ): Promise> { const [docs, total] = await Promise.all([ this.comments .find({ issueId }) .sort({ createdAt: 1 }) .skip(offset) .limit(limit) .lean(), this.comments.countDocuments({ issueId }), ]); return { items: docs.map(commentFromDoc), total }; } countByIssue(issueId: string): Promise { return this.comments.countDocuments({ issueId, deleted: { $ne: true } }); } countReplies(commentId: string): Promise { return this.comments.countDocuments({ replyTo: commentId }); } } @Injectable() export class MongoFindReleasesRepository extends FindReleasesRepository { constructor( @InjectModel(ReadModels.Release) private readonly releases: Model, ) { super(); } async findById(id: string): Promise { const doc = await this.releases.findOne({ id: id }).lean(); return doc ? fromDoc(doc) : null; } async findByProject(projectId: string): Promise { const docs = await this.releases .find({ projectId }) .sort({ createdAt: -1 }) .lean(); return docs.map(fromDoc); } async findUpcoming( projectIds: string[], limit: number, ): Promise { if (projectIds.length === 0) return []; const docs = await this.releases .find({ projectId: { $in: projectIds }, status: ReleaseStatus.PLANNED }) .lean(); // Ascending targetDate puts nulls first in Mongo: sort here instead. return docs .map(fromDoc) .sort( (a, b) => (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') || a.name.localeCompare(b.name), ) .slice(0, limit); } } @Injectable() export class MongoFindActivityRepository extends FindActivityRepository { constructor( @InjectModel(ReadModels.Activity) private readonly activity: Model, ) { super(); } async find(params: { projectId: string; issueId?: string; limit: number; before?: string; }): Promise { const query: FilterQuery = { projectId: params.projectId }; if (params.issueId) query.issueId = params.issueId; if (params.before) query.occurredAt = { $lt: params.before }; const docs = await this.activity .find(query) .sort({ occurredAt: -1 }) .limit(params.limit) .lean(); return docs.map(fromDoc); } } @Injectable() export class MongoFindTimelineRepository extends FindTimelineRepository { constructor( @InjectModel(ReadModels.Timeline) private readonly timeline: Model, ) { super(); } async findByProject(projectId: string): Promise { const docs = await this.timeline .find({ projectId }) .sort({ at: 1 }) .lean(); return docs.map(fromDoc); } }