import { Automation, OutgoingWebhook, } from '@task-stream/api/tracker/application'; import { ActivityReadModel, CommentReadModel, NotificationReadModel, IssueLinkReadModel, IssueReadModel, IssueTimelineEntry, ProjectReadModel, ReleaseReadModel, SavedFilterReadModel, MyViewReadModel, PinnedIssueReadModel, SprintReadModel, implicitRank, } from '@task-stream/api/tracker/domain'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Types } from 'mongoose'; /* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */ /** * Each document keeps the domain id (a uuid, the same one the event store * uses) in `id`, unique; `_id` is Mongo's own ObjectId and means nothing * outside the database. */ type Doc = T & { _id?: Types.ObjectId }; export type ProjectDoc = Doc & { memberIds: string[] }; /** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */ export type IssueDoc = Doc> & { outwardLinks: IssueLinkReadModel[]; inwardLinks: IssueLinkReadModel[]; }; export type CommentDoc = Doc; export type ReleaseDoc = Doc; export type SprintDoc = Doc; export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; export type MyViewDoc = Doc; export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId }; export type OutgoingWebhookDoc = Doc; export type AutomationDoc = Doc; export interface AutomationRunDoc { /** `ruleId:eventKey`: a rule runs once per event. */ id: string; at: Date; } export interface GitRepositoryDoc { id: string; name: string; secret: string; createdAt: string; } /** * One per project. `secret` alone is how the single webhook of before was * kept: it reads as a repository named "principal" until the list is saved. */ export interface GitIntegrationDoc { /** The project's id. */ id: string; repositories?: GitRepositoryDoc[]; secret?: string; createdAt?: string; } export interface PushSubscriptionDoc { /** sha256 of the endpoint. */ id: string; userId: string; endpoint: string; keys: { p256dh: string; auth: string }; createdAt: string; } export interface DashboardShareDoc { /** The project's id. */ id: string; token: string; blocks: string[]; createdAt: string; } export interface CalendarFeedDoc { /** The user's id. */ id: string; token: string; createdAt: string; } export interface NotificationSettingsDoc { /** The user's id. */ id: string; email: boolean; } export const ReadModels = { Project: 'ProjectReadModel', Issue: 'IssueReadModel', Comment: 'CommentReadModel', Release: 'ReleaseReadModel', Activity: 'ActivityReadModel', Timeline: 'IssueTimelineReadModel', Notification: 'NotificationReadModel', NotificationSettings: 'NotificationSettings', SavedFilter: 'SavedFilter', MyView: 'MyView', PinnedIssue: 'PinnedIssue', AlertIntegration: 'AlertIntegration', AlertTrace: 'AlertTrace', TreeFollow: 'TreeFollow', IssueVisit: 'IssueVisit', IssueReminder: 'IssueReminder', MyDay: 'MyDay', ReleaseNotesShare: 'ReleaseNotesShare', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', Automation: 'Automation', AutomationRun: 'AutomationRun', CalendarFeed: 'CalendarFeed', DashboardShare: 'DashboardShare', PushSubscription: 'PushSubscription', } as const; /* * Every collection keeps the whole read model (`strict: false`); the props * declared here are the ones with an index or a shape worth stating. `id` is * the domain id (unique, what every query goes by); `_id` is Mongo's own. */ const LOOSE = { strict: false, versionKey: false } as const; @Schema({ _id: false }) class UserRefProp { @Prop({ type: String }) id!: string; @Prop({ type: String }) name!: string; } const UserRefSchema = SchemaFactory.createForClass(UserRefProp); @Schema({ _id: false }) class MemberProp { @Prop({ type: UserRefSchema }) user!: UserRefProp; @Prop({ type: String }) role!: string; } @Schema({ _id: false }) class LinkProp { @Prop({ type: String }) type!: string; @Prop({ type: String }) direction!: string; @Prop({ type: String }) issueId!: string; @Prop({ type: String }) issueKey!: string; } const LinkSchema = SchemaFactory.createForClass(LinkProp); @Schema({ _id: false }) class NamedRefProp { @Prop({ type: String }) id!: string; @Prop({ type: String }) name!: string; } @Schema({ _id: false }) class ParentProp { @Prop({ type: String }) id!: string; @Prop({ type: String }) key!: string; @Prop({ type: String }) title!: string; } @Schema({ _id: false }) class ChecklistItemProp { @Prop({ type: String }) id!: string; @Prop({ type: String }) text!: string; @Prop({ type: Boolean }) done!: boolean; } @Schema({ _id: false }) class AttachmentProp { @Prop({ type: String }) id!: string; @Prop({ type: String }) fileName!: string; @Prop({ type: String }) mimeType!: string; @Prop({ type: Number }) size!: number; @Prop({ type: UserRefSchema }) uploadedBy!: UserRefProp; @Prop({ type: String }) uploadedAt!: string; @Prop({ type: String }) commentId!: string; } @Schema({ ...LOOSE, collection: 'projects_view' }) class ProjectDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, unique: true }) key!: string; @Prop({ type: Boolean, index: true }) isPublic!: boolean; @Prop({ type: [String], index: true }) memberIds!: string[]; @Prop({ type: UserRefSchema }) owner!: UserRefProp; @Prop({ type: [SchemaFactory.createForClass(MemberProp)] }) members!: MemberProp[]; @Prop({ type: Number }) version!: number; } export const ProjectSchema = SchemaFactory.createForClass(ProjectDocument); @Schema({ ...LOOSE, collection: 'issues_view' }) class IssueDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) projectId!: string; @Prop({ type: String, unique: true }) key!: string; @Prop({ type: String }) status!: string; @Prop({ type: String }) statusCategory!: string; @Prop({ type: UserRefSchema }) reporter!: UserRefProp; @Prop({ type: UserRefSchema }) assignee!: UserRefProp | null; @Prop({ type: SchemaFactory.createForClass(NamedRefProp) }) release!: NamedRefProp | null; @Prop({ type: SchemaFactory.createForClass(NamedRefProp) }) sprint!: NamedRefProp | null; @Prop({ type: [LinkSchema] }) outwardLinks!: LinkProp[]; @Prop({ type: [LinkSchema] }) inwardLinks!: LinkProp[]; @Prop({ type: [UserRefSchema] }) watchers!: UserRefProp[]; @Prop({ type: SchemaFactory.createForClass(ParentProp) }) parent!: ParentProp | null; @Prop({ type: [SchemaFactory.createForClass(ChecklistItemProp)] }) checklist!: ChecklistItemProp[]; @Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] }) attachments!: AttachmentProp[]; @Prop({ type: [String] }) labels!: string[]; /** Field id → value: filtered as `customFields.`. */ @Prop({ type: Object }) customFields!: Record; @Prop({ type: Number }) version!: number; } export const IssueSchema = SchemaFactory.createForClass(IssueDocument); IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 }); IssueSchema.index({ projectId: 1, statusCategory: 1 }); IssueSchema.index({ 'assignee.id': 1 }); IssueSchema.index({ 'watchers.id': 1 }); IssueSchema.index({ 'parent.id': 1 }); IssueSchema.index({ projectId: 1, rank: 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ 'sprint.id': 1 }); IssueSchema.index({ trashedAt: 1 }, { sparse: true }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); @Schema({ ...LOOSE, collection: 'comments_view' }) class CommentDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) issueId!: string; @Prop({ type: String, index: true }) replyTo!: string | null; @Prop({ type: UserRefSchema }) author!: UserRefProp; @Prop({ type: Number }) version!: number; } export const CommentSchema = SchemaFactory.createForClass(CommentDocument); CommentSchema.index({ issueId: 1, createdAt: 1 }); CommentSchema.index( { projectId: 1, decidedAt: -1 }, { partialFilterExpression: { decision: true } }, ); @Schema({ ...LOOSE, collection: 'releases_view' }) class ReleaseDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) projectId!: string; @Prop({ type: Number }) version!: number; } export const ReleaseSchema = SchemaFactory.createForClass(ReleaseDocument); @Schema({ ...LOOSE, collection: 'sprints_view' }) class SprintDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) projectId!: string; @Prop({ type: String }) status!: string; @Prop({ type: Number }) version!: number; } export const SprintSchema = SchemaFactory.createForClass(SprintDocument); @Schema({ ...LOOSE, collection: 'activity' }) class ActivityDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String }) projectId!: string; @Prop({ type: String }) issueId!: string | null; @Prop({ type: UserRefSchema }) actor!: UserRefProp; } export const ActivitySchema = SchemaFactory.createForClass(ActivityDocument); ActivitySchema.index({ projectId: 1, occurredAt: -1 }); ActivitySchema.index({ issueId: 1, occurredAt: -1 }); /** Every status and release step of every ticket (statistics). */ @Schema({ ...LOOSE, collection: 'issue_timeline' }) class TimelineDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String }) projectId!: string; @Prop({ type: String }) issueId!: string; @Prop({ type: String }) at!: string; } export const TimelineSchema = SchemaFactory.createForClass(TimelineDocument); TimelineSchema.index({ projectId: 1, at: 1 }); @Schema({ ...LOOSE, collection: 'notifications' }) class NotificationDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String }) userId!: string; @Prop({ type: String }) readAt!: string | null; @Prop({ type: String }) createdAt!: string; @Prop({ type: UserRefSchema }) actor!: UserRefProp; } export const NotificationSchema = SchemaFactory.createForClass(NotificationDocument); NotificationSchema.index({ userId: 1, createdAt: -1 }); NotificationSchema.index({ userId: 1, readAt: 1 }); @Schema({ ...LOOSE, collection: 'notification_settings' }) class NotificationSettingsDocument { /** The user's id. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: Boolean }) email!: boolean; } export const NotificationSettingsSchema = SchemaFactory.createForClass( NotificationSettingsDocument, ); @Schema({ ...LOOSE, collection: 'saved_filters' }) class SavedFilterDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String }) projectId!: string; @Prop({ type: UserRefSchema }) owner!: UserRefProp; @Prop({ type: String }) name!: string; @Prop({ type: Boolean }) shared!: boolean; @Prop({ type: String }) createdAt!: string; } export const SavedFilterSchema = SchemaFactory.createForClass(SavedFilterDocument); 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: 'pinned_issues' }) class PinnedIssueDocument { @Prop({ type: String }) ownerId!: string; @Prop({ type: String }) issueId!: string; @Prop({ type: String }) pinnedAt!: string; } export const PinnedIssueSchema = SchemaFactory.createForClass(PinnedIssueDocument); PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true }); @Schema({ ...LOOSE, collection: 'alert_integrations' }) class AlertIntegrationDocument { @Prop({ type: String, required: true, unique: true }) projectId!: string; @Prop({ type: String }) token!: string; } export const AlertIntegrationSchema = SchemaFactory.createForClass( AlertIntegrationDocument, ); @Schema({ ...LOOSE, collection: 'alert_traces' }) class AlertTraceDocument { @Prop({ type: String }) projectId!: string; @Prop({ type: String }) fingerprint!: string; @Prop({ type: String }) issueId!: string; } export const AlertTraceSchema = SchemaFactory.createForClass(AlertTraceDocument); AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true }); @Schema({ ...LOOSE, collection: 'tree_follows' }) class TreeFollowDocument { @Prop({ type: String }) userId!: string; @Prop({ type: String, index: true }) issueId!: string; } export const TreeFollowSchema = SchemaFactory.createForClass(TreeFollowDocument); TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true }); @Schema({ ...LOOSE, collection: 'issue_visits' }) class IssueVisitDocument { @Prop({ type: String }) userId!: string; @Prop({ type: String }) issueId!: string; @Prop({ type: String }) seenAt!: string; } export const IssueVisitSchema = SchemaFactory.createForClass(IssueVisitDocument); IssueVisitSchema.index({ userId: 1, issueId: 1 }, { unique: true }); @Schema({ ...LOOSE, collection: 'issue_reminders' }) class IssueReminderDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String }) userId!: string; @Prop({ type: String }) issueId!: string; @Prop({ type: String }) at!: string; @Prop({ type: String, default: null }) sentAt!: string | null; } export const IssueReminderSchema = SchemaFactory.createForClass( IssueReminderDocument, ); IssueReminderSchema.index({ sentAt: 1, at: 1 }); IssueReminderSchema.index({ userId: 1, issueId: 1 }); @Schema({ ...LOOSE, collection: 'my_days' }) class MyDayDocument { @Prop({ type: String }) userId!: string; @Prop({ type: String }) day!: string; @Prop({ type: [String] }) issueIds!: string[]; } export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument); MyDaySchema.index({ userId: 1, day: -1 }, { unique: true }); @Schema({ ...LOOSE, collection: 'release_note_shares' }) class ReleaseNotesShareDocument { @Prop({ type: String, required: true, unique: true }) releaseId!: string; @Prop({ type: String, required: true, unique: true }) token!: string; } export const ReleaseNotesShareSchema = SchemaFactory.createForClass( ReleaseNotesShareDocument, ); @Schema({ ...LOOSE, collection: 'git_integrations' }) class GitIntegrationDocument { /** The project's id. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: [Object] }) repositories!: GitRepositoryDoc[]; @Prop({ type: String }) secret!: string; @Prop({ type: String }) createdAt!: string; } export const GitIntegrationSchema = SchemaFactory.createForClass( GitIntegrationDocument, ); @Schema({ ...LOOSE, collection: 'outgoing_webhooks' }) class OutgoingWebhookDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) projectId!: string; @Prop({ type: Boolean }) active!: boolean; @Prop({ type: [String] }) events!: string[]; @Prop({ type: UserRefSchema }) createdBy!: UserRefProp; @Prop({ type: String }) createdAt!: string; } export const OutgoingWebhookSchema = SchemaFactory.createForClass( OutgoingWebhookDocument, ); @Schema({ ...LOOSE, collection: 'automations' }) class AutomationDocument { @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) projectId!: string; @Prop({ type: Boolean }) active!: boolean; @Prop({ type: UserRefSchema }) createdBy!: UserRefProp; @Prop({ type: String }) createdAt!: string; } export const AutomationSchema = SchemaFactory.createForClass(AutomationDocument); @Schema({ ...LOOSE, collection: 'automation_runs' }) class AutomationRunDocument { /** `ruleId:eventKey`: a rule runs once per event. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: Date }) at!: Date; } export const AutomationRunSchema = SchemaFactory.createForClass( AutomationRunDocument, ); /** One per person: the secret of their calendar URL. */ @Schema({ ...LOOSE, collection: 'calendar_feeds' }) class CalendarFeedDocument { /** The user's id. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, required: true, unique: true }) token!: string; @Prop({ type: String }) createdAt!: string; } export const CalendarFeedSchema = SchemaFactory.createForClass(CalendarFeedDocument); /** One per project: the secret of its link for clients, and what it shows. */ @Schema({ ...LOOSE, collection: 'dashboard_shares' }) class DashboardShareDocument { /** The project's id. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, required: true, unique: true }) token!: string; @Prop({ type: [String] }) blocks!: string[]; @Prop({ type: String }) createdAt!: string; } export const DashboardShareSchema = SchemaFactory.createForClass( DashboardShareDocument, ); /** The devices where each person turned push on. */ @Schema({ ...LOOSE, collection: 'push_subscriptions' }) class PushSubscriptionDocument { /** sha256 of the endpoint. */ @Prop({ type: String, required: true, unique: true }) id!: string; @Prop({ type: String, index: true }) userId!: string; @Prop({ type: String }) endpoint!: string; @Prop({ type: Object }) keys!: { p256dh: string; auth: string }; @Prop({ type: String }) createdAt!: string; } export const PushSubscriptionSchema = SchemaFactory.createForClass( PushSubscriptionDocument, ); // Redeliveries happen within minutes; a month of memory is plenty. AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 }); export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, { name: ReadModels.Comment, schema: CommentSchema }, { name: ReadModels.Release, schema: ReleaseSchema }, { name: ReadModels.Sprint, schema: SprintSchema }, { name: ReadModels.GitIntegration, schema: GitIntegrationSchema }, { name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema }, { name: ReadModels.Automation, schema: AutomationSchema }, { name: ReadModels.AutomationRun, schema: AutomationRunSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.MyView, schema: MyViewSchema }, { name: ReadModels.PinnedIssue, schema: PinnedIssueSchema }, { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.TreeFollow, schema: TreeFollowSchema }, { name: ReadModels.IssueVisit, schema: IssueVisitSchema }, { name: ReadModels.IssueReminder, schema: IssueReminderSchema }, { name: ReadModels.MyDay, schema: MyDaySchema }, { name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, ]; /** A document as the model it holds: Mongo's own `_id` left out. */ export function fromDoc( doc: T & { _id?: unknown }, ): T { const { _id: _ignored, ...rest } = doc; return rest as T; } export function issueFromDoc(doc: IssueDoc): IssueReadModel { const { _id: _ignored, outwardLinks, inwardLinks, ...rest } = doc; return { ...rest, links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])], // Tickets projected before attachments existed have none. attachments: rest.attachments ?? [], watchers: rest.watchers ?? [], reviews: rest.reviews ?? [], votes: rest.votes ?? 0, voterIds: rest.voterIds ?? [], parent: rest.parent?.id ? rest.parent : null, rank: rest.rank ?? implicitRank(rest.number), startDate: rest.startDate ?? null, team: rest.team ?? null, customFields: rest.customFields ?? {}, sprint: rest.sprint?.id ? rest.sprint : null, // Links recorded before projects had several repositories name none. devLinks: (rest.devLinks ?? []).map((link) => ({ ...link, repository: link.repository ?? null, })), trashedAt: rest.trashedAt ?? null, checklist: rest.checklist ?? [], }; }