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:
@@ -0,0 +1,9 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotificationsService } 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 notifications: NotificationsService,
|
||||
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 = await this.notifications.sendDueReminders(
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
IssuesService,
|
||||
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 issues: IssuesService,
|
||||
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 = await this.issues.emptyTrash(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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
|
||||
import { IssuesController } from './http/issues.controller';
|
||||
import { ExploreController } from './http/explore.controller';
|
||||
import { ProposalLimiter } from './http/proposal-limiter';
|
||||
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
@@ -65,6 +67,9 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
providers: [
|
||||
Refs,
|
||||
ProposalLimiter,
|
||||
// Timers: entry points like the HTTP and GraphQL ones.
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
Reference in New Issue
Block a user