Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import {
|
|
ActivityReadModel,
|
|
CommentReadModel,
|
|
NotificationReadModel,
|
|
IssueLinkReadModel,
|
|
IssueReadModel,
|
|
IssueTimelineEntry,
|
|
ProjectReadModel,
|
|
ReleaseReadModel,
|
|
} from '@task-stream/api/tracker/domain';
|
|
import { Schema } from 'mongoose';
|
|
|
|
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
|
|
|
|
type Doc<T extends { id: string }> = Omit<T, 'id'> & { _id: string };
|
|
|
|
export type ProjectDoc = Doc<ProjectReadModel> & { memberIds: string[] };
|
|
/** Links are split: outward ones come from the issue itself, inward ones from other issues' events. */
|
|
export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
|
|
outwardLinks: IssueLinkReadModel[];
|
|
inwardLinks: IssueLinkReadModel[];
|
|
};
|
|
export type CommentDoc = Doc<CommentReadModel>;
|
|
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
|
export type ActivityDoc = Doc<ActivityReadModel>;
|
|
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
|
export type NotificationDoc = Doc<NotificationReadModel>;
|
|
export interface NotificationSettingsDoc {
|
|
_id: string;
|
|
email: boolean;
|
|
}
|
|
|
|
export const ReadModels = {
|
|
Project: 'ProjectReadModel',
|
|
Issue: 'IssueReadModel',
|
|
Comment: 'CommentReadModel',
|
|
Release: 'ReleaseReadModel',
|
|
Activity: 'ActivityReadModel',
|
|
Timeline: 'IssueTimelineReadModel',
|
|
Notification: 'NotificationReadModel',
|
|
NotificationSettings: 'NotificationSettings',
|
|
} as const;
|
|
|
|
const userRef = { _id: false, id: String, name: String };
|
|
// `type` must be wrapped: a bare `type` key would declare the whole subdocument a String.
|
|
const link = {
|
|
_id: false,
|
|
type: { type: String },
|
|
direction: String,
|
|
issueId: String,
|
|
issueKey: String,
|
|
};
|
|
const loose = { strict: false, versionKey: false, _id: false } as const;
|
|
|
|
export const ProjectSchema = new Schema<ProjectDoc>(
|
|
{
|
|
_id: String,
|
|
key: { type: String, unique: true },
|
|
isPublic: { type: Boolean, index: true },
|
|
memberIds: { type: [String], index: true },
|
|
owner: userRef,
|
|
members: [{ _id: false, user: userRef, role: String }],
|
|
version: Number,
|
|
},
|
|
{ ...loose, collection: 'projects_view' },
|
|
);
|
|
|
|
export const IssueSchema = new Schema<IssueDoc>(
|
|
{
|
|
_id: String,
|
|
projectId: { type: String, index: true },
|
|
key: { type: String, unique: true },
|
|
status: String,
|
|
statusCategory: String,
|
|
reporter: userRef,
|
|
assignee: userRef,
|
|
release: { _id: false, id: String, name: String },
|
|
outwardLinks: [link],
|
|
inwardLinks: [link],
|
|
watchers: [userRef],
|
|
parent: { _id: false, id: String, key: String, title: String },
|
|
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
|
|
attachments: [
|
|
{
|
|
_id: false,
|
|
id: String,
|
|
fileName: String,
|
|
mimeType: String,
|
|
size: Number,
|
|
uploadedBy: userRef,
|
|
uploadedAt: String,
|
|
commentId: String,
|
|
},
|
|
],
|
|
labels: [String],
|
|
version: Number,
|
|
},
|
|
{ ...loose, collection: 'issues_view' },
|
|
);
|
|
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({ 'release.id': 1 });
|
|
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
|
|
|
export const CommentSchema = new Schema<CommentDoc>(
|
|
{
|
|
_id: String,
|
|
issueId: { type: String, index: true },
|
|
author: userRef,
|
|
version: Number,
|
|
},
|
|
{ ...loose, collection: 'comments_view' },
|
|
);
|
|
CommentSchema.index({ issueId: 1, createdAt: 1 });
|
|
|
|
export const ReleaseSchema = new Schema<ReleaseDoc>(
|
|
{ _id: String, projectId: { type: String, index: true }, version: Number },
|
|
{ ...loose, collection: 'releases_view' },
|
|
);
|
|
|
|
export const ActivitySchema = new Schema<ActivityDoc>(
|
|
{
|
|
_id: String,
|
|
projectId: String,
|
|
issueId: String,
|
|
actor: userRef,
|
|
},
|
|
{ ...loose, collection: 'activity' },
|
|
);
|
|
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
|
|
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
|
|
|
|
/** Every status and release step of every ticket (statistics). */
|
|
export const TimelineSchema = new Schema<TimelineDoc>(
|
|
{ _id: String, projectId: String, issueId: String, at: String },
|
|
{ ...loose, collection: 'issue_timeline' },
|
|
);
|
|
TimelineSchema.index({ projectId: 1, at: 1 });
|
|
|
|
export const NotificationSchema = new Schema<NotificationDoc>(
|
|
{
|
|
_id: String,
|
|
userId: String,
|
|
readAt: String,
|
|
createdAt: String,
|
|
actor: userRef,
|
|
},
|
|
{ ...loose, collection: 'notifications' },
|
|
);
|
|
NotificationSchema.index({ userId: 1, createdAt: -1 });
|
|
NotificationSchema.index({ userId: 1, readAt: 1 });
|
|
|
|
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
|
{ _id: String, email: Boolean },
|
|
{ ...loose, collection: 'notification_settings' },
|
|
);
|
|
|
|
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.Activity, schema: ActivitySchema },
|
|
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
|
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
|
];
|
|
|
|
export function fromDoc<T extends { _id: string }>(
|
|
doc: T,
|
|
): Omit<T, '_id'> & { id: string } {
|
|
const { _id, ...rest } = doc;
|
|
return { id: _id, ...rest };
|
|
}
|
|
|
|
export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|
const { _id, outwardLinks, inwardLinks, ...rest } = doc;
|
|
return {
|
|
id: _id,
|
|
...rest,
|
|
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
|
// Tickets projected before attachments existed have none.
|
|
attachments: rest.attachments ?? [],
|
|
watchers: rest.watchers ?? [],
|
|
parent: rest.parent?.id ? rest.parent : null,
|
|
checklist: rest.checklist ?? [],
|
|
};
|
|
}
|