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

@@ -9,6 +9,7 @@ import {
Prisma,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput {
@@ -87,13 +88,15 @@ function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
* needs (the rule the PermissionsGuard applies). The stored
* requiredPermissions are the recipient's and the content's permissions
* together, so a later consumer (e.g. the Telegram sender) can check them
* again.
* again. Right after the insert, in the same transaction, the delivery planner
* queues the Telegram delivery for users who asked for it.
*/
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly deliveryPlanner: NotificationDeliveryPlanner,
) {}
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
@@ -274,19 +277,30 @@ export class NotificationsService {
...recipientKeys,
...(content.requiredPermissions ?? []),
]);
return db.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
const insertAndPlan = async (client: DbClient): Promise<Notification[]> => {
const rows = await client.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
// Only the rows that were inserted: a repeated dedupe key plans nothing.
await this.deliveryPlanner.planIn(client, rows);
return rows;
};
// Rows and deliveries belong together: without the caller's transaction
// they get their own. A caller WITH a transaction gets no nested one: the
// insert and the planning run on its client and commit or roll back with it.
return '$connect' in db
? db.$transaction((tx) => insertAndPlan(tx))
: insertAndPlan(db);
}
}