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

@@ -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');
});
});

View File

@@ -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}`,
);
}
}
}

View File

@@ -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}`,
);
}
}
}

View File

@@ -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,