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
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
FAILED_RETENTION_DAYS,
|
||||
JOB_RUN_RETENTION_DAYS,
|
||||
JOB_RUN_STALE_HOURS,
|
||||
PENDING_DELIVERY_MAX_AGE_DAYS,
|
||||
WEBHOOK_DELIVERY_RETENTION_DAYS,
|
||||
} from './cleanup.job';
|
||||
|
||||
@@ -85,9 +87,13 @@ describe('CleanupJob', () => {
|
||||
'domainEvents.failed': 12,
|
||||
'webhookDeliveries.delivered': 13,
|
||||
'webhookDeliveries.failed': 14,
|
||||
'notificationDeliveries.done': 15,
|
||||
'notificationDeliveries.failed': 16,
|
||||
'notificationDeliveries.pending': 17,
|
||||
telegramLinkTokens: 18,
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 15,
|
||||
weatherDays: 16,
|
||||
'jobRuns.deleted': 19,
|
||||
weatherDays: 20,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,11 +130,48 @@ describe('CleanupJob', () => {
|
||||
'"createdAt" < now() - (? * interval \'1 day\')',
|
||||
);
|
||||
// PENDING and PROCESSING entries are never touched, however old.
|
||||
for (const { sql } of statements()) {
|
||||
for (const { sql } of statements().filter(
|
||||
({ sql }) => !sql.includes('FROM "NotificationDelivery"'),
|
||||
)) {
|
||||
expect(sql).not.toMatch(/PENDING|PROCESSING/);
|
||||
}
|
||||
});
|
||||
|
||||
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => {
|
||||
await job.run();
|
||||
|
||||
const failed = statementOn('NotificationDelivery', 'FAILED');
|
||||
expect(failed.sql).toContain(
|
||||
'"createdAt" < now() - (? * interval \'1 day\')',
|
||||
);
|
||||
expect(failed.values).toEqual([FAILED_RETENTION_DAYS]);
|
||||
const delivered = statements().find(
|
||||
({ sql }) =>
|
||||
sql.includes('DELETE FROM "NotificationDelivery"') &&
|
||||
sql.includes("IN ('DELIVERED', 'SKIPPED')"),
|
||||
);
|
||||
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
|
||||
expect(delivered?.sql).toContain('"createdAt" < now()');
|
||||
const tokens = statementOn('TelegramLinkToken');
|
||||
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\'');
|
||||
expect(tokens.values).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes PENDING NotificationDelivery rows older than 7 days and logs the count', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await job.run();
|
||||
|
||||
const { sql, values } = statementOn('NotificationDelivery', 'PENDING');
|
||||
expect(sql).toContain('"createdAt" < now() - (? * interval \'1 day\')');
|
||||
expect(values).toEqual([PENDING_DELIVERY_MAX_AGE_DAYS]);
|
||||
expect(PENDING_DELIVERY_MAX_AGE_DAYS).toBe(7);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('17 Telegram'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
|
||||
await job.run();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
@@ -14,6 +14,8 @@ export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
|
||||
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
|
||||
export const FAILED_RETENTION_DAYS = 90;
|
||||
export const JOB_RUN_RETENTION_DAYS = 90;
|
||||
/** A Telegram delivery that waited this long is stale anyway (24 h at most). */
|
||||
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
|
||||
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
|
||||
export const JOB_RUN_STALE_HOURS = 6;
|
||||
|
||||
@@ -24,6 +26,7 @@ export const JOB_RUN_STALE_HOURS = 6;
|
||||
*/
|
||||
@Injectable()
|
||||
export class CleanupJob implements Job {
|
||||
private readonly logger = new Logger(CleanupJob.name);
|
||||
readonly name: JobName = 'cleanup';
|
||||
|
||||
constructor(
|
||||
@@ -60,6 +63,31 @@ export class CleanupJob implements Job {
|
||||
DELETE FROM "WebhookDelivery"
|
||||
WHERE "status" = 'FAILED'
|
||||
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Template deliveries (a reply to a chat) carry no user, so the user
|
||||
// cascade never reaches them: they go by age like all the others.
|
||||
const notificationDeliveriesDone = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" IN ('DELIVERED', 'SKIPPED')
|
||||
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
|
||||
const notificationDeliveriesFailed = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" = 'FAILED'
|
||||
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Unsent for a week (bot token never fixed, loop off): the sender would
|
||||
// skip it as stale anyway. The only PENDING rows this job removes.
|
||||
const notificationDeliveriesPending = await this.prisma.$executeRaw`
|
||||
DELETE FROM "NotificationDelivery"
|
||||
WHERE "status" = 'PENDING'
|
||||
AND "createdAt" < now() - (${PENDING_DELIVERY_MAX_AGE_DAYS} * interval '1 day')`;
|
||||
if (notificationDeliveriesPending > 0) {
|
||||
this.logger.warn(
|
||||
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
|
||||
);
|
||||
}
|
||||
// A link token lives 10 minutes; used or not, it is of no use after that.
|
||||
const telegramLinkTokens = await this.prisma.$executeRaw`
|
||||
DELETE FROM "TelegramLinkToken"
|
||||
WHERE "expiresAt" < now() - interval '30 days'`;
|
||||
// After abortStale: no run this old can still be RUNNING.
|
||||
const jobRunsDeleted = await this.prisma.$executeRaw`
|
||||
DELETE FROM "JobRun"
|
||||
@@ -78,6 +106,10 @@ export class CleanupJob implements Job {
|
||||
'domainEvents.failed': domainEventsFailed,
|
||||
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
|
||||
'webhookDeliveries.failed': webhookDeliveriesFailed,
|
||||
'notificationDeliveries.done': notificationDeliveriesDone,
|
||||
'notificationDeliveries.failed': notificationDeliveriesFailed,
|
||||
'notificationDeliveries.pending': notificationDeliveriesPending,
|
||||
telegramLinkTokens,
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
|
||||
Reference in new issue
Block a user