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:
@@ -3,11 +3,7 @@ import {
|
||||
SYSTEM_ACTOR_NAME,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
FindUserQuery,
|
||||
FindUsersByIdsQuery,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import {
|
||||
DirectoryUser,
|
||||
UserContact,
|
||||
@@ -15,10 +11,13 @@ import {
|
||||
UserRefs,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
|
||||
/** Anti-corruption layer: the tracker asks the identity context about users. */
|
||||
/**
|
||||
* Anti-corruption layer: the tracker asks the identity context about users,
|
||||
* through its service (the identity module is global for this).
|
||||
*/
|
||||
@Injectable()
|
||||
export class IdentityUserDirectory extends UserDirectory {
|
||||
constructor(private readonly queryBus: QueryBus) {
|
||||
constructor(private readonly identity: IdentityService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,9 +25,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
userId: string,
|
||||
accessToken?: string,
|
||||
): Promise<DirectoryUser | null> {
|
||||
const user = await this.queryBus.execute(
|
||||
new FindUserQuery(userId, accessToken),
|
||||
);
|
||||
const user = await this.identity.findUser(userId, accessToken);
|
||||
return user
|
||||
? { id: user.id, name: user.name, isActive: user.isActive }
|
||||
: null;
|
||||
@@ -37,7 +34,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
async contacts(userIds: string[]): Promise<UserContact[]> {
|
||||
const wanted = [...new Set(userIds.filter(Boolean))];
|
||||
if (!wanted.length) return [];
|
||||
const users = await this.queryBus.execute(new FindUsersByIdsQuery(wanted));
|
||||
const users = await this.identity.findUsers(wanted);
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
@@ -47,9 +44,7 @@ export class IdentityUserDirectory extends UserDirectory {
|
||||
|
||||
async refs(userIds: (string | null | undefined)[]): Promise<UserRefs> {
|
||||
const wanted = [...new Set(userIds.filter((id): id is string => !!id))];
|
||||
const users = wanted.length
|
||||
? await this.queryBus.execute(new FindUsersByIdsQuery(wanted))
|
||||
: [];
|
||||
const users = wanted.length ? await this.identity.findUsers(wanted) : [];
|
||||
const names = new Map(users.map((u) => [u.id, u.name]));
|
||||
const of = (id: string) => ({
|
||||
id,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { dayIn } from './reminder.scheduler';
|
||||
|
||||
describe('reminder scheduler', () => {
|
||||
it("knows the installation's date, not UTC's", () => {
|
||||
const lateNight = new Date('2026-10-08T23:30:00Z');
|
||||
expect(dayIn(lateNight, 'UTC')).toBe('2026-10-08');
|
||||
expect(dayIn(lateNight, 'Europe/Madrid')).toBe('2026-10-09');
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { SendDueRemindersCommand } from '@task-stream/api/tracker/application';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
/** `YYYY-MM-DD` of `date` in `timeZone`. */
|
||||
export function dayIn(date: Date, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends due-date reminders a minute after start and then every hour, in the
|
||||
* installation's time zone (REMINDERS_TIMEZONE). Reminders are idempotent, so
|
||||
* several instances running this at once only repeat lookups.
|
||||
* REMINDERS_ENABLED=false turns it off.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReminderScheduler
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(ReminderScheduler.name);
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false')
|
||||
return;
|
||||
const first = setTimeout(() => void this.run(), 60_000);
|
||||
const every = setInterval(() => void this.run(), HOUR);
|
||||
first.unref?.();
|
||||
every.unref?.();
|
||||
this.timers = [first, every];
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
this.timers.forEach((timer) => clearTimeout(timer));
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const timeZone =
|
||||
this.config.get<string>('REMINDERS_TIMEZONE') || 'Europe/Madrid';
|
||||
try {
|
||||
const sent: number = await this.commandBus.execute(
|
||||
new SendDueRemindersCommand(dayIn(new Date(), timeZone)),
|
||||
);
|
||||
if (sent) this.logger.log(`Due-date reminders checked (${sent})`);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Due-date reminders failed: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
EmptyTrashCommand,
|
||||
TRASH_DAYS,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
/**
|
||||
* Deletes for good what has been in the trash longer than TRASH_DAYS (30
|
||||
* by default), a few minutes after start and then every six hours.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrashScheduler
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(TrashScheduler.name);
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
const first = setTimeout(() => void this.run(), 5 * 60_000);
|
||||
const every = setInterval(() => void this.run(), 6 * HOUR);
|
||||
first.unref?.();
|
||||
every.unref?.();
|
||||
this.timers = [first, every];
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
this.timers.forEach((timer) => clearTimeout(timer));
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const days =
|
||||
Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS;
|
||||
const before = new Date(Date.now() - days * 24 * HOUR).toISOString();
|
||||
try {
|
||||
const deleted: number = await this.commandBus.execute(
|
||||
new EmptyTrashCommand(before),
|
||||
);
|
||||
if (deleted)
|
||||
this.logger.log(`Deleted ${deleted} ticket(s) from the trash`);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Emptying the trash failed: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
} from './mongo/upsert-materialized.repositories';
|
||||
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
|
||||
import { RankBackfill } from './mongo/rank.backfill';
|
||||
import { ReminderScheduler } from './notifications/reminder.scheduler';
|
||||
import { TrashScheduler } from './notifications/trash.scheduler';
|
||||
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
|
||||
import {
|
||||
PostgresIssueNumbersRepository,
|
||||
@@ -158,13 +156,16 @@ const PORTS = [
|
||||
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
|
||||
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
|
||||
],
|
||||
providers: [
|
||||
...PORTS,
|
||||
StatusCategoryBackfill,
|
||||
RankBackfill,
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
],
|
||||
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
|
||||
exports: PORTS.map((port) => port.provide),
|
||||
})
|
||||
export class TrackerInfrastructureModule {}
|
||||
export class TrackerInfrastructureModule {
|
||||
/**
|
||||
* The user directory asks the identity context through its service, so
|
||||
* the tracker needs that context's application module: the composition
|
||||
* root passes the same one the identity context is built with.
|
||||
*/
|
||||
static withIdentity(identity: Type | DynamicModule): DynamicModule {
|
||||
return { module: TrackerInfrastructureModule, imports: [identity] };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user