feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { NextStepsService } from '../autopilot/next-steps.service';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { DigestService } from './digest.service';
|
||||
|
||||
describe('DigestService season changes since the last digest', () => {
|
||||
const NOW = new Date('2026-06-10T02:30:00.000Z');
|
||||
let domainEvents: jest.Mock;
|
||||
let jobRuns: jest.Mock;
|
||||
let recordForUsers: jest.Mock;
|
||||
let service: DigestService;
|
||||
|
||||
const change = (
|
||||
apiaryId: string,
|
||||
previousSegment: string | null,
|
||||
segment: string,
|
||||
occurredAt = '2026-06-09T12:00:00.000Z',
|
||||
) => ({
|
||||
occurredAt: new Date(occurredAt),
|
||||
payload: { apiaryId, previousSegment, segment },
|
||||
});
|
||||
|
||||
const lines = (): string[] =>
|
||||
(
|
||||
recordForUsers.mock.calls[0] as [string[], { message: string }]
|
||||
)[1].message.split('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
domainEvents = jest.fn().mockResolvedValue([]);
|
||||
jobRuns = jest.fn().mockResolvedValue([]);
|
||||
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
|
||||
const prisma = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
colony: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ apiaryId: 'a1' }, { apiaryId: 'a2' }]),
|
||||
},
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'a1', name: 'Wald' },
|
||||
{ id: 'a2', name: 'Tal' },
|
||||
]),
|
||||
},
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
domainEvent: { findMany: domainEvents },
|
||||
jobRun: { findMany: jobRuns },
|
||||
$transaction: (work: (client: unknown) => unknown) => work({}),
|
||||
} as unknown as PrismaService;
|
||||
service = new DigestService(
|
||||
prisma,
|
||||
{ recordForUsers } as unknown as NotificationsService,
|
||||
{
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
|
||||
} as unknown as PermissionResolverService,
|
||||
{
|
||||
inspectionDueAll: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as NextStepsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the changes between the end of the previous window and now', async () => {
|
||||
jobRuns.mockResolvedValue([
|
||||
{
|
||||
result: {
|
||||
digestsWritten: 1,
|
||||
digestWindowEnd: '2026-06-09T05:00:00.000Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(domainEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: new Date('2026-06-09T05:00:00.000Z'), lt: NOW },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the last 24 hours without a previous digest run', async () => {
|
||||
jobRuns.mockResolvedValue([{ result: { TaskDue: 1 } }, { result: null }]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(domainEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: new Date('2026-06-09T02:30:00.000Z'), lt: NOW },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists a segment change since the last digest, with its interval', async () => {
|
||||
domainEvents.mockResolvedValue([change('a1', 'FRUEHJAHR', 'SCHWARMZEIT')]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines()).toContain(
|
||||
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores phase-only changes and a first state without previous segment', async () => {
|
||||
domainEvents.mockResolvedValue([
|
||||
change('a1', 'SOMMER', 'SOMMER'),
|
||||
change('a2', null, 'FRUEHJAHR'),
|
||||
]);
|
||||
|
||||
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a change back neutrally and nets out several changes of one apiary', async () => {
|
||||
domainEvents.mockResolvedValue([
|
||||
change('a1', 'FRUEHJAHR', 'SCHWARMZEIT', '2026-06-09T08:00:00.000Z'),
|
||||
change('a1', 'SCHWARMZEIT', 'FRUEHJAHR', '2026-06-09T09:00:00.000Z'),
|
||||
change('a2', 'FRUEHJAHR', 'WINTERRUHE', '2026-06-09T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines().slice(1)).toEqual(['Stand Tal: wieder Winterruhe.']);
|
||||
});
|
||||
|
||||
it('treats autumn to winter rest as a beginning, not a return', async () => {
|
||||
domainEvents.mockResolvedValue([change('a1', 'HERBST', 'WINTERRUHE')]);
|
||||
|
||||
await service.runFor('2026-06-10', undefined, { until: NOW });
|
||||
|
||||
expect(lines()).toContain('Stand Wald: Winterruhe beginnt.');
|
||||
});
|
||||
|
||||
it('leaves out apiaries without living colonies', async () => {
|
||||
domainEvents.mockResolvedValue([change('a9', 'FRUEHJAHR', 'SCHWARMZEIT')]);
|
||||
|
||||
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,22 @@ describe('buildDigest', () => {
|
||||
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
|
||||
});
|
||||
|
||||
it('words a segment that went back neutrally', () => {
|
||||
const digest = buildDigest('u1', new Set(['apiaries:read']), {
|
||||
...EMPTY,
|
||||
seasonChanges: [
|
||||
{
|
||||
apiaryName: 'Wald',
|
||||
segment: 'WINTERRUHE',
|
||||
intervalDays: null,
|
||||
isReturn: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines(digest?.message)[1]).toBe('Stand Wald: wieder Winterruhe.');
|
||||
});
|
||||
|
||||
it('builds no digest without content', () => {
|
||||
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
|
||||
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
|
||||
@@ -203,6 +219,8 @@ describe('DigestService', () => {
|
||||
colony: {
|
||||
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
|
||||
},
|
||||
domainEvent: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
jobRun: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
|
||||
},
|
||||
@@ -215,9 +233,6 @@ describe('DigestService', () => {
|
||||
{
|
||||
inspectionDueAll: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as NextStepsService,
|
||||
{
|
||||
statesFor: jest.fn().mockResolvedValue(new Map()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import { loggableError } from '../common/loggable-error';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonSegmentKey } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
|
||||
/** One line of the digest and the permissions that entitled the reader to it. */
|
||||
@@ -36,6 +35,8 @@ export interface DigestData {
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
intervalDays: number | null;
|
||||
/** The segment went back (a revoked report): worded neutrally. */
|
||||
isReturn?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -55,6 +56,17 @@ const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
|
||||
HERBST: 'Herbst',
|
||||
};
|
||||
|
||||
/** The time span of season changes a digest run reports. */
|
||||
export interface DigestWindow {
|
||||
from?: Date;
|
||||
until?: Date;
|
||||
}
|
||||
|
||||
/** Key in the JobRun result: where the season window of that digest ended. */
|
||||
export const WINDOW_END_KEY = 'digestWindowEnd';
|
||||
const FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const PREVIOUS_RUNS_LOOKED_AT = 10;
|
||||
|
||||
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
@@ -133,11 +145,12 @@ function seasonLines(
|
||||
return [];
|
||||
}
|
||||
return data.seasonChanges.map((change) => ({
|
||||
text:
|
||||
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
|
||||
(change.intervalDays === null
|
||||
? ''
|
||||
: ` Durchsicht alle ${change.intervalDays} Tage.`),
|
||||
text: change.isReturn
|
||||
? `Stand ${change.apiaryName}: wieder ${SEGMENT_NAMES[change.segment]}.`
|
||||
: `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
|
||||
(change.intervalDays === null
|
||||
? ''
|
||||
: ` Durchsicht alle ${change.intervalDays} Tage.`),
|
||||
permissions: ['apiaries:read'],
|
||||
}));
|
||||
}
|
||||
@@ -183,16 +196,23 @@ export class DigestService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly nextSteps: NextStepsService,
|
||||
private readonly seasons: SeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The digests of the Berlin day `today` for every active user who did not
|
||||
* switch them off (`userIds` narrows that, for tests). Returns how many
|
||||
* were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others;
|
||||
* the run then fails with the count of the digests that went through.
|
||||
* were written; a rerun for the same day writes 0 (the keys exist). A failing
|
||||
* user is logged and does not stop the others; the run then fails with the
|
||||
* count of the digests that went through. `window` bounds the season changes
|
||||
* this digest reports: `until` (default now) is stored by DigestStep as the
|
||||
* start of the next window; `from` (default: the previous window end, else
|
||||
* 24 hours before `until`) is for tests.
|
||||
*/
|
||||
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
|
||||
async runFor(
|
||||
today: string,
|
||||
userIds?: readonly string[],
|
||||
window: DigestWindow = {},
|
||||
): Promise<number> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
@@ -206,7 +226,7 @@ export class DigestService {
|
||||
if (users.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const data = await this.loadData(today);
|
||||
const data = await this.loadData(today, window);
|
||||
let written = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of users) {
|
||||
@@ -249,7 +269,10 @@ export class DigestService {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async loadData(today: string): Promise<DigestData> {
|
||||
private async loadData(
|
||||
today: string,
|
||||
window: DigestWindow,
|
||||
): Promise<DigestData> {
|
||||
const [tasks, suggestions, inspectionDue, seasonChanges] =
|
||||
await Promise.all([
|
||||
this.loadTasks(today),
|
||||
@@ -259,7 +282,7 @@ export class DigestService {
|
||||
}),
|
||||
// Every colony, whatever the reader may see: the lines are filtered per user.
|
||||
this.nextSteps.inspectionDueAll(today),
|
||||
this.loadSeasonChanges(today),
|
||||
this.loadSeasonChanges(window),
|
||||
]);
|
||||
return { today, tasks, inspectionDue, suggestions, seasonChanges };
|
||||
}
|
||||
@@ -279,40 +302,142 @@ export class DigestService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Apiaries with living colonies whose season segment begins today. */
|
||||
/**
|
||||
* The start of the window: the end of the window the previous digest run
|
||||
* reported (stored in its JobRun result), else 24 hours before `until`.
|
||||
* Only runs that reached the digest step carry a window end.
|
||||
*/
|
||||
private async windowStart(until: Date): Promise<Date> {
|
||||
const runs = await this.prisma.jobRun.findMany({
|
||||
where: {
|
||||
job: 'daily-evaluation',
|
||||
status: { in: ['SUCCEEDED', 'FAILED'] },
|
||||
},
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: PREVIOUS_RUNS_LOOKED_AT,
|
||||
select: { result: true },
|
||||
});
|
||||
for (const { result } of runs) {
|
||||
const end =
|
||||
result !== null && typeof result === 'object' && !Array.isArray(result)
|
||||
? result[WINDOW_END_KEY]
|
||||
: undefined;
|
||||
if (typeof end === 'string' && !Number.isNaN(Date.parse(end))) {
|
||||
return new Date(end);
|
||||
}
|
||||
}
|
||||
return new Date(until.getTime() - FALLBACK_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apiaries with living colonies whose season segment changed in the window
|
||||
* [previous window end, until) according to the SeasonPhaseChanged events.
|
||||
* Several changes of one apiary count as one (first previous segment to
|
||||
* last segment); no net change, no line. A change that happened after
|
||||
* `until` belongs to the next digest, so every change is reported once.
|
||||
*/
|
||||
private async loadSeasonChanges(
|
||||
today: string,
|
||||
window: DigestWindow,
|
||||
): Promise<DigestData['seasonChanges']> {
|
||||
const colonies = await this.prisma.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
const until = window.until ?? new Date();
|
||||
const from = window.from ?? (await this.windowStart(until));
|
||||
const events = await this.prisma.domainEvent.findMany({
|
||||
where: {
|
||||
type: 'SeasonPhaseChanged',
|
||||
occurredAt: { gte: from, lt: until },
|
||||
},
|
||||
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
|
||||
select: { payload: true },
|
||||
});
|
||||
const nets = netSegmentChanges(events.map((event) => event.payload));
|
||||
if (nets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const withColonies = await this.prisma.colony.findMany({
|
||||
where: {
|
||||
...LIVING_COLONY_WHERE,
|
||||
apiaryId: { in: nets.map((net) => net.apiaryId) },
|
||||
},
|
||||
select: { apiaryId: true },
|
||||
distinct: ['apiaryId'],
|
||||
});
|
||||
const apiaryIds = colonies.map((colony) => colony.apiaryId);
|
||||
const states = await this.seasons.statesFor(this.prisma, apiaryIds, today);
|
||||
const changed = [...states.values()].filter(
|
||||
(state) => state.segmentSince === today,
|
||||
);
|
||||
const living = new Set(withColonies.map((colony) => colony.apiaryId));
|
||||
const changed = nets.filter((net) => living.has(net.apiaryId));
|
||||
if (changed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const [apiaries, interval] = await Promise.all([
|
||||
this.prisma.apiary.findMany({
|
||||
where: { id: { in: changed.map((state) => state.apiaryId) } },
|
||||
where: { id: { in: changed.map((net) => net.apiaryId) } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
readRule(this.prisma, 'INSPECTION_INTERVAL'),
|
||||
]);
|
||||
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
||||
return changed
|
||||
.map((state) => ({
|
||||
apiaryName: names.get(state.apiaryId) ?? '',
|
||||
segment: state.segment,
|
||||
.map((net) => ({
|
||||
apiaryName: names.get(net.apiaryId) ?? '',
|
||||
segment: net.segment,
|
||||
intervalDays:
|
||||
state.segment === 'WINTERRUHE' || !interval.active
|
||||
net.segment === 'WINTERRUHE' || !interval.active
|
||||
? null
|
||||
: interval.config.intervalDays[state.segment],
|
||||
: interval.config.intervalDays[net.segment],
|
||||
isReturn: net.isReturn,
|
||||
}))
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
|
||||
}
|
||||
}
|
||||
|
||||
interface NetSegmentChange {
|
||||
apiaryId: string;
|
||||
segment: SeasonSegmentKey;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
function isSegment(value: unknown): value is SeasonSegmentKey {
|
||||
return SEASON_SEGMENTS.some((segment) => segment === value);
|
||||
}
|
||||
|
||||
/** Autumn to winter rest is the normal course of the year, not a step back. */
|
||||
function wentBack(from: SeasonSegmentKey, to: SeasonSegmentKey): boolean {
|
||||
if (from === 'HERBST' && to === 'WINTERRUHE') {
|
||||
return false;
|
||||
}
|
||||
return SEASON_SEGMENTS.indexOf(to) < SEASON_SEGMENTS.indexOf(from);
|
||||
}
|
||||
|
||||
/**
|
||||
* First previous segment to last segment per apiary, in event order. Events
|
||||
* without a valid previous or new segment (a first state, phase-only
|
||||
* changes) say nothing about a segment change.
|
||||
*/
|
||||
function netSegmentChanges(payloads: readonly unknown[]): NetSegmentChange[] {
|
||||
const firstPrevious = new Map<string, SeasonSegmentKey>();
|
||||
const last = new Map<string, SeasonSegmentKey>();
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
continue;
|
||||
}
|
||||
const { apiaryId, previousSegment, segment } = payload as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (
|
||||
typeof apiaryId !== 'string' ||
|
||||
!isSegment(previousSegment) ||
|
||||
!isSegment(segment)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!firstPrevious.has(apiaryId)) {
|
||||
firstPrevious.set(apiaryId, previousSegment);
|
||||
}
|
||||
last.set(apiaryId, segment);
|
||||
}
|
||||
return [...last].flatMap(([apiaryId, segment]) => {
|
||||
const previous = firstPrevious.get(apiaryId);
|
||||
return previous === undefined || previous === segment
|
||||
? []
|
||||
: [{ apiaryId, segment, isReturn: wentBack(previous, segment) }];
|
||||
});
|
||||
}
|
||||
@@ -14,8 +14,26 @@ describe('DigestStep', () => {
|
||||
runFor,
|
||||
} as unknown as DigestService).run('2026-06-10');
|
||||
|
||||
expect(runFor).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(result).toEqual({ digestsWritten: 7 });
|
||||
expect(runFor).toHaveBeenCalledWith('2026-06-10', undefined, {
|
||||
until: expect.any(Date) as unknown,
|
||||
});
|
||||
const until = (
|
||||
runFor.mock.calls[0] as [string, undefined, { until: Date }]
|
||||
)[2].until;
|
||||
expect(result).toEqual({
|
||||
digestsWritten: 7,
|
||||
digestWindowEnd: until.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no window end when nothing was written, so the next run reads the same window', async () => {
|
||||
const runFor = jest.fn().mockResolvedValue(0);
|
||||
|
||||
const result = await new DigestStep({
|
||||
runFor,
|
||||
} as unknown as DigestService).run('2026-06-10');
|
||||
|
||||
expect(result).toEqual({ digestsWritten: 0 });
|
||||
});
|
||||
|
||||
it('lets a partial failure through with its counter', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { DigestService } from './digest.service';
|
||||
import { DigestService, WINDOW_END_KEY } from './digest.service';
|
||||
|
||||
/**
|
||||
* Step "digest", the last of the daily evaluation: the in-app morning
|
||||
@@ -16,6 +16,16 @@ export class DigestStep implements DailyEvaluationStep {
|
||||
constructor(private readonly digests: DigestService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
return { digestsWritten: await this.digests.runFor(today) };
|
||||
// The window of season changes ends here; the next run starts where
|
||||
// this one stopped, so every change is reported once.
|
||||
const until = new Date();
|
||||
const digestsWritten = await this.digests.runFor(today, undefined, {
|
||||
until,
|
||||
});
|
||||
// A run that wrote nothing (a same-day rerun, or no content) reported
|
||||
// no change: the next run must still read the earlier window.
|
||||
return digestsWritten === 0
|
||||
? { digestsWritten }
|
||||
: { digestsWritten, [WINDOW_END_KEY]: until.toISOString() };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { DigestService } from './digest.service';
|
||||
import { DigestStep } from './digest.step';
|
||||
import { TaskDueHandlers } from './task-due.handlers';
|
||||
@@ -14,7 +13,7 @@ import { TaskDueHandlers } from './task-due.handlers';
|
||||
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule, SeasonModule, AutopilotModule],
|
||||
imports: [NotificationsModule, AutopilotModule],
|
||||
providers: [TaskDueHandlers, DigestService, DigestStep],
|
||||
exports: [DigestService, DigestStep],
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user