feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 21:13:23 +02:00
1 parent 1971b2d9c4
commit 16cc557462
74 files changed
+9634 -340

No files matched your search

+46 -3
View File
@@ -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();
+33 -1
View File
@@ -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,