feat: due-date reminders

Every hour the API reminds whoever has a ticket, and whoever follows it,
the day before its due date, on the day and once when it is overdue, in
the app and by e-mail for those who asked. Cards on the board flag what
is due soon or late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:36:19 +02:00
parent d6a434ca1d
commit 71b807b94b
19 changed files with 371 additions and 7 deletions

View File

@@ -238,6 +238,18 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
return docs.map(issueFromDoc);
}
async findDueBy(day: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({
dueDate: { $ne: null, $lte: `${day}\uffff` },
statusCategory: { $nin: FINISHED },
$or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }],
})
.limit(5000)
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async findByProject(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues.find({ projectId }).lean<IssueDoc[]>();
return docs.map(issueFromDoc);

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

View File

@@ -53,6 +53,7 @@ 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 { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -153,7 +154,12 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
],
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
providers: [
...PORTS,
StatusCategoryBackfill,
RankBackfill,
ReminderScheduler,
],
exports: PORTS.map((port) => port.provide),
})
export class TrackerInfrastructureModule {}