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

@@ -0,0 +1,202 @@
import type { Notification } from '../../generated/prisma';
import { berlinDateTime } from '../common/dates';
import type { DbClient } from '../prisma/db-client';
import { NotificationDeliveryPlanner } from './delivery-planner';
const NOW = berlinDateTime('2026-10-13', '04:30');
function notification(
id: string,
userId: string,
category: Notification['category'],
): Notification {
return { id, userId, category } as Notification;
}
interface Preference {
userId: string;
telegramCategories: Notification['category'][];
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
}
const preference = (
userId: string,
overrides: Partial<Preference> = {},
): Preference => ({
userId,
telegramCategories: ['TASK_DUE', 'DIGEST'],
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
...overrides,
});
describe('NotificationDeliveryPlanner', () => {
let db: {
notificationPreference: { findMany: jest.Mock };
notificationDelivery: { createMany: jest.Mock };
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
};
let planner: NotificationDeliveryPlanner;
const created = () =>
(
db.notificationDelivery.createMany.mock.calls[0] as [
{
data: {
channel: string;
userId: string;
notificationId: string;
availableAt: Date;
}[];
skipDuplicates: boolean;
},
]
)[0];
beforeEach(() => {
db = {
notificationPreference: { findMany: jest.fn().mockResolvedValue([]) },
notificationDelivery: {
createMany: jest
.fn()
.mockImplementation(({ data }: { data: unknown[] }) =>
Promise.resolve({ count: data.length }),
),
},
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
$executeRaw: jest.fn().mockResolvedValue(1),
};
planner = new NotificationDeliveryPlanner();
});
const plan = (notifications: Notification[]) =>
planner.planIn(db as unknown as DbClient, notifications);
it('plans nothing for an empty list and asks no question', async () => {
expect(await plan([])).toBe(0);
expect(db.notificationPreference.findMany).not.toHaveBeenCalled();
});
it('asks only for users with Telegram switched on and a link', async () => {
await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(db.notificationPreference.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: { in: ['u1'] },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
}),
);
});
it('plans nothing without an eligible user (no row, nothing sent to the worker)', async () => {
expect(await plan([notification('n1', 'u1', 'TASK_DUE')])).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
expect(db.$executeRaw).not.toHaveBeenCalled();
});
it('plans a TELEGRAM row for a category the user chose, available after the quiet hours', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
const count = await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(count).toBe(1);
expect(created().skipDuplicates).toBe(true);
expect(created().data).toEqual([
{
channel: 'TELEGRAM',
userId: 'u1',
notificationId: 'n1',
availableAt: berlinDateTime('2026-10-13', '06:31'),
},
]);
});
it('skips a category the user did not switch on', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { telegramCategories: ['DIGEST'] }),
]);
const count = await plan([
notification('n1', 'u1', 'TASK_DUE'),
notification('n2', 'u1', 'SUGGESTION'),
]);
expect(count).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
});
it('plans each notification with the settings of its own user', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { quietFrom: null, quietUntil: null }),
preference('u2', { telegramCategories: ['SYSTEM'] }),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'SYSTEM'),
notification('n3', 'u3', 'SYSTEM'),
]);
expect(created().data).toEqual([
expect.objectContaining({
userId: 'u1',
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
expect.objectContaining({
userId: 'u2',
// 04:30 is inside the quiet hours of u2.
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
]);
});
it('reads the clock of the database once for the whole list', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$queryRaw).toHaveBeenCalledTimes(1);
});
it('wakes the worker once through notification_delivery after inserting', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const [strings, channel] = db.$executeRaw.mock.calls[0] as [
readonly string[],
string,
];
expect(strings.join('?')).toContain('pg_notify');
expect(channel).toBe('notification_delivery');
});
it('does not wake the worker if every row existed already', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
db.notificationDelivery.createMany.mockResolvedValue({ count: 0 });
expect(await plan([notification('n1', 'u1', 'DIGEST')])).toBe(0);
expect(db.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import type { Notification } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import type { DbClient } from '../prisma/db-client';
import { deliveryAvailableAt } from './delivery-time';
/**
* Queues the Telegram delivery of new notifications (Spec 9a.4). Runs in the
* transaction that wrote the notifications, so a rollback takes the
* deliveries with it. Only users who switched Telegram on, hold a link and
* chose the category get a row; its text is rendered at send time.
*/
@Injectable()
export class NotificationDeliveryPlanner {
/** Returns how many deliveries were queued. */
async planIn(
db: DbClient,
notifications: readonly Notification[],
): Promise<number> {
if (notifications.length === 0) {
return 0;
}
const userIds = [...new Set(notifications.map((row) => row.userId))];
const preferences = await db.notificationPreference.findMany({
where: {
userId: { in: userIds },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
select: {
userId: true,
telegramCategories: true,
digestTime: true,
quietFrom: true,
quietUntil: true,
},
});
if (preferences.length === 0) {
return 0;
}
const byUser = new Map(preferences.map((row) => [row.userId, row]));
const now = await this.databaseNow(db);
const data = notifications.flatMap((row) => {
const preference = byUser.get(row.userId);
if (!preference?.telegramCategories.includes(row.category)) {
return [];
}
return [
{
channel: 'TELEGRAM' as const,
userId: row.userId,
notificationId: row.id,
availableAt: deliveryAvailableAt(now, row.category, preference),
},
];
});
if (data.length === 0) {
return 0;
}
// A repeated call (retried handler) finds the row of its notification.
const { count } = await db.notificationDelivery.createMany({
data,
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(db, WAKE_CHANNELS.notificationDeliveries);
}
return count;
}
/** "now" once, from the database clock the claim compares against. */
private async databaseNow(db: DbClient): Promise<Date> {
const [row] = await db.$queryRaw<{ now: Date }[]>`SELECT now() AS "now"`;
return row.now;
}
}
@@ -0,0 +1,194 @@
import { berlinDateTime } from '../common/dates';
import {
DeliveryPreferences,
deliveryAvailableAt,
isInQuietHours,
} from './delivery-time';
const DEFAULTS: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
};
const NO_QUIET: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: null,
quietUntil: null,
};
const at = (day: string, hhmm: string): Date => berlinDateTime(day, hhmm);
describe('isInQuietHours', () => {
it('is false without quiet hours', () => {
expect(isInQuietHours(at('2026-10-13', '23:30'), NO_QUIET)).toBe(false);
});
it('spans midnight: 21:00 up to, not including, 06:30', () => {
expect(isInQuietHours(at('2026-10-13', '20:59'), DEFAULTS)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '21:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '23:59'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '00:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:29'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:30'), DEFAULTS)).toBe(false);
});
it('handles a window inside one day', () => {
const prefs = { ...NO_QUIET, quietFrom: '13:00', quietUntil: '15:00' };
expect(isInQuietHours(at('2026-10-13', '12:59'), prefs)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '13:00'), prefs)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '15:00'), prefs)).toBe(false);
});
it('reads the Berlin wall clock in summer and winter time', () => {
expect(isInQuietHours(at('2026-07-01', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '12:00'), DEFAULTS)).toBe(false);
});
it('treats equal bounds as no quiet hours', () => {
const prefs = { ...NO_QUIET, quietFrom: '21:00', quietUntil: '21:00' };
expect(isInQuietHours(at('2026-10-13', '22:00'), prefs)).toBe(false);
});
});
describe('deliveryAvailableAt', () => {
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', () => {
const now = at('2026-10-13', '22:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('a delivery created after midnight waits until 06:30 the same day', () => {
const now = at('2026-10-14', '02:15');
expect(deliveryAvailableAt(now, 'SYSTEM', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...NO_QUIET, digestTime: '07:15' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:16'),
);
expect(deliveryAvailableAt(now, 'DIGEST', prefs)).toEqual(
at('2026-10-13', '07:15'),
);
});
it('with default quiet hours a task due at 04:30 comes at 06:31 and the digest at 06:30', () => {
const now = at('2026-10-13', '04:30');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(
at('2026-10-13', '06:31'),
);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(
at('2026-10-13', '06:30'),
);
});
it('never goes back in time: after the digest time the category waits no longer', () => {
const now = at('2026-10-13', '09:00');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(now);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(now);
});
it('a category time that falls into quiet hours moves to the end of them', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...DEFAULTS, quietUntil: '07:00' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:00'),
);
});
it('without quiet hours a SUGGESTION is available now', () => {
const now = at('2026-10-13', '23:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
});
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
const now = at('2026-10-24', '22:00');
const result = deliveryAvailableAt(now, 'SYSTEM', DEFAULTS);
expect(result).toEqual(at('2026-10-25', '06:30'));
expect(result.toISOString()).toBe('2026-10-25T05:30:00.000Z');
});
it('returns a new Date and leaves the input untouched', () => {
const now = at('2026-10-13', '12:00');
const result = deliveryAvailableAt(now, 'SYSTEM', NO_QUIET);
expect(result).not.toBe(now);
expect(result).toEqual(now);
});
});
describe('clock changes', () => {
const quietUntil0230: DeliveryPreferences = {
digestTime: '02:30',
quietFrom: '21:00',
quietUntil: '02:30',
};
const utc = (iso: string): Date => new Date(iso);
it('spring (2026-03-29, 02:00 jumps to 03:00): 01:59 is quiet, 03:00 is not', () => {
expect(isInQuietHours(utc('2026-03-29T00:59:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-03-29T01:00:00Z'), quietUntil0230)).toBe(
false,
);
});
it('spring: an end time inside the gap becomes the instant one hour later (03:30 CEST)', () => {
const result = deliveryAvailableAt(
utc('2026-03-28T21:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-03-29T01:30:00.000Z');
});
it('autumn (2026-10-25, 03:00 falls back to 02:00): both 02:30 are outside the quiet hours, 02:29 is inside', () => {
expect(isInQuietHours(utc('2026-10-25T00:29:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-10-25T00:30:00Z'), quietUntil0230)).toBe(
false,
);
expect(isInQuietHours(utc('2026-10-25T01:30:00Z'), quietUntil0230)).toBe(
false,
);
});
it('autumn: the end of quiet hours is the FIRST 02:30 (no extra hour of waiting)', () => {
const result = deliveryAvailableAt(
utc('2026-10-24T20:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
it('autumn: the digest time 02:30 is the first occurrence as well', () => {
const prefs = { ...quietUntil0230, quietFrom: null, quietUntil: null };
const result = deliveryAvailableAt(
utc('2026-10-24T22:10:00Z'),
'DIGEST',
prefs,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
});
@@ -0,0 +1,110 @@
import type { NotificationCategory } from '../../generated/prisma';
import { addDays, berlinDateTime, berlinDayOf } from '../common/dates';
export interface DeliveryPreferences {
/** HH:MM, Europe/Berlin. */
digestTime: string;
/** HH:MM; null together with quietUntil: no quiet hours. */
quietFrom: string | null;
quietUntil: string | null;
}
const ONE_MINUTE_MS = 60_000;
const ONE_HOUR_MS = 3_600_000;
const berlinClock = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Berlin',
hourCycle: 'h23',
hour: '2-digit',
minute: '2-digit',
});
/** The Berlin wall clock of an instant as HH:MM (compares as text). */
function berlinClockOf(instant: Date): string {
return berlinClock.format(instant);
}
interface QuietWindow {
from: string;
until: string;
}
/** Equal bounds or a missing bound mean: no quiet hours. */
function quietWindow(prefs: DeliveryPreferences): QuietWindow | null {
const { quietFrom: from, quietUntil: until } = prefs;
if (from === null || until === null || from === until) {
return null;
}
return { from, until };
}
/** Quiet hours may span midnight (21:00-06:30); the end is exclusive. */
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean {
const window = quietWindow(prefs);
if (window === null) {
return false;
}
const time = berlinClockOf(now);
return window.from < window.until
? time >= window.from && time < window.until
: time >= window.from || time < window.until;
}
/**
* The instant the Berlin clock shows `hhmm` on `day`. A wall time that
* happens twice (autumn change) is taken at its FIRST occurrence; one inside
* the spring gap becomes the instant one hour later (02:30 -> 03:30 CEST).
*/
function berlinWallTime(day: string, hhmm: string): Date {
const instant = berlinDateTime(day, hhmm);
const earlier = new Date(instant.getTime() - ONE_HOUR_MS);
return berlinClockOf(earlier) === hhmm && berlinDayOf(earlier) === day
? earlier
: instant;
}
/** The next instant after `instant` at which the Berlin clock shows `hhmm`. */
function nextBerlinTime(instant: Date, hhmm: string): Date {
const day = berlinDayOf(instant);
const today = berlinWallTime(day, hhmm);
return today.getTime() > instant.getTime()
? today
: berlinWallTime(addDays(day, 1), hhmm);
}
/** TASK_DUE follows the morning overview by a minute; the digest is the overview. */
function categoryTime(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date | null {
if (category !== 'TASK_DUE' && category !== 'DIGEST') {
return null;
}
const digest = berlinWallTime(berlinDayOf(now), prefs.digestTime);
return category === 'TASK_DUE'
? new Date(digest.getTime() + ONE_MINUTE_MS)
: digest;
}
/**
* When a Telegram delivery may go out (Spec 9a.4): not before `now`, not
* before the category's time of day (TASK_DUE: one minute after the digest
* time, DIGEST: the digest time), and not inside quiet hours - an instant
* inside them moves to their end.
*/
export function deliveryAvailableAt(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date {
const categoryAt = categoryTime(now, category, prefs);
const earliest = new Date(
Math.max(now.getTime(), categoryAt?.getTime() ?? now.getTime()),
);
const window = quietWindow(prefs);
if (window === null || !isInQuietHours(earliest, prefs)) {
return earliest;
}
return nextBerlinTime(earliest, window.until);
}
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller';
@@ -6,7 +7,15 @@ import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService, NotificationPreferencesService],
providers: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
exports: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
})
export class NotificationsModule {}
@@ -3,6 +3,7 @@ import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
type CreateManyArg = {
data: {
@@ -19,6 +20,7 @@ type CreateManyArg = {
describe('NotificationsService', () => {
let service: NotificationsService;
let held: Set<string>;
let planner: { planIn: jest.Mock };
let prisma: {
notification: {
createManyAndReturn: jest.Mock;
@@ -28,9 +30,12 @@ describe('NotificationsService', () => {
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
};
beforeEach(() => {
planner = { planIn: jest.fn().mockResolvedValue(0) };
prisma = {
notification: {
createManyAndReturn: jest
@@ -44,7 +49,13 @@ describe('NotificationsService', () => {
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
Promise.resolve(work(prisma)),
);
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
@@ -53,6 +64,7 @@ describe('NotificationsService', () => {
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
planner as unknown as NotificationDeliveryPlanner,
);
});
@@ -155,6 +167,64 @@ describe('NotificationsService', () => {
});
});
describe('Telegram delivery planning', () => {
const base = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: 'm',
};
it('plans the deliveries of the inserted rows right after the insert', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
});
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record({ userId: 'u1', ...base }, tx as never);
expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('without a caller transaction, rows and deliveries share one of their own', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.record({ userId: 'u1', ...base });
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledTimes(1);
});
it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' });
expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []);
});
it('plans nothing when no recipient is eligible', async () => {
await service.recordForUsers(['u1'], base);
expect(planner.planIn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
const input = {
priority: 'WARNUNG' as const,
@@ -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);
}
}