refactor(api): buses only in services, atomic commands, @Prop schemas

- Mongo read models are @Schema/@Prop classes (SchemaFactory), still
  loose: the props declared are the indexed or shaped ones.
- CommandBus and QueryBus are used by the application services alone.
  Duplicating a ticket, bulk updates, CSV imports, proposals and
  emptying the trash chain several commands, so they are service
  methods now and their composite commands are gone; every command
  handler is atomic. No forwardRef is needed.
- The due-date and trash timers are entry points: they live in the
  presenters and call the services. The automations event handler has
  its own file and calls AutomationsService.
- The tracker's user directory asks IdentityService; the composition
  root hands the identity application module to the tracker
  infrastructure (withIdentity), with no global module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 19:56:59 +02:00
parent 2c501a50d0
commit 1550332d41
34 changed files with 673 additions and 784 deletions

View File

@@ -15,7 +15,8 @@ import {
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema, Types } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Types } from 'mongoose';
/* Read model (MongoDB): denormalised documents shaped for the GraphQL queries. */
@@ -86,65 +87,105 @@ export const ReadModels = {
AutomationRun: 'AutomationRun',
} 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 } as const;
/** The domain id: what every query looks a document up by. */
const domainId = { type: String, required: true, unique: true };
/*
* 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;
export const ProjectSchema = new Schema<ProjectDoc>(
{
id: domainId,
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' },
);
@Schema({ _id: false })
class UserRefProp {
@Prop({ type: String }) id!: string;
@Prop({ type: String }) name!: string;
}
const UserRefSchema = SchemaFactory.createForClass(UserRefProp);
export const IssueSchema = new Schema<IssueDoc>(
{
id: domainId,
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 },
sprint: { _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' },
);
@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[];
@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 });
@@ -156,115 +197,137 @@ IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ trashedAt: 1 }, { sparse: true });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
{
id: domainId,
issueId: { type: String, index: true },
replyTo: { type: String, index: true },
author: userRef,
version: Number,
},
{ ...loose, collection: 'comments_view' },
);
@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 });
export const ReleaseSchema = new Schema<ReleaseDoc>(
{ id: domainId, projectId: { type: String, index: true }, version: Number },
{ ...loose, collection: 'releases_view' },
);
@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);
export const SprintSchema = new Schema<SprintDoc>(
{
id: domainId,
projectId: { type: String, index: true },
status: String,
version: Number,
},
{ ...loose, collection: 'sprints_view' },
);
@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);
export const ActivitySchema = new Schema<ActivityDoc>(
{
id: domainId,
projectId: String,
issueId: String,
actor: userRef,
},
{ ...loose, collection: 'activity' },
);
@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). */
export const TimelineSchema = new Schema<TimelineDoc>(
{ id: domainId, projectId: String, issueId: String, at: String },
{ ...loose, collection: 'issue_timeline' },
);
@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 });
export const NotificationSchema = new Schema<NotificationDoc>(
{
id: domainId,
userId: String,
readAt: String,
createdAt: String,
actor: userRef,
},
{ ...loose, collection: 'notifications' },
);
@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 });
export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ id: domainId, email: Boolean },
{ ...loose, collection: 'notification_settings' },
@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,
);
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
{
id: domainId,
projectId: String,
owner: userRef,
name: String,
shared: Boolean,
createdAt: String,
},
{ ...loose, collection: 'saved_filters' },
);
@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 });
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
{ id: domainId, repositories: [Object], secret: String, createdAt: String },
{ ...loose, collection: 'git_integrations' },
@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,
);
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
{
id: domainId,
projectId: { type: String, index: true },
active: Boolean,
events: [String],
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'outgoing_webhooks' },
@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,
);
export const AutomationSchema = new Schema<AutomationDoc>(
{
id: domainId,
projectId: { type: String, index: true },
active: Boolean,
createdBy: userRef,
createdAt: String,
},
{ ...loose, collection: 'automations' },
);
@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);
export const AutomationRunSchema = new Schema<AutomationRunDoc>(
{ id: domainId, at: Date },
{ ...loose, collection: 'automation_runs' },
@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,
);
// Redeliveries happen within minutes; a month of memory is plenty.
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });