feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -31,6 +31,7 @@ describe('NotificationPreferencesService', () => {
|
||||
};
|
||||
let prisma: {
|
||||
notificationPreference: { upsert: jest.Mock };
|
||||
telegramLink: { findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
const order: string[] = [];
|
||||
@@ -62,6 +63,7 @@ describe('NotificationPreferencesService', () => {
|
||||
notificationPreference: {
|
||||
upsert: jest.fn().mockResolvedValue(stored),
|
||||
},
|
||||
telegramLink: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((work: (t: unknown) => Promise<unknown>) =>
|
||||
@@ -180,4 +182,75 @@ describe('NotificationPreferencesService', () => {
|
||||
expect(arg.data).not.toHaveProperty('telegramEnabled');
|
||||
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
|
||||
});
|
||||
describe('toView (Telegram status)', () => {
|
||||
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUsername === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
|
||||
}
|
||||
if (originalToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = '@apicore_test_bot';
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
const view = await service.toView(stored as never);
|
||||
|
||||
expect(view.telegram).toEqual({
|
||||
configured: true,
|
||||
botUsername: 'apicore_test_bot',
|
||||
linked: false,
|
||||
firstName: null,
|
||||
linkedAt: null,
|
||||
enabled: false,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toMatch(/token/i);
|
||||
});
|
||||
|
||||
it('is not configured without a bot username, even with a token', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = '';
|
||||
process.env.TELEGRAM_BOT_TOKEN =
|
||||
'123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
const view = await service.toView(stored as never);
|
||||
|
||||
expect(view.telegram.configured).toBe(false);
|
||||
expect(view.telegram.botUsername).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the link without the chat id', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
|
||||
prisma.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: 4_711_000_001n,
|
||||
telegramFirstName: 'Anna',
|
||||
linkedAt: new Date('2026-10-14T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
const view = await service.toView({
|
||||
...stored,
|
||||
telegramEnabled: true,
|
||||
} as never);
|
||||
|
||||
expect(view.telegram).toMatchObject({
|
||||
linked: true,
|
||||
firstName: 'Anna',
|
||||
linkedAt: '2026-10-14T08:00:00.000Z',
|
||||
enabled: true,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain('4711000001');
|
||||
expect(prisma.telegramLink.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
NotificationCategory,
|
||||
NotificationPreference,
|
||||
TelegramLink,
|
||||
} from '../../generated/prisma';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { telegramConfig } from '../telegram/telegram-config';
|
||||
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
|
||||
|
||||
/** The digest time the owner may choose, Europe/Berlin (inclusive). */
|
||||
@@ -16,6 +18,19 @@ const DIGEST_TIME_MESSAGE =
|
||||
const QUIET_PAIR_MESSAGE =
|
||||
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
|
||||
|
||||
/**
|
||||
* Telegram as the API sees it. "configured" means the bot name is known
|
||||
* (the API pod never holds the bot token); the chat id never leaves the server.
|
||||
*/
|
||||
export interface TelegramStatusView {
|
||||
configured: boolean;
|
||||
botUsername: string | null;
|
||||
linked: boolean;
|
||||
firstName: string | null;
|
||||
linkedAt: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationSettingsView {
|
||||
taskDueEnabled: boolean;
|
||||
digestEnabled: boolean;
|
||||
@@ -24,10 +39,27 @@ export interface NotificationSettingsView {
|
||||
quietUntil: string | null;
|
||||
telegramCategories: NotificationCategory[];
|
||||
telegramDetail: boolean;
|
||||
telegram: TelegramStatusView;
|
||||
}
|
||||
|
||||
function toTelegramStatus(
|
||||
preference: NotificationPreference,
|
||||
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
|
||||
): TelegramStatusView {
|
||||
const { botUsername } = telegramConfig();
|
||||
return {
|
||||
configured: botUsername !== null,
|
||||
botUsername,
|
||||
linked: link !== null,
|
||||
firstName: link?.telegramFirstName ?? null,
|
||||
linkedAt: link?.linkedAt.toISOString() ?? null,
|
||||
enabled: preference.telegramEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function toNotificationSettingsView(
|
||||
preference: NotificationPreference,
|
||||
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
|
||||
): NotificationSettingsView {
|
||||
return {
|
||||
taskDueEnabled: preference.taskDueEnabled,
|
||||
@@ -37,6 +69,7 @@ export function toNotificationSettingsView(
|
||||
quietUntil: preference.quietUntil,
|
||||
telegramCategories: preference.telegramCategories,
|
||||
telegramDetail: preference.telegramDetail,
|
||||
telegram: toTelegramStatus(preference, link),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +96,16 @@ export class NotificationPreferencesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The settings as the API returns them, with the Telegram status. */
|
||||
async toView(
|
||||
preference: NotificationPreference,
|
||||
): Promise<NotificationSettingsView> {
|
||||
const link = await this.prisma.telegramLink.findUnique({
|
||||
where: { userId: preference.userId },
|
||||
});
|
||||
return toNotificationSettingsView(preference, link);
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
dto: UpdateNotificationSettingsDto,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { UpdateNotificationSettingsDto } from './dto/update-notification-setting
|
||||
import {
|
||||
NotificationPreferencesService,
|
||||
NotificationSettingsView,
|
||||
toNotificationSettingsView,
|
||||
} from './notification-preferences.service';
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,7 @@ export class NotificationSettingsController {
|
||||
async get(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<NotificationSettingsView> {
|
||||
return toNotificationSettingsView(
|
||||
return this.preferences.toView(
|
||||
await this.preferences.getOrCreate(actor.id),
|
||||
);
|
||||
}
|
||||
@@ -35,7 +34,7 @@ export class NotificationSettingsController {
|
||||
@Body() dto: UpdateNotificationSettingsDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<NotificationSettingsView> {
|
||||
return toNotificationSettingsView(
|
||||
return this.preferences.toView(
|
||||
await this.preferences.update(actor.id, dto),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user