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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user