fix: T4 browser-check findings, interval on season state, T6 plan refresh

- Honey batch create omits an empty batch number (was 400); product form
  labels the legacy total weight, detail shows jar weight and shelf life;
  bottling hints next to their fields, values cleared on product change,
  post-save water-content wording; withdraw reason placeholder not
  selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
  consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
  dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
  nicht."); backup import gives its disabled reason; missing event labels
  plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
  inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
  2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
  without step-up).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 12:47:08 +02:00
1 parent 9760ce154f
commit 7a1480138d
63 files changed
+1076 -251

No files matched your search

+1 -1
View File
@@ -606,7 +606,7 @@ Stand: Schema, Wetterquelle, Berechnung, Job-Schritt, die Meldungs-API (Melden,
**Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9). **Meldungen** (`PhenologyModule`, Annahme 47). Routen: `GET /phenology/state` (`apiaries:read`; je aktivem Stand Zustand ohne Koordinaten, `reportedOn` nur bei Quelle `REPORT` der früheste Meldetag der geltenden Phase), `GET /phenology/reports?year=` (`phenology:read`, Standard laufendes Jahr, auch zurückgezogene mit `revokedAt`, Stand- und Nutzername), `POST /phenology/reports` (`phenology:create`, `@Idempotent()`; Phase, Datum `JJJJ-MM-TT`, `apiaryId` oder `null` für alle, Notiz höchstens 500 Zeichen), `PATCH /phenology/reports/:id` (`phenology:update`; `null` auf Phase oder Datum: 400, auf Stand oder Notiz: leert), `POST /phenology/reports/:id/revoke` (`phenology:update`, `@Idempotent()`, schon zurückgezogen: 409). Datum in der Zukunft: 400 „Das Datum liegt in der Zukunft.“, ungültiger Tag: 400 „Das Datum ist ungültig.“, unbekannter Stand: 404 „Stand nicht gefunden.“, Bearbeiten einer zurückgezogenen Meldung: 409 „Die Meldung wurde schon zurückgezogen.“. Jede Änderung ist eine Transaktion aus Sperre auf die Meldung (`lockRowForUpdate`, danach „vorher“ neu lesen), Schreiben, Audit-Eintrag mit Vorher-Wert und Ereignis; Handler `phenology.recompute` und `phenology.recompute-apiary` (Abschnitt 10) berechnen danach die Stände neu. Saisonwechsel erscheinen in der Morgenübersicht (Abschnitt 11.9).
**Oberfläche** (Seite eines Standes). Der Kopf `ApiarySeason` zeigt Saisonabschnitt (`SeasonChip`), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) stammt aus `GET /autopilot/today` (`inspectionDue[].intervalDays`) und fehlt für Stände ohne fällige Durchsicht oder ohne Recht; ein Feld `intervalDays` an `GET /phenology/state` würde es exakt machen (offen, Nachfolgearbeit). Der Knopf „Pflanze blüht“ (`phenology:create`) öffnet das Blatt „Entwicklung melden“ (`PhenologyReportSheet`): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur <Stand>“. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem `Idempotency-Key`. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (`phenology:read`; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (`phenology:update`, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den `ConfirmDialog` nach. Offline landet die Meldung im IndexedDB-Speicher `pending-phenology-reports` (`apicore-offline`, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (`POST /phenology/reports`, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales `invalidateQueries()` aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55. **Oberfläche** (Seite eines Standes). Der Kopf `ApiarySeason` zeigt Saisonabschnitt (`SeasonChip`), Phase und Quelle („gemeldet am TT.MM.“, „geschätzt“, „Kalender“) und bei schwacher Wetterbasis einen Hinweis. Das Intervall im Chip („alle 7 Tage“) kommt aus `GET /phenology/state` (`intervalDays` je Stand): das jetzt geltende Durchsichts-Intervall des Segments aus der Regel `INSPECTION_INTERVAL`, über dieselbe Funktion `inspectionIntervalOf` (`src/season/inspection-due.ts`) wie `inspectionDue[].intervalDays` und der Digest. Es ist `null` in der Winterruhe und bei inaktiver Regel; dann zeigt der Chip nur die Saison. Der Knopf „Pflanze blüht“ (`phenology:create`) öffnet das Blatt „Entwicklung melden“ (`PhenologyReportSheet`): acht Zeilen (Icon und Wort, 56 px), ein Tipp meldet die Phase für heute (Berliner Tag), vorgewählt „Für alle Stände“, wahlweise „Nur <Stand>“. Jede Phase und jeder Bereich ist eine eigene Meldung mit eigenem `Idempotency-Key`. Ein Serverfehler bleibt im Blatt sichtbar; während einer Meldung oder Rücknahme lässt es sich nicht schließen. Darunter stehen die Meldungen des Jahres (`phenology:read`; eigener Stand und „alle Stände“, zurückgezogene durchgestrichen) mit „Zurückziehen“ (`phenology:update`, nur online, sonst „Nur mit Verbindung“); das Zurückziehen einer Meldung für alle Stände fragt über den `ConfirmDialog` nach. Offline landet die Meldung im IndexedDB-Speicher `pending-phenology-reports` (`apicore-offline`, Version 4; gleiche Form wie die übrigen Einzel-Warteschlangen mit Schlüssel und Besitzer; Anzeige „Pflanzenmeldung“). Der Sync sendet ihn mit den anderen Warteschlangen (`POST /phenology/reports`, originaler Schlüssel; abgelehnte Einträge bleiben mit der Antwort des Servers stehen). Danach lösen Offline-Band und Warteschlangen-Sheet ein globales `invalidateQueries()` aus, sodass Saisonzustand und Meldungsliste neu laden. Eine Meldung für einen früheren Tag bietet die Oberfläche nicht an (nur die API per PATCH). Dass ein erneutes Öffnen des Blatts dieselbe Phase doppelt melden kann, ist Annahme 55.
### 11.12 Honig-Kette und Bewegungsbuch ### 11.12 Honig-Kette und Bewegungsbuch
@@ -0,0 +1,61 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { ReauthService } from './reauth.service';
describe('ReauthService', () => {
const baseUser = {
id: 'u1',
email: '[email protected]',
passwordHash: bcrypt.hashSync('richtig', 4),
mfaEnabled: false,
mfaSecret: null,
mfaLastTimeStep: null,
authLockedUntil: null,
isActive: true,
};
const accountGuard = {
isLocked: jest.fn(),
recordFailure: jest.fn(),
recordSuccess: jest.fn(),
consumeTotp: jest.fn(),
};
const prisma = { user: { findUnique: jest.fn() } };
const service = new ReauthService(prisma as never, accountGuard as never);
beforeEach(() => {
jest.resetAllMocks();
prisma.user.findUnique.mockResolvedValue(baseUser);
accountGuard.isLocked.mockReturnValue(false);
});
it('sagt bei falschem Passwort, dass es nicht stimmt', async () => {
await expect(service.verify('u1', { password: 'falsch' })).rejects.toThrow(
new UnauthorizedException('Das Passwort stimmt nicht.'),
);
expect(accountGuard.recordFailure).toHaveBeenCalledWith('u1');
});
it('sagt bei falschem Code, dass er nicht stimmt', async () => {
prisma.user.findUnique.mockResolvedValue({ ...baseUser, mfaEnabled: true });
accountGuard.consumeTotp.mockResolvedValue(false);
await expect(
service.verify('u1', { password: 'richtig', mfaCode: '000000' }),
).rejects.toThrow(new UnauthorizedException('Der Code stimmt nicht.'));
});
it('nennt bei gesperrtem Konto die Sperre', async () => {
accountGuard.isLocked.mockReturnValue(true);
await expect(service.verify('u1', { password: 'richtig' })).rejects.toThrow(
new UnauthorizedException(
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
),
);
});
it('lässt richtige Angaben durch', async () => {
await expect(
service.verify('u1', { password: 'richtig' }),
).resolves.toBeUndefined();
expect(accountGuard.recordSuccess).toHaveBeenCalledWith('u1');
});
});
@@ -37,7 +37,7 @@ export class ReauthService {
}); });
if (!user || !user.isActive || this.accountGuard.isLocked(user)) { if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
throw new UnauthorizedException( throw new UnauthorizedException(
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.', 'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
); );
} }
const passwordMatches = const passwordMatches =
@@ -45,9 +45,7 @@ export class ReauthService {
(await bcrypt.compare(proof.password, user.passwordHash)); (await bcrypt.compare(proof.password, user.passwordHash));
if (!passwordMatches) { if (!passwordMatches) {
await this.accountGuard.recordFailure(user.id); await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException('Das Passwort stimmt nicht.');
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
);
} }
if (user.mfaEnabled) { if (user.mfaEnabled) {
const codeMatches = const codeMatches =
@@ -55,9 +53,7 @@ export class ReauthService {
(await this.accountGuard.consumeTotp(user, proof.mfaCode)); (await this.accountGuard.consumeTotp(user, proof.mfaCode));
if (!codeMatches) { if (!codeMatches) {
await this.accountGuard.recordFailure(user.id); await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException('Der Code stimmt nicht.');
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
);
} }
} }
await this.accountGuard.recordSuccess(user.id); await this.accountGuard.recordSuccess(user.id);
@@ -16,7 +16,10 @@ import {
} from '../common/dates'; } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service'; import { SeasonService } from '../season/season.service';
import { inspectionDueOn } from '../season/inspection-due'; import {
inspectionDueOn,
inspectionIntervalOf,
} from '../season/inspection-due';
import type { SeasonSegmentKey, SeasonState } from '../season/season-types'; import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
import { buildEvidenceLookup } from '../suggestions/evidence-lookup'; import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
import { renderSuggestion } from '../suggestions/suggestion-texts'; import { renderSuggestion } from '../suggestions/suggestion-texts';
@@ -311,10 +314,7 @@ export class NextStepsService {
apiaryId: season.apiaryId, apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId), apiaryName: nameOf(season.apiaryId),
segment: season.segment, segment: season.segment,
intervalDays: intervalDays: inspectionIntervalOf(season.segment, intervalDays),
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due, colonies: due,
}, },
]; ];
@@ -66,6 +66,7 @@ describe('PhenologyService', () => {
$transaction: jest.Mock; $transaction: jest.Mock;
apiary: { findUnique: jest.Mock; findMany: jest.Mock }; apiary: { findUnique: jest.Mock; findMany: jest.Mock };
phenologyReport: { findMany: jest.Mock }; phenologyReport: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
}; };
let audit: { record: jest.Mock }; let audit: { record: jest.Mock };
let events: { record: jest.Mock }; let events: { record: jest.Mock };
@@ -100,6 +101,8 @@ describe('PhenologyService', () => {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
}, },
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) }, phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
// No row: the built-in interval setting applies (SCHWARMZEIT 7 days).
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
}; };
audit = { record: jest.fn() }; audit = { record: jest.fn() };
events = { record: jest.fn() }; events = { record: jest.fn() };
@@ -436,11 +439,44 @@ describe('PhenologyService', () => {
gts: 210.5, gts: 210.5,
gtsThroughDay: '2026-03-31', gtsThroughDay: '2026-03-31',
note: null, note: null,
intervalDays: 14,
}, },
]); ]);
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/); expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
}); });
it('intervalDays is the interval of the apiary segment from the rule', async () => {
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Hang' },
{ id: 'a2', name: 'Wald' },
]);
season.statesFor.mockResolvedValue(
new Map([
['a1', seasonState({ segment: 'SCHWARMZEIT' })],
['a2', seasonState({ apiaryId: 'a2', segment: 'WINTERRUHE' })],
]),
);
const states = await service.state();
expect(states.map((state) => state.intervalDays)).toEqual([7, null]);
});
it('intervalDays is null while the interval rule is inactive', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-1',
active: false,
config: { intervalDays: { FRUEHJAHR: 10 } },
actionTemplate: null,
});
const [state] = await service.state();
expect(state.intervalDays).toBeNull();
});
it('reportedOn is null unless the phase comes from a report', async () => { it('reportedOn is null unless the phase comes from a report', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]); prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue( season.statesFor.mockResolvedValue(
@@ -11,6 +11,7 @@ import type {
Prisma, Prisma,
SeasonSegment, SeasonSegment,
} from '../../generated/prisma'; } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates'; import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
@@ -18,6 +19,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock'; import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
import type { SeasonNote } from '../season/season-types'; import type { SeasonNote } from '../season/season-types';
import { inspectionIntervalOf } from '../season/inspection-due';
import { SeasonService } from '../season/season.service'; import { SeasonService } from '../season/season.service';
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto'; import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto'; import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
@@ -52,6 +54,8 @@ export interface PhenologyApiaryState {
gts: number | null; gts: number | null;
gtsThroughDay: string | null; gtsThroughDay: string | null;
note: SeasonNote; note: SeasonNote;
/** The inspection interval in force now (days); null in winter or without the rule. */
intervalDays: number | null;
} }
/** A report day must exist in the calendar and must not lie after today. */ /** A report day must exist in the calendar and must not lie after today. */
@@ -267,6 +271,7 @@ export class PhenologyService {
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) }, where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
select: { apiaryId: true, phase: true, date: true }, select: { apiaryId: true, phase: true, date: true },
}); });
const interval = await readRule(this.prisma, 'INSPECTION_INTERVAL');
return apiaries.flatMap((apiary) => { return apiaries.flatMap((apiary) => {
const state = states.get(apiary.id); const state = states.get(apiary.id);
if (state === undefined) { if (state === undefined) {
@@ -294,6 +299,11 @@ export class PhenologyService {
gts: state.gts, gts: state.gts,
gtsThroughDay: state.gtsThroughDay, gtsThroughDay: state.gtsThroughDay,
note: state.note, note: state.note,
intervalDays: inspectionIntervalOf(
state.segment,
interval.config.intervalDays,
interval.active,
),
}, },
]; ];
}); });
@@ -12,6 +12,7 @@ import { loggableError } from '../common/loggable-error';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { inspectionIntervalOf } from '../season/inspection-due';
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types'; import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
import { JobPartialFailure } from '../system/job-run.service'; import { JobPartialFailure } from '../system/job-run.service';
@@ -378,10 +379,11 @@ export class DigestService {
.map((net) => ({ .map((net) => ({
apiaryName: names.get(net.apiaryId) ?? '', apiaryName: names.get(net.apiaryId) ?? '',
segment: net.segment, segment: net.segment,
intervalDays: intervalDays: inspectionIntervalOf(
net.segment === 'WINTERRUHE' || !interval.active net.segment,
? null interval.config.intervalDays,
: interval.config.intervalDays[net.segment], interval.active,
),
isReturn: net.isReturn, isReturn: net.isReturn,
})) }))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')); .sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
@@ -1,5 +1,5 @@
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config'; import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
import { inspectionDueOn } from './inspection-due'; import { inspectionDueOn, inspectionIntervalOf } from './inspection-due';
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG; const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
const SCHWARMZEIT = { const SCHWARMZEIT = {
@@ -71,3 +71,19 @@ describe('inspectionDueOn', () => {
), ),
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 })); ).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
}); });
describe('inspectionIntervalOf', () => {
const days = CONFIG.intervalDays;
it('is the configured interval of the segment', () => {
expect(inspectionIntervalOf('SCHWARMZEIT', days)).toBe(7);
expect(inspectionIntervalOf('SOMMER', days)).toBe(14);
expect(inspectionIntervalOf('HERBST', days)).toBe(21);
});
it('is null in WINTERRUHE', () =>
expect(inspectionIntervalOf('WINTERRUHE', days)).toBeNull());
it('is null while the rule is inactive', () =>
expect(inspectionIntervalOf('SCHWARMZEIT', days, false)).toBeNull());
});
+19 -3
View File
@@ -1,6 +1,6 @@
import { addDays } from '../common/dates'; import { addDays } from '../common/dates';
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types'; import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
import type { SeasonState } from './season-types'; import type { SeasonSegmentKey, SeasonState } from './season-types';
export interface InspectionDue { export interface InspectionDue {
dueOn: string; dueOn: string;
@@ -9,6 +9,19 @@ export interface InspectionDue {
intervalDays: number; intervalDays: number;
} }
/**
* The inspection interval in force for a segment: null in WINTERRUHE and
* while the rule is inactive. The one source of the interval shown to users
* (autopilot, digest, season state).
*/
export function inspectionIntervalOf(
segment: SeasonSegmentKey,
intervalDays: InspectionIntervalConfig['intervalDays'],
isActive = true,
): number | null {
return segment === 'WINTERRUHE' || !isActive ? null : intervalDays[segment];
}
/** /**
* The day an inspection is due: the last inspection (else the creation of * The day an inspection is due: the last inspection (else the creation of
* the colony) plus the interval of today's segment. A colony not inspected * the colony) plus the interval of today's segment. A colony not inspected
@@ -19,10 +32,13 @@ export function inspectionDueOn(
season: Pick<SeasonState, 'segment' | 'springSince'>, season: Pick<SeasonState, 'segment' | 'springSince'>,
config: InspectionIntervalConfig, config: InspectionIntervalConfig,
): InspectionDue | null { ): InspectionDue | null {
if (season.segment === 'WINTERRUHE') { const intervalDays = inspectionIntervalOf(
season.segment,
config.intervalDays,
);
if (intervalDays === null) {
return null; return null;
} }
const intervalDays = config.intervalDays[season.segment];
const base = input.lastInspectedOn ?? input.createdOn; const base = input.lastInspectedOn ?? input.createdOn;
if (season.springSince !== null && base < season.springSince) { if (season.springSince !== null && base < season.springSince) {
return { dueOn: season.springSince, firstOfYear: true, intervalDays }; return { dueOn: season.springSince, firstOfYear: true, intervalDays };
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { readRule } from '../src/automation/rule-config';
import { PhenologyStateService } from '../src/phenology/phenology-state.service'; import { PhenologyStateService } from '../src/phenology/phenology-state.service';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api'; import { login } from './utils/api';
@@ -23,6 +24,7 @@ interface StateBody {
segment: string; segment: string;
phaseSource: string | null; phaseSource: string | null;
reportedOn: string | null; reportedOn: string | null;
intervalDays: number | null;
} }
/** /**
@@ -256,11 +258,14 @@ describe('Phenology reports (e2e)', () => {
.set(auth()) .set(auth())
.expect(200) .expect(200)
).body as StateBody[]; ).body as StateBody[];
// The interval of the rule in force, the one the autopilot uses.
const rule = await readRule(prisma, 'INSPECTION_INTERVAL');
expect(states.find((state) => state.apiaryId === first)).toMatchObject({ expect(states.find((state) => state.apiaryId === first)).toMatchObject({
phase: 'SALWEIDE', phase: 'SALWEIDE',
segment: 'FRUEHJAHR', segment: 'FRUEHJAHR',
phaseSource: 'REPORT', phaseSource: 'REPORT',
reportedOn: TODAY, reportedOn: TODAY,
intervalDays: rule.active ? rule.config.intervalDays.FRUEHJAHR : null,
}); });
} }
@@ -0,0 +1,17 @@
import { Info } from "lucide-react";
/**
* "Weicht vom Artikel ab", right under the field it is about. Plain text the
* field points at with aria-describedby - not a live region, the one live
* region of the form stays at the top.
*/
export function DifferenceHint({ id, message }: { id: string; message: string | null }) {
if (!message) return null;
return (
<p id={id} className="flex items-start gap-2 text-sm">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="font-medium">Hinweis</span>
<span>{message}</span>
</p>
);
}
@@ -276,6 +276,53 @@ describe("NewBottlingBatchPage", () => {
expect(screen.getByText("Hinweis")).toBeInTheDocument(); expect(screen.getByText("Hinweis")).toBeInTheDocument();
}); });
it("shows the differs hint under its own field, outside every live region", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2029-01-01" },
});
const jarHint = screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g).");
const bbdHint = screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028).");
expect(screen.getByLabelText(/Glasgröße/).getAttribute("aria-describedby")).toContain(
jarHint.closest("p")!.id,
);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/).getAttribute("aria-describedby")).toContain(
bbdHint.closest("p")!.id,
);
expect(jarHint.closest("[aria-live]")).toBeNull();
expect(bbdHint.closest("[aria-live]")).toBeNull();
expect(screen.getAllByText(/weicht vom Artikel ab/)).toHaveLength(2);
});
it("clears jar size and best before typed for the previous product when the new one has no values", async () => {
vi.mocked(fetchProducts).mockResolvedValue([
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
{ id: "p-3", name: "Honig ohne Angaben", kind: "BOTTLED_HONEY" },
] as never);
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "300");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2027-01-31" },
});
await chooseProduct("Honig ohne Angaben");
await screen.findByText(/Am Artikel ist keine Haltbarkeit hinterlegt/);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("");
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
});
it("fills jar size and best before from the product and names the source", async () => { it("fills jar size and best before from the product and names the source", async () => {
renderPage(); renderPage();
await chooseProduct(); await chooseProduct();
@@ -343,7 +390,7 @@ describe("NewBottlingBatchPage", () => {
renderPage(); renderPage();
await fillAndSubmit(); await fillAndSubmit();
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent( expect(dialog).toHaveTextContent(
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?", "Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
); );
@@ -364,15 +411,39 @@ describe("NewBottlingBatchPage", () => {
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1")); await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
}); });
it("words the toast after the confirmed save as a fact, not as the pre-save text", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(22))
.mockResolvedValueOnce({
id: "b-1",
warnings: [
{
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Der Wassergehalt (22 %) liegt über der gesetzlichen Grenze von 20 %. Abfüllen nur mit Bestätigung.",
refs: [],
},
],
} as never);
renderPage();
await fillAndSubmit();
await userEvent.click(await screen.findByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
const options = vi.mocked(toast.warning).mock.calls[0][1] as { description: string };
expect(options.description).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
expect(options.description).not.toMatch(/nur mit Bestätigung/);
});
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => { it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5)); vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
renderPage(); renderPage();
await fillAndSubmit(); await fillAndSubmit();
await screen.findByRole("dialog"); await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" })); await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
expect(createBottlingBatch).toHaveBeenCalledTimes(1); expect(createBottlingBatch).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24); expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
expect(push).not.toHaveBeenCalled(); expect(push).not.toHaveBeenCalled();
@@ -388,11 +459,11 @@ describe("NewBottlingBatchPage", () => {
.mockResolvedValueOnce({ id: "b-1" } as never); .mockResolvedValueOnce({ id: "b-1" } as never);
renderPage(); renderPage();
await fillAndSubmit(); await fillAndSubmit();
await screen.findByRole("dialog"); await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" })); await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("dialog")).toHaveTextContent( expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Wassergehalt 21 % über der gesetzlichen Grenze.", "Wassergehalt 21 % über der gesetzlichen Grenze.",
), ),
); );
@@ -524,11 +595,11 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24"); await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01"); await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" })); await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" })); await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch.")); await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("alertdialog")).toBeInTheDocument();
}); });
it("a double click on Trotzdem abfüllen sends exactly one request", async () => { it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
@@ -538,12 +609,12 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24"); await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01"); await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" })); await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await screen.findByRole("dialog"); await screen.findByRole("alertdialog");
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" })); await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
expect(createBottlingBatch).toHaveBeenCalledTimes(2); expect(createBottlingBatch).toHaveBeenCalledTimes(2);
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("alertdialog")).toBeInTheDocument();
}); });
it("Tab stays inside the dialog", async () => { it("Tab stays inside the dialog", async () => {
@@ -552,7 +623,7 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24"); await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01"); await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" })); await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
const outside = [ const outside = [
screen.getByLabelText(/Anzahl Gläser/), screen.getByLabelText(/Anzahl Gläser/),
@@ -578,7 +649,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined} onCancel={() => undefined}
/>, />,
); );
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %"); expect(screen.getByRole("alertdialog")).toHaveTextContent("Wassergehalt 20,5 %");
rerender( rerender(
<WaterContentConfirmDialog <WaterContentConfirmDialog
@@ -590,7 +661,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined} onCancel={() => undefined}
/>, />,
); );
const closing = screen.queryByRole("dialog"); const closing = screen.queryByRole("alertdialog");
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %"); if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
}); });
@@ -9,8 +9,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api"; import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types"; import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
import { postSaveWarnings } from "@/lib/bottling/post-save-warnings";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { PrefillStatus } from "@/components/warnings/prefill-status"; import { PrefillStatus } from "@/components/warnings/prefill-status";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints"; import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
@@ -31,6 +31,7 @@ import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner"; import { DraftBanner } from "@/components/form/draft-banner";
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog"; import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
import { WaterContentNotes } from "./water-content-notes"; import { WaterContentNotes } from "./water-content-notes";
import { DifferenceHint } from "./difference-hint";
import { import {
BottlingFormValues, BottlingFormValues,
initialBottlingValues, initialBottlingValues,
@@ -89,6 +90,10 @@ type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
/** The prefill with the batch and product it was asked for. */ /** The prefill with the batch and product it was asked for. */
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string }; type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
function joinIds(...ids: (string | undefined)[]): string | undefined {
return ids.filter(Boolean).join(" ") || undefined;
}
function germanDay(day: string): string { function germanDay(day: string): string {
return day.split("-").reverse().join("."); return day.split("-").reverse().join(".");
} }
@@ -178,14 +183,25 @@ export default function NewBottlingBatchPage() {
key: K, key: K,
value: BottlingFormValues[K], value: BottlingFormValues[K],
) { ) {
setValues((current) => ({ ...current, [key]: value }));
if (key === "productId") { if (key === "productId") {
// A new product brings new master data; earlier typing no longer applies. // A new product brings new master data; jar size and best before typed
// for the previous product no longer apply and must not stay next to
// the new product's text (e.g. "Am Artikel ist keine Haltbarkeit ...").
const fresh = initialBottlingValues("");
setValues((current) => ({
...current,
productId: value as string,
jarSize: fresh.jarSize,
bestBeforeDate: fresh.bestBeforeDate,
}));
setManual(NO_MANUAL_EDITS); setManual(NO_MANUAL_EDITS);
setFromDraft(NO_MANUAL_EDITS); setFromDraft(NO_MANUAL_EDITS);
} else if (key === "jarSize" || key === "bestBeforeDate") { } else if (key === "jarSize" || key === "bestBeforeDate") {
setValues((current) => ({ ...current, [key]: value }));
setManual((current) => ({ ...current, [key]: true })); setManual((current) => ({ ...current, [key]: true }));
setFromDraft((current) => ({ ...current, [key]: false })); setFromDraft((current) => ({ ...current, [key]: false }));
} else {
setValues((current) => ({ ...current, [key]: value }));
} }
setDirty(true); setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) { if ((REQUIRED_KEYS as string[]).includes(key)) {
@@ -207,14 +223,17 @@ export default function NewBottlingBatchPage() {
}, },
{ idempotencyKey: idempotencyKey.current }, { idempotencyKey: idempotencyKey.current },
), ),
onSuccess: (bottlingBatch) => { onSuccess: (bottlingBatch, acknowledged) => {
draft.clear(); draft.clear();
idempotencyKey.current = newIdempotencyKey(); idempotencyKey.current = newIdempotencyKey();
lastBody.current = null; lastBody.current = null;
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] }); void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
if (bottlingBatch.warnings?.length) { if (bottlingBatch.warnings?.length) {
toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt"); toastWarnings(
postSaveWarnings(bottlingBatch.warnings, acknowledged ? confirmation : null),
"Abfüllung angelegt",
);
} else { } else {
toast.success("Abfüllung angelegt"); toast.success("Abfüllung angelegt");
} }
@@ -326,25 +345,27 @@ export default function NewBottlingBatchPage() {
: bestBeforeBase; : bestBeforeBase;
// The entered values against the product, before saving (hints, never a block). // The entered values against the product, before saving (hints, never a block).
const localNotes: PlausibilityWarning[] = []; // Shown right under the field they are about, not in the live region.
if (prefill && formValues.productId) { const jarSizeDiffers =
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) { prefill &&
localNotes.push({ formValues.productId &&
code: "JAR_SIZE_DIFFERS", prefill.jarSize != null &&
severity: "HINWEIS", values.jarSize.trim() !== "" &&
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`, Number(values.jarSize) !== prefill.jarSize
refs: [], ? `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`
}); : null;
} const bestBeforeDiffers =
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) { prefill &&
localNotes.push({ formValues.productId &&
code: "BEST_BEFORE_DIFFERS", prefill.bestBeforeDate &&
severity: "HINWEIS", values.bestBeforeDate &&
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`, values.bestBeforeDate !== prefill.bestBeforeDate
refs: [], ? `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`
}); : null;
} const shownAtField = [
} jarSizeDiffers ? "JAR_SIZE_DIFFERS" : null,
bestBeforeDiffers ? "BEST_BEFORE_DIFFERS" : null,
].filter((code): code is string => code !== null);
const prefillLoading = prefillQuery.isFetching; const prefillLoading = prefillQuery.isFetching;
const submitDescribedBy = const submitDescribedBy =
@@ -420,7 +441,7 @@ export default function NewBottlingBatchPage() {
/> />
)} )}
</Field> </Field>
<WaterContentNotes prefill={prefill} localNotes={localNotes} /> <WaterContentNotes prefill={prefill} shownAtFieldCodes={shownAtField} />
<PrefillStatus <PrefillStatus
enabled={prefillEnabled} enabled={prefillEnabled}
isLoading={prefillQuery.isLoading} isLoading={prefillQuery.isLoading}
@@ -487,19 +508,22 @@ export default function NewBottlingBatchPage() {
hint={jarSizeHint} hint={jarSizeHint}
> >
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <>
id={id} <Input
data-field="jarSize" id={id}
type="number" data-field="jarSize"
inputMode="numeric" type="number"
min={1} inputMode="numeric"
required min={1}
className="h-11" required
aria-describedby={describedBy} className="h-11"
aria-invalid={invalid} aria-describedby={joinIds(describedBy, jarSizeDiffers ? `${id}-differs` : undefined)}
value={values.jarSize} aria-invalid={invalid}
onChange={(event) => set("jarSize", event.target.value)} value={values.jarSize}
/> onChange={(event) => set("jarSize", event.target.value)}
/>
<DifferenceHint id={`${id}-differs`} message={jarSizeDiffers} />
</>
)} )}
</Field> </Field>
<Field <Field
@@ -531,19 +555,22 @@ export default function NewBottlingBatchPage() {
hint={bestBeforeHint} hint={bestBeforeHint}
> >
{({ id, describedBy, invalid }) => ( {({ id, describedBy, invalid }) => (
<Input <>
id={id} <Input
data-field="bestBeforeDate" id={id}
type="date" data-field="bestBeforeDate"
required type="date"
className="h-11" required
aria-describedby={describedBy} className="h-11"
aria-invalid={invalid} aria-describedby={joinIds(describedBy, bestBeforeDiffers ? `${id}-differs` : undefined)}
value={values.bestBeforeDate} aria-invalid={invalid}
onChange={(event) => value={values.bestBeforeDate}
set("bestBeforeDate", event.target.value) onChange={(event) =>
} set("bestBeforeDate", event.target.value)
/> }
/>
<DifferenceHint id={`${id}-differs`} message={bestBeforeDiffers} />
</>
)} )}
</Field> </Field>
</FormSection> </FormSection>
@@ -47,7 +47,7 @@ export function WaterContentConfirmDialog({
if (!open && !isPending) onCancel(); if (!open && !isPending) onCancel();
}} }}
> >
<DialogContent finalFocus={returnFocusTo}> <DialogContent role="alertdialog" finalFocus={returnFocusTo}>
<DialogHeader> <DialogHeader>
<DialogTitle>Wassergehalt zu hoch</DialogTitle> <DialogTitle>Wassergehalt zu hoch</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -2,7 +2,7 @@ import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { formatWaterContent } from "@/lib/bottling/format"; import { formatWaterContent } from "@/lib/bottling/format";
import type { BottlingPrefill } from "@/lib/bottling/types"; import type { BottlingPrefill } from "@/lib/bottling/types";
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types"; import type { WarningSeverity } from "@/lib/warnings/types";
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = { const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
HINWEIS: { label: "Hinweis", Icon: Info }, HINWEIS: { label: "Hinweis", Icon: Info },
@@ -17,17 +17,18 @@ const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentTyp
*/ */
export function WaterContentNotes({ export function WaterContentNotes({
prefill, prefill,
localNotes = [], shownAtFieldCodes = [],
}: { }: {
prefill: BottlingPrefill | undefined; prefill: BottlingPrefill | undefined;
/** Findings the form works out itself (entered value vs product), shown as hints. */ /**
localNotes?: PlausibilityWarning[]; * Codes the form shows next to their field (entered value vs product). They
* are left out here: one live region, no second announcement.
*/
shownAtFieldCodes?: string[];
}) { }) {
const localCodes = new Set(localNotes.map((note) => note.code)); const warnings = (prefill?.warnings ?? []).filter(
const warnings = [ (warning) => !shownAtFieldCodes.includes(warning.code),
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)), );
...localNotes,
];
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0; const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
return ( return (
<div role="status" aria-live="polite"> <div role="status" aria-live="polite">
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addHoneyMeasurement } from "@/lib/honey-batches/api"; import { addHoneyMeasurement } from "@/lib/honey-batches/api";
import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types"; import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -20,6 +21,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) { export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [waterContent, setWaterContent] = useState(""); const [waterContent, setWaterContent] = useState("");
const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate()); const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate());
@@ -56,8 +58,15 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
} }
return ( return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} /> <DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Neue Messung
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Honigmessung</DialogTitle> <DialogTitle>Honigmessung</DialogTitle>
@@ -107,5 +116,7 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<OnlineOnlyHint id={hintId} />
</div>
); );
} }
@@ -10,6 +10,7 @@ import {
} from "@/lib/honey-batches/api"; } from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types"; import { HoneyBatch } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api"; import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -26,6 +27,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// represent. // represent.
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) { export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [storageContainerId, setStorageContainerId] = useState(""); const [storageContainerId, setStorageContainerId] = useState("");
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -167,9 +169,16 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" variant="outline" disabled={assignMutation.isPending}> <Button
type="submit"
variant="outline"
size="touch"
disabled={assignMutation.isPending || isOffline}
aria-describedby={describedBy}
>
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"} {assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
</Button> </Button>
<OnlineOnlyHint id={hintId} />
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -10,6 +10,7 @@ import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels"; import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api"; import { fetchExtractions } from "@/lib/extractions/api";
import { fetchStorageContainers } from "@/lib/storage/api"; import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -38,6 +39,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? ""); const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? ""); const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { data: extractions } = useQuery({ const { data: extractions } = useQuery({
queryKey: ["extractions"], queryKey: ["extractions"],
queryFn: () => fetchExtractions(), queryFn: () => fetchExtractions(),
@@ -72,10 +75,11 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
} }
return ( return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger <DialogTrigger
render={ render={
<Button variant="ghost" size="sm"> <Button variant="ghost" size="touch" disabled={isOffline} aria-describedby={describedBy}>
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
Bearbeiten Bearbeiten
</Button> </Button>
@@ -174,5 +178,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<OnlineOnlyHint id={hintId} />
</div>
); );
} }
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api"; import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types"; import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api"; import { fetchStorageContainers } from "@/lib/storage/api";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -25,6 +26,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) { export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [storageContainerId, setStorageContainerId] = useState(""); const [storageContainerId, setStorageContainerId] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
@@ -72,8 +74,15 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Lagerbehälter</CardTitle> <CardTitle>Lagerbehälter</CardTitle>
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Umlagern</Button>} /> <DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Umlagern
</Button>
}
/>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Honigcharge umlagern</DialogTitle> <DialogTitle>Honigcharge umlagern</DialogTitle>
@@ -111,6 +120,8 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<OnlineOnlyHint id={hintId} />
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
<p> <p>
@@ -37,6 +37,16 @@ describe("HoneyBatchWithdrawDialog", () => {
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
}); });
it("lists the reasons but not the placeholder „Grund wählen“ as a choice", async () => {
renderDialog();
await open();
await userEvent.click(screen.getByRole("combobox"));
const placeholder = await screen.findByRole("option", { name: "Grund wählen" });
expect(placeholder).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "Hofladen" })).not.toHaveAttribute("aria-disabled", "true");
});
it("refuses more than the rest and less than a gram in words", async () => { it("refuses more than the rest and less than a gram in words", async () => {
renderDialog(); renderDialog();
const quantity = await open(); const quantity = await open();
@@ -147,6 +147,7 @@ export function HoneyBatchWithdrawDialog({
value={reason} value={reason}
onChange={setReason} onChange={setReason}
noneLabel="Grund wählen" noneLabel="Grund wählen"
noneIsPlaceholder
labels={WITHDRAWAL_REASON_LABELS} labels={WITHDRAWAL_REASON_LABELS}
/> />
</div> </div>
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
vi.mock("@/lib/honey-batches/api", () => ({
updateHoneyBatch: vi.fn(),
fetchHoneyBatchRelocations: vi.fn(async () => []),
relocateHoneyBatch: vi.fn(),
fetchHoneyBatchContainerAllocations: vi.fn(async () => []),
setHoneyBatchContainerAllocation: vi.fn(),
removeHoneyBatchContainerAllocation: vi.fn(),
addHoneyMeasurement: vi.fn(),
}));
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn(async () => []) }));
vi.mock("@/lib/storage/api", () => ({ fetchStorageContainers: vi.fn(async () => []) }));
const HONEY_BATCH = {
id: "h1",
batchNumber: "H-1",
extractionId: "x1",
honeyType: null,
amount: 20,
waterContent: null,
storageContainerId: null,
storageContainer: null,
status: "STORED",
createdAt: "2026-01-01T00:00:00.000Z",
measurements: [],
} as HoneyBatch;
const ACTIONS: { name: string; button: RegExp; ui: () => React.ReactElement }[] = [
{ name: "Bearbeiten", button: /Bearbeiten/, ui: () => <HoneyBatchEditDialog honeyBatch={HONEY_BATCH} /> },
{ name: "Umlagern", button: /^Umlagern$/, ui: () => <HoneyBatchStorageCard honeyBatch={HONEY_BATCH} /> },
{
name: "Behälter zuweisen",
button: /Behälter zuweisen/,
ui: () => <HoneyBatchContainerAllocationsCard honeyBatch={HONEY_BATCH} />,
},
{ name: "Neue Messung", button: /Neue Messung/, ui: () => <AddMeasurementDialog honeyBatchId="h1" /> },
];
function renderUi(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("online-only actions of the honey batch page", () => {
beforeEach(() => act(() => reportRequestOutcome("reached-server")));
afterEach(() => act(() => reportRequestOutcome("reached-server")));
for (const action of ACTIONS) {
it(`${action.name}: enabled and 44 px high online, no hint`, () => {
renderUi(action.ui());
const button = screen.getByRole("button", { name: action.button });
expect(button).toBeEnabled();
expect(button).toHaveClass("h-11");
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
});
it(`${action.name}: disabled offline with the visible reason „Nur mit Verbindung“`, () => {
renderUi(action.ui());
act(() => reportRequestOutcome("network-error"));
const button = screen.getByRole("button", { name: action.button });
const reason = screen.getByText("Nur mit Verbindung");
expect(button).toBeDisabled();
expect(button.getAttribute("aria-describedby")).toBe(reason.id);
expect(button).toHaveClass("h-11");
});
}
});
@@ -134,4 +134,17 @@ describe("CreateHoneyBatchDialog", () => {
expect(createHoneyBatch).toHaveBeenCalled(); expect(createHoneyBatch).toHaveBeenCalled();
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt"); expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
}); });
it("omits an empty batch number instead of sending null (the DTO rejects null)", async () => {
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1" } as never);
renderDialog();
await openAndChooseExtraction();
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
const payload = vi.mocked(createHoneyBatch).mock.calls[0][0];
expect(payload.extractionId).toBe("x1");
expect(payload.batchNumber).toBeUndefined();
expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("batchNumber");
});
}); });
@@ -86,8 +86,9 @@ export function CreateHoneyBatchDialog() {
return; return;
} }
mutation.mutate({ mutation.mutate({
// Empty: the server generates the number (automation format). // Empty: omitted, the server generates the number (automation format).
batchNumber: emptyToNull(batchNumber), // The create DTO is IsOptionalNotNull, so null would be a 400.
batchNumber: emptyToNull(batchNumber) ?? undefined,
extractionId, extractionId,
honeyType: emptyToNull(honeyType), honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number), amount: emptyToNull(amount, Number),
@@ -93,4 +93,32 @@ describe("ProductDetailPage", () => {
const link = (await screen.findByText("K-1")).closest("a"); const link = (await screen.findByText("K-1")).closest("a");
expect(link).toHaveAttribute("href", "/production-batches/b1"); expect(link).toHaveAttribute("href", "/production-batches/b1");
}); });
it("shows the jar weight and the shelf life in the details when they are set", async () => {
vi.mocked(fetchProduct).mockResolvedValue({
id: "candle",
name: "Honig 250",
description: null,
sku: null,
packagingSize: 250,
shelfLifeMonths: 18,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
renderPage();
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
});
it("hides jar weight and shelf life when they are not set", async () => {
renderPage();
await screen.findByTestId("total-remaining");
expect(screen.queryByText(/Gewicht pro Glas/)).not.toBeInTheDocument();
expect(screen.queryByText(/Haltbarkeit/)).not.toBeInTheDocument();
});
}); });
@@ -79,6 +79,12 @@ export default function ProductDetailPage() {
<p> <p>
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]} Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
</p> </p>
{product.packagingSize != null ? (
<p>Gewicht pro Glas: {product.packagingSize.toLocaleString("de-DE")} g</p>
) : null}
{product.shelfLifeMonths != null ? (
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
) : null}
{product.sku ? <p>SKU: {product.sku}</p> : null} {product.sku ? <p>SKU: {product.sku}</p> : null}
{product.salesUnit ? ( {product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p> <p>Verkaufseinheit: {product.salesUnit}</p>
@@ -36,6 +36,13 @@ async function openDialog(product: Product = PRODUCT) {
} }
describe("EditProductDialog", () => { describe("EditProductDialog", () => {
it("labels the second weight field so it cannot be mistaken for the jar weight", async () => {
await openDialog();
expect(screen.getByLabelText(/Gewicht pro Glas \(g\)/)).toBeInTheDocument();
expect(screen.getByLabelText(/Artikelgewicht gesamt \(g\)/)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Gewicht$/)).not.toBeInTheDocument();
});
beforeEach(() => { beforeEach(() => {
vi.mocked(updateProduct).mockReset(); vi.mocked(updateProduct).mockReset();
vi.mocked(updateProduct).mockResolvedValue(PRODUCT); vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
@@ -205,15 +205,20 @@ export function EditProductDialog({ product }: { product: Product }) {
) : null} ) : null}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Gewicht</Label> <Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
<Input <Input
id="product-edit-weight" id="product-edit-weight"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="0.01" step="0.01"
min={0}
aria-describedby="product-edit-weight-hint"
value={weight} value={weight}
onChange={(event) => setWeight(event.target.value)} onChange={(event) => setWeight(event.target.value)}
/> />
<p id="product-edit-weight-hint" className="text-xs text-muted-foreground">
Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas.
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="product-edit-status">Status</Label> <Label htmlFor="product-edit-status">Status</Label>
@@ -221,8 +221,8 @@ export default function NewProductPage() {
fieldCount={3} fieldCount={3}
> >
<Field <Field
label="Gewicht (g)" label="Artikelgewicht gesamt (g)"
hint="in Gramm" hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas"
> >
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <Input
@@ -35,6 +35,12 @@ describe("DangerZoneSection", () => {
logoutMock.mockResolvedValue(undefined); logoutMock.mockResolvedValue(undefined);
}); });
it("the delete button is 44 px high", () => {
renderZone();
expect(screen.getByRole("button", { name: /Konto löschen/ })).toHaveClass("h-11");
});
it("signs out and deletes the unsent entries of a deactivated account", async () => { it("signs out and deletes the unsent entries of a deactivated account", async () => {
vi.mocked(deactivateAccount).mockResolvedValue(undefined); vi.mocked(deactivateAccount).mockResolvedValue(undefined);
renderZone(); renderZone();
@@ -58,7 +58,7 @@ function DeactivateAccountDialog() {
return ( return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}> <Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="destructive" onClick={() => setOpen(true)}> <Button type="button" size="touch" variant="destructive" onClick={() => setOpen(true)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Konto löschen Konto löschen
</Button> </Button>
@@ -88,10 +88,10 @@ function DeactivateAccountDialog() {
</p> </p>
) : null} ) : null}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen Abbrechen
</Button> </Button>
<Button type="submit" variant="destructive" disabled={mutation.isPending}> <Button type="submit" size="touch" variant="destructive" disabled={mutation.isPending}>
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"} {mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -85,7 +85,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
return ( return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}> <Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" onClick={handleTrigger}> <Button type="button" size="touch" onClick={handleTrigger}>
Einrichten Einrichten
</Button> </Button>
<DialogContent> <DialogContent>
@@ -134,10 +134,10 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
</p> </p>
) : null} ) : null}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen Abbrechen
</Button> </Button>
<Button type="submit" disabled={confirmMutation.isPending}> <Button type="submit" size="touch" disabled={confirmMutation.isPending}>
{confirmMutation.isPending ? "Prüfe…" : "Bestätigen"} {confirmMutation.isPending ? "Prüfe…" : "Bestätigen"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -183,7 +183,7 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
return ( return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}> <Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="outline" onClick={() => setOpen(true)}> <Button type="button" size="touch" variant="outline" onClick={() => setOpen(true)}>
Deaktivieren Deaktivieren
</Button> </Button>
<DialogContent> <DialogContent>
@@ -224,10 +224,10 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
</p> </p>
) : null} ) : null}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen Abbrechen
</Button> </Button>
<Button type="submit" variant="destructive" disabled={disableMutation.isPending}> <Button type="submit" size="touch" variant="destructive" disabled={disableMutation.isPending}>
{disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"} {disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -92,4 +92,12 @@ describe("ProfilePage", () => {
expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file); expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file);
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled()); await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
}); });
it("keeps the profile buttons at 44 px (touch size)", async () => {
renderWithClient(<ProfilePage />);
for (const name of ["Bild wählen", "Speichern", "Passwort ändern"]) {
expect(await screen.findByRole("button", { name })).toHaveClass("h-11");
}
});
}); });
@@ -87,6 +87,7 @@ function AvatarSection() {
/> />
<Button <Button
type="button" type="button"
size="touch"
variant="outline" variant="outline"
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
@@ -96,6 +97,7 @@ function AvatarSection() {
{user.hasAvatar ? ( {user.hasAvatar ? (
<Button <Button
type="button" type="button"
size="touch"
variant="ghost" variant="ghost"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
@@ -205,7 +207,7 @@ function ProfileInfoSection() {
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}> <Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"} {mutation.isPending ? "Speichert…" : "Speichern"}
</Button> </Button>
</form> </form>
@@ -276,7 +278,7 @@ function PasswordSection() {
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}> <Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Ändert…" : "Passwort ändern"} {mutation.isPending ? "Ändert…" : "Passwort ändern"}
</Button> </Button>
</form> </form>
@@ -72,7 +72,7 @@ function renderCard() {
async function connect(user: ReturnType<typeof userEvent.setup>) { async function connect(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" })); await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim"); await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" })); await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
} }
@@ -105,7 +105,7 @@ describe("TelegramLinkCard", () => {
renderCard(); renderCard();
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" })); await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim"); await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456"); await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" })); await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
@@ -197,7 +197,7 @@ describe("TelegramLinkCard", () => {
renderCard(); renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" })); await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
expect(unlinkTelegram).not.toHaveBeenCalled(); expect(unlinkTelegram).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Trennen" })); await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
@@ -222,7 +222,7 @@ describe("TelegramLinkCard", () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked); vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
await user.click(await screen.findByRole("button", { name: "Trennen" })); await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" })); await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
await screen.findByRole("button", { name: "Telegram verbinden" }); await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -235,7 +235,7 @@ describe("TelegramLinkCard", () => {
renderCard(); renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" })); await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" })); await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" }); const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -281,7 +281,7 @@ export function TelegramLinkCard() {
open={isStepUpOpen} open={isStepUpOpen}
onOpenChange={setIsStepUpOpen} onOpenChange={setIsStepUpOpen}
title="Telegram verbinden?" title="Telegram verbinden?"
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt." description="Zur Sicherheit fragen wir noch einmal nach deinem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
confirmLabel="Link erzeugen" confirmLabel="Link erzeugen"
needsMfa={Boolean(user?.mfaEnabled)} needsMfa={Boolean(user?.mfaEnabled)}
onConfirm={createLink} onConfirm={createLink}
@@ -178,17 +178,32 @@ describe("BackupPage", () => {
}); });
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" })); await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
} }
it("explains why „Backup importieren“ is off until a file is chosen", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
await screen.findByText("Aktiv");
const button = screen.getByRole("button", { name: "Backup importieren" });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Bitte zuerst eine Backup-Datei wählen.");
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
expect(button).toBeEnabled();
expect(screen.queryByText("Bitte zuerst eine Backup-Datei wählen.")).toBeNull();
});
it("the backup import still asks for the password through StepUpDialog", async () => { it("the backup import still asks for the password through StepUpDialog", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule()); vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true }); vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
renderWithClient(<BackupPage />); renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument(); expect(await screen.findByText("Aktiv")).toBeInTheDocument();
// No loose password field on the page any more. // No loose password field on the page any more.
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Dein Passwort")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), { fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] }, target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
}); });
@@ -196,7 +211,7 @@ describe("BackupPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" })); await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled(); expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() => await vi.waitFor(() =>
@@ -222,7 +237,7 @@ describe("BackupPage", () => {
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
// Still open, file still chosen: a second try needs only the password. // Still open, file still chosen: a second try needs only the password.
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig"); await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "richtig");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File); expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
@@ -165,17 +165,23 @@ function BackupSection() {
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium" className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/> />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir dein Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}. {needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
</p> </p>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={!file || importMutation.isPending} disabled={!file || importMutation.isPending}
aria-describedby={file ? undefined : "backup-import-hint"}
onClick={() => setIsImportDialogOpen(true)} onClick={() => setIsImportDialogOpen(true)}
> >
Backup importieren Backup importieren
</Button> </Button>
{file ? null : (
<p id="backup-import-hint" className="text-xs text-muted-foreground">
Bitte zuerst eine Backup-Datei wählen.
</p>
)}
<StepUpDialog <StepUpDialog
open={isImportDialogOpen} open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen} onOpenChange={setIsImportDialogOpen}
@@ -149,7 +149,7 @@ describe("TelegramCard", () => {
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht"); expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" })); await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?"); expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen."); expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst."); expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
@@ -168,7 +168,7 @@ describe("TelegramCard", () => {
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }), await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?"); expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen."); expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst."); expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
@@ -29,7 +29,7 @@ describe("StepUpDialog", () => {
const confirm = screen.getByRole("button", { name: "Verbinden" }); const confirm = screen.getByRole("button", { name: "Verbinden" });
expect(confirm).toBeDisabled(); expect(confirm).toBeDisabled();
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(confirm); await userEvent.click(confirm);
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" })); await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" }));
@@ -39,7 +39,7 @@ describe("StepUpDialog", () => {
it("asks for the six-digit code with MFA and sends both", async () => { it("asks for the six-digit code with MFA and sends both", async () => {
const { onConfirm } = renderDialog({ needsMfa: true }); const { onConfirm } = renderDialog({ needsMfa: true });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
const confirm = screen.getByRole("button", { name: "Verbinden" }); const confirm = screen.getByRole("button", { name: "Verbinden" });
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34"); await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34");
expect(confirm).toBeDisabled(); expect(confirm).toBeDisabled();
@@ -56,21 +56,21 @@ describe("StepUpDialog", () => {
const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch.")); const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch."));
const { onOpenChange } = renderDialog({ onConfirm }); const { onOpenChange } = renderDialog({ onConfirm });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "falsch"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "falsch");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" })); await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."), expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
); );
expect(onOpenChange).not.toHaveBeenCalledWith(false); expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByLabelText("Ihr Passwort")).toHaveValue(""); expect(screen.getByLabelText("Dein Passwort")).toHaveValue("");
expect(screen.getByLabelText("Ihr Passwort")).toHaveFocus(); expect(screen.getByLabelText("Dein Passwort")).toHaveFocus();
}); });
it("shows the German too-many-attempts text on 429", async () => { it("shows the German too-many-attempts text on 429", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) }); renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" })); await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => await waitFor(() =>
@@ -83,7 +83,7 @@ describe("StepUpDialog", () => {
it("says the session expired on a bare 401", async () => { it("says the session expired on a bare 401", async () => {
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) }); renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "x");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" })); await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => await waitFor(() =>
@@ -96,17 +96,17 @@ describe("StepUpDialog", () => {
it("makes the inputs read-only while the action runs", async () => { it("makes the inputs read-only while the action runs", async () => {
renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) }); renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" })); await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
await waitFor(() => expect(screen.getByLabelText("Ihr Passwort")).toHaveAttribute("readonly")); await waitFor(() => expect(screen.getByLabelText("Dein Passwort")).toHaveAttribute("readonly"));
}); });
it("cannot be closed with Escape while the action runs, and shows the pending label", async () => { it("cannot be closed with Escape while the action runs, and shows the pending label", async () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {})); const onConfirm = vi.fn(() => new Promise<void>(() => {}));
const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" }); const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" });
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim"); await userEvent.type(screen.getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Verbinden" })); await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled(); expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled();
@@ -88,7 +88,7 @@ function StepUpForm({
return ( return (
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate> <form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor={passwordId}>Ihr Passwort</Label> <Label htmlFor={passwordId}>Dein Passwort</Label>
<Input <Input
id={passwordId} id={passwordId}
ref={passwordRef} ref={passwordRef}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmDialog } from "./confirm-dialog";
function renderDialog(onConfirm = vi.fn()) {
render(
<ConfirmDialog
triggerLabel="Löschen"
title="Wirklich löschen?"
description="Das lässt sich nicht rückgängig machen."
confirmLabel="Ja, löschen"
onConfirm={onConfirm}
/>,
);
return onConfirm;
}
describe("ConfirmDialog", () => {
it("is an alertdialog named by its title and described by its text", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
const dialog = await screen.findByRole("alertdialog", { name: "Wirklich löschen?" });
expect(dialog).toHaveAccessibleDescription("Das lässt sich nicht rückgängig machen.");
});
it("confirms, cancels and closes with Escape as before", async () => {
const onConfirm = renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
await screen.findByRole("alertdialog");
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(onConfirm).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Löschen" }));
await userEvent.click(await screen.findByRole("button", { name: "Ja, löschen" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
@@ -65,7 +65,7 @@ export function ConfirmDialog({
> >
{triggerLabel} {triggerLabel}
</Button> </Button>
<DialogContent> <DialogContent role="alertdialog">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
@@ -45,6 +45,11 @@ type EnumSelectProps<T extends string> = EnumSelectBaseProps<T> &
* Status"). It stands for the empty value "". * Status"). It stands for the empty value "".
*/ */
noneLabel: string; noneLabel: string;
/**
* The "none" row is only a placeholder ("Grund wählen"): it shows in
* the trigger while nothing is chosen but cannot be picked.
*/
noneIsPlaceholder?: boolean;
value: NoInfer<T> | ""; value: NoInfer<T> | "";
onChange: (value: NoInfer<T> | "") => void; onChange: (value: NoInfer<T> | "") => void;
} }
@@ -103,7 +108,9 @@ export function EnumSelect<T extends string>(props: EnumSelectProps<T>) {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{props.noneLabel !== undefined ? ( {props.noneLabel !== undefined ? (
<SelectItem value={NONE_VALUE}>{props.noneLabel}</SelectItem> <SelectItem value={NONE_VALUE} disabled={props.noneIsPlaceholder}>
{props.noneLabel}
</SelectItem>
) : null} ) : null}
{values.map((value) => ( {values.map((value) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
@@ -37,4 +37,14 @@ describe("PreviousValueHint", () => {
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument(); expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument(); expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
}); });
it("keeps the separator and „übernehmen“ in one non-wrapping unit", () => {
render(<PreviousValueHint fieldLabel="Menge" valueLabel="20 kg" onTakeOver={() => undefined} />);
const button = screen.getByRole("button", { name: /übernehmen/ });
const unit = button.parentElement as HTMLElement;
expect(unit).toHaveClass("whitespace-nowrap");
expect(unit).toHaveTextContent(/^·\s*übernehmen/);
expect(screen.getByText(/Letztes Mal: 20 kg/)).not.toHaveTextContent("·");
});
}); });
@@ -28,16 +28,20 @@ export function PreviousValueHint({
return ( return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}> <p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span> <span>
{prefix}: {valueLabel} <span aria-hidden="true">·</span> {prefix}: {valueLabel}
</span>
{/* Separator and button wrap as one unit: the dot is never left alone at a line end. */}
<span className="inline-flex items-center whitespace-nowrap">
<span aria-hidden="true">·</span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</span> </span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</p> </p>
); );
} }
@@ -66,7 +66,7 @@ async function discardRejectedEntry(nextSummary: { own: number; failed: number;
await userEvent.click( await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }), await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
); );
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ }); const confirm = await screen.findByRole("alertdialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" })); await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED)); await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
return sheet; return sheet;
@@ -162,7 +162,7 @@ describe("PendingQueueSheet", () => {
await userEvent.click( await userEvent.click(
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }), within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
); );
const confirm = await screen.findByRole("dialog", { const confirm = await screen.findByRole("alertdialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`, name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
}); });
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht"); expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
@@ -204,7 +204,7 @@ describe("PendingQueueSheet", () => {
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`; const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` })); await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` }); const confirm = await screen.findByRole("alertdialog", { name: `${name} verwerfen?` });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" })); await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1")); await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
@@ -226,7 +226,7 @@ describe("PendingQueueSheet", () => {
await userEvent.click(discardRejected); await userEvent.click(discardRejected);
expect(discardQueueItem).not.toHaveBeenCalled(); expect(discardQueueItem).not.toHaveBeenCalled();
const confirm = await screen.findByRole("dialog", { const confirm = await screen.findByRole("alertdialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`, name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
}); });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" })); await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
@@ -270,7 +270,7 @@ describe("PendingQueueSheet", () => {
expect(names[2]).toMatch(/\(2\)$/); expect(names[2]).toMatch(/\(2\)$/);
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]); await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
expect( expect(
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }), await screen.findByRole("alertdialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api"; import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
import type { PhenologyState } from "@/lib/phenology/types"; import type { PhenologyState } from "@/lib/phenology/types";
import { ApiarySeason } from "./apiary-season"; import { ApiarySeason } from "./apiary-season";
@@ -14,10 +13,6 @@ vi.mock("@/lib/phenology/api", async (importOriginal) => ({
fetchPhenologyReports: vi.fn().mockResolvedValue([]), fetchPhenologyReports: vi.fn().mockResolvedValue([]),
createPhenologyReport: vi.fn(), createPhenologyReport: vi.fn(),
})); }));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
const auth = vi.hoisted(() => ({ permissions: new Set<string>() })); const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
vi.mock("@/lib/auth/auth-context", () => ({ vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1", useCurrentUserId: () => "user-1",
@@ -39,6 +34,7 @@ function state(overrides: Partial<PhenologyState>): PhenologyState {
gts: null, gts: null,
gtsThroughDay: null, gtsThroughDay: null,
note: null, note: null,
intervalDays: 7,
...overrides, ...overrides,
}; };
} }
@@ -62,23 +58,6 @@ beforeEach(() => {
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }), state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
state({}), state({}),
]); ]);
vi.mocked(fetchAutopilotToday)
.mockReset()
.mockResolvedValue({
nextStep: null,
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [],
},
],
suggestionsOpen: 0,
seasons: [],
tasksVisible: true,
});
}); });
describe("ApiarySeason", () => { describe("ApiarySeason", () => {
@@ -105,8 +84,8 @@ describe("ApiarySeason", () => {
expect(await screen.findByText("Kalender")).toBeInTheDocument(); expect(await screen.findByText("Kalender")).toBeInTheDocument();
}); });
it("falls back to the season without an interval when the interval is unknown", async () => { it("shows just the season when no interval applies", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403")); vi.mocked(fetchPhenologyState).mockResolvedValue([state({ intervalDays: null })]);
renderSeason(); renderSeason();
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument(); expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
@@ -121,6 +100,15 @@ describe("ApiarySeason", () => {
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
}); });
it("takes the interval from the state, without asking the autopilot", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ segment: "SOMMER", intervalDays: 14 }),
]);
renderSeason();
expect(await screen.findByText("Sommer · alle 14 Tage")).toBeInTheDocument();
});
it("hides the button without phenology:create", async () => { it("hides the button without phenology:create", async () => {
auth.permissions = new Set(); auth.permissions = new Set();
renderSeason(); renderSeason();
@@ -1,12 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Flower2 } from "lucide-react"; import { Flower2 } from "lucide-react";
import { SeasonChip } from "@/components/autopilot/season-chip"; import { SeasonChip } from "@/components/autopilot/season-chip";
import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet"; import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api"; import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api";
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels"; import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels";
@@ -22,21 +21,13 @@ interface ApiarySeasonProps {
export function ApiarySeason({ apiary }: ApiarySeasonProps) { export function ApiarySeason({ apiary }: ApiarySeasonProps) {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [isSheetOpen, setSheetOpen] = useState(false); const [isSheetOpen, setSheetOpen] = useState(false);
const reportButton = useRef<HTMLButtonElement>(null);
const stateQuery = useQuery({ const stateQuery = useQuery({
queryKey: phenologyStateKey, queryKey: phenologyStateKey,
queryFn: fetchPhenologyState, queryFn: fetchPhenologyState,
}); });
// The interval is only known for apiaries with colonies to inspect; without
// it (or without the right to see it) the chip names just the season.
const todayQuery = useQuery({
queryKey: autopilotTodayKey,
queryFn: fetchAutopilotToday,
});
const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id); const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id);
const intervalDays =
todayQuery.data?.inspectionDue.find((entry) => entry.apiaryId === apiary.id)?.intervalDays ??
null;
const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null; const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null;
const canReport = hasPermission("phenology:create"); const canReport = hasPermission("phenology:create");
@@ -46,7 +37,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
<section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2"> <section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2">
{state ? ( {state ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<SeasonChip segment={state.segment} intervalDays={intervalDays} /> <SeasonChip segment={state.segment} intervalDays={state.intervalDays} />
{state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null} {state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null}
{source ? ( {source ? (
<> <>
@@ -63,11 +54,22 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
) : null} ) : null}
{canReport ? ( {canReport ? (
<> <>
<Button type="button" size="touch" variant="outline" onClick={() => setSheetOpen(true)}> <Button
ref={reportButton}
type="button"
size="touch"
variant="outline"
onClick={() => setSheetOpen(true)}
>
<Flower2 aria-hidden /> <Flower2 aria-hidden />
Pflanze blüht Pflanze blüht
</Button> </Button>
<PhenologyReportSheet open={isSheetOpen} onOpenChange={setSheetOpen} apiary={apiary} /> <PhenologyReportSheet
open={isSheetOpen}
onOpenChange={setSheetOpen}
apiary={apiary}
returnFocusTo={reportButton}
/>
</> </>
) : null} ) : null}
</section> </section>
@@ -256,7 +256,7 @@ describe("PhenologyReportSheet", () => {
}), }),
); );
expect(revokePhenologyReport).not.toHaveBeenCalled(); expect(revokePhenologyReport).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { const dialog = await screen.findByRole("alertdialog", {
name: "Für alle Stände zurückziehen?", name: "Für alle Stände zurückziehen?",
}); });
await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" }));
@@ -268,6 +268,21 @@ describe("PhenologyReportSheet", () => {
); );
}); });
it("Escape on the revoke question closes only that question, not the sheet", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
const onOpenChange = renderSheet();
await userEvent.click(
await screen.findByRole("button", { name: /Für alle Stände zurückziehen/ }),
);
await screen.findByRole("alertdialog", { name: "Für alle Stände zurückziehen?" });
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Entwicklung melden" })).toBeInTheDocument();
});
it("does not withdraw when the question is cancelled", async () => { it("does not withdraw when the question is cancelled", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]); vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
renderSheet(); renderSheet();
@@ -277,7 +292,7 @@ describe("PhenologyReportSheet", () => {
name: /Für alle Stände zurückziehen/, name: /Für alle Stände zurückziehen/,
}), }),
); );
const dialog = await screen.findByRole("dialog", { const dialog = await screen.findByRole("alertdialog", {
name: "Für alle Stände zurückziehen?", name: "Für alle Stände zurückziehen?",
}); });
await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" })); await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" }));
@@ -298,6 +313,13 @@ describe("PhenologyReportSheet", () => {
); );
}); });
it("is a max-width card from md up, not a full-width bottom sheet", () => {
renderSheet();
const dialog = screen.getByRole("dialog", { name: "Entwicklung melden" });
expect(dialog).toHaveClass("md:w-[28rem]", "md:inset-x-auto", "md:right-4");
});
it("has a neutral heading", () => { it("has a neutral heading", () => {
renderSheet(); renderSheet();
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type RefObject } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flower2 } from "lucide-react"; import { Flower2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -41,6 +41,8 @@ interface PhenologyReportSheetProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
apiary: { id: string; name: string }; apiary: { id: string; name: string };
/** Where the focus goes when the sheet closes: the button that opened it. */
returnFocusTo?: RefObject<HTMLElement | null>;
} }
type Scope = "all" | "apiary"; type Scope = "all" | "apiary";
@@ -48,7 +50,12 @@ type Scope = "all" | "apiary";
const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht"; const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht";
/** Which plant is in bloom? One tap reports it for today. */ /** Which plant is in bloom? One tap reports it for today. */
export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyReportSheetProps) { export function PhenologyReportSheet({
open,
onOpenChange,
apiary,
returnFocusTo,
}: PhenologyReportSheetProps) {
// While a report or a withdrawal is on its way the sheet stays open, so the // While a report or a withdrawal is on its way the sheet stays open, so the
// answer (or the server's reason) is never lost behind a closed sheet. // answer (or the server's reason) is never lost behind a closed sheet.
const [isBusy, setBusy] = useState(false); const [isBusy, setBusy] = useState(false);
@@ -61,7 +68,10 @@ export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyRe
<SheetContent <SheetContent
side="bottom" side="bottom"
showCloseButton={false} showCloseButton={false}
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)" finalFocus={returnFocusTo}
// Below md a full-width bottom sheet; from md a card at the bottom right,
// so it does not cover the sidebar.
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) md:inset-x-auto md:right-4 md:bottom-4 md:w-[28rem] md:max-w-[calc(100vw-2rem)] md:rounded-2xl md:border"
> >
{/* Remounted per opening: a fresh idempotency key and the preselected scope. */} {/* Remounted per opening: a fresh idempotency key and the preselected scope. */}
{open ? ( {open ? (
@@ -131,7 +131,7 @@ describe("PhotoCaptureField", () => {
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2); expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" })); await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" }); const confirm = await screen.findByRole("alertdialog", { name: "Foto 1 entfernen?" });
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" })); await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1)); await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));
@@ -12,8 +12,8 @@ describe("stepUpErrorText", () => {
}); });
it("passes a German reauth message through unchanged", () => { it("passes a German reauth message through unchanged", () => {
expect(stepUpErrorText(new ApiError(401, "Das Passwort ist falsch."), "x")).toBe( expect(stepUpErrorText(new ApiError(401, "Das Passwort stimmt nicht."), "x")).toBe(
"Das Passwort ist falsch.", "Das Passwort stimmt nicht.",
); );
}); });
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { postSaveWarnings } from "./post-save-warnings";
import type { PlausibilityWarning } from "@/lib/warnings/types";
const WATER: PlausibilityWarning = {
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Der Wassergehalt (22 %) liegt über der Grenze. Abfüllen nur mit Bestätigung.",
refs: [],
};
const OTHER: PlausibilityWarning = {
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "Wartezeit läuft.",
refs: [],
};
describe("postSaveWarnings", () => {
it("words the confirmed water content as a fact with the value", () => {
const [warning] = postSaveWarnings([WATER], 22);
expect(warning.message).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
expect(warning.severity).toBe("BESTAETIGEN");
});
it("falls back to a text without the value and leaves other warnings alone", () => {
const result = postSaveWarnings([WATER, OTHER], null);
expect(result[0].message).toBe("Wassergehalt über der Grenze – mit Bestätigung abgefüllt.");
expect(result[1]).toBe(OTHER);
});
});
@@ -0,0 +1,25 @@
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { formatWaterContent } from "./format";
import { WATER_CONTENT_ABOVE_LEGAL } from "./types";
/**
* The server words its warnings for the time before saving ("... Abfüllen nur
* mit Bestätigung."). After a confirmed save that text is out of date, so the
* confirmed finding is worded as the fact it now is.
*/
export function postSaveWarnings(
warnings: readonly PlausibilityWarning[],
confirmedWaterContent: number | null,
): PlausibilityWarning[] {
return warnings.map((warning) =>
warning.code === WATER_CONTENT_ABOVE_LEGAL
? {
...warning,
message:
confirmedWaterContent === null
? "Wassergehalt über der Grenze – mit Bestätigung abgefüllt."
: `Wassergehalt ${formatWaterContent(confirmedWaterContent)} über der Grenze – mit Bestätigung abgefüllt.`,
}
: warning,
);
}
@@ -92,15 +92,75 @@ describe("fetch-with-timeout", () => {
return new Response(new ReadableStream({ start() {} }), { status: 200 }); return new Response(new ReadableStream({ start() {} }), { status: 200 });
} }
it("a stalled body ends in TimeoutError and reports network-error", async () => { async function timeOut(): Promise<void> {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
}
async function answer(): Promise<void> {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
}
it("one timeout leaves the app online, two in a row set offline", async () => {
reportRequestOutcome("reached-server");
await timeOut();
expect(getConnectivity()).toBe("online");
await timeOut();
expect(getConnectivity()).toBe("offline");
await answer();
expect(getConnectivity()).toBe("online");
});
it("timeout, answer, timeout stays online", async () => {
reportRequestOutcome("reached-server");
await timeOut();
await answer();
await timeOut();
expect(getConnectivity()).toBe("online");
});
it("a caller abort is never counted", async () => {
reportRequestOutcome("reached-server");
await timeOut();
for (let i = 0; i < 3; i += 1) {
vi.stubGlobal("fetch", vi.fn(hangingFetch));
const caller = new AbortController();
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000, signal: caller.signal });
const assertion = expect(pending).rejects.toMatchObject({ name: "AbortError" });
caller.abort(new DOMException("weg", "AbortError"));
await assertion;
}
expect(getConnectivity()).toBe("online");
await timeOut();
expect(getConnectivity()).toBe("offline");
});
it("a stalled body ends in TimeoutError and one such timeout does not flip the app offline", async () => {
reportRequestOutcome("reached-server"); reportRequestOutcome("reached-server");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse())); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(stalledBodyResponse()));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 }); const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" }); const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000); await vi.advanceTimersByTimeAsync(4_000);
await assertion; await assertion;
expect(getConnectivity()).toBe("online");
});
it("a timed-out request does not clear an offline state set by a real network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
await expect(fetchReportingOutcome("/x", { timeoutMs: 4_000 })).rejects.toBeInstanceOf(TypeError);
expect(getConnectivity()).toBe("offline"); expect(getConnectivity()).toBe("offline");
reportRequestOutcome("reached-server"); vi.stubGlobal("fetch", vi.fn(hangingFetch));
const pending = fetchReportingOutcome("/x", { timeoutMs: 4_000 });
const assertion = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
await vi.advanceTimersByTimeAsync(4_000);
await assertion;
expect(getConnectivity()).toBe("offline");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
await fetchReportingOutcome("/x", { timeoutMs: 4_000 });
expect(getConnectivity()).toBe("online");
}); });
it("returns a readable response once headers and body arrived in time", async () => { it("returns a readable response once headers and body arrived in time", async () => {
@@ -87,9 +87,9 @@ export function isNetworkError(error: unknown): boolean {
} }
// fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including // fetchWithTimeout plus the connectivity signal: ANY HTTP answer (including
// 502/503/504 from a proxy) counts as "reached-server"; a timeout or network // 502/503/504 from a proxy) counts as "reached-server" (clears offline at once);
// TypeError counts as "network-error". A caller-initiated AbortError // a network TypeError counts as "network-error". A timeout or abort (our own
// (unmount/cancel) says nothing about the network and is not reported. // 4 s deadline, unmount, cancel) says nothing about the network: not reported.
export async function fetchReportingOutcome( export async function fetchReportingOutcome(
input: RequestInfo | URL, input: RequestInfo | URL,
init: RequestInit & { timeoutMs: number }, init: RequestInit & { timeoutMs: number },
@@ -102,8 +102,12 @@ export async function fetchReportingOutcome(
reportRequestOutcome("reached-server"); reportRequestOutcome("reached-server");
return response; return response;
} catch (error) { } catch (error) {
if (isNetworkError(error) && !(error instanceof DOMException && error.name === "AbortError")) { // A TypeError proves the network is gone. Our own timeout counts only when
// repeated (see connectivity.ts); a caller abort says nothing.
if (error instanceof TypeError) {
reportRequestOutcome("network-error"); reportRequestOutcome("network-error");
} else if (error instanceof DOMException && error.name === "TimeoutError") {
reportRequestOutcome("timeout");
} }
throw error; throw error;
} }
@@ -1,12 +1,16 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
export type Connectivity = "online" | "offline"; export type Connectivity = "online" | "offline";
export type RequestOutcome = "reached-server" | "network-error"; export type RequestOutcome = "reached-server" | "network-error" | "timeout";
/** Consecutive timeouts without any HTTP answer that count as offline. */
export const TIMEOUTS_BEFORE_OFFLINE = 2;
// Offline = the browser says so, or the last request failed at network level // Offline = the browser says so, or the last request failed at network level
// and no request has reached the server since. navigator.onLine alone is // and no request has reached the server since. navigator.onLine alone is
// unreliable (it stays true on a captive portal or a dead uplink). // unreliable (it stays true on a captive portal or a dead uplink).
let lastOutcome: RequestOutcome = "reached-server"; let lastOutcome: "reached-server" | "network-error" = "reached-server";
let consecutiveTimeouts = 0;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
function notify(): void { function notify(): void {
@@ -14,6 +18,14 @@ function notify(): void {
} }
export function reportRequestOutcome(outcome: RequestOutcome): void { export function reportRequestOutcome(outcome: RequestOutcome): void {
if (outcome === "timeout") {
// One slow request proves nothing; two in a row without an answer do.
consecutiveTimeouts += 1;
if (consecutiveTimeouts < TIMEOUTS_BEFORE_OFFLINE) return;
outcome = "network-error";
} else {
consecutiveTimeouts = 0;
}
if (outcome === lastOutcome) { if (outcome === lastOutcome) {
return; return;
} }
@@ -55,4 +55,6 @@ export interface PhenologyState {
gts: number | null; gts: number | null;
gtsThroughDay: string | null; gtsThroughDay: string | null;
note: SeasonNote; note: SeasonNote;
/** Days between inspections now; null in winter or without the interval rule. */
intervalDays: number | null;
} }
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DOMAIN_EVENT_LABELS, DOMAIN_EVENT_LABELS,
@@ -55,3 +57,20 @@ describe("system labels", () => {
expect(formatAge(86400)).toBe("vor 1 Tag"); expect(formatAge(86400)).toBe("vor 1 Tag");
}); });
}); });
describe("DOMAIN_EVENT_LABELS completeness", () => {
it("names PhenologyRecomputeRequested and WinterCheckRecorded", () => {
expect(DOMAIN_EVENT_LABELS.PhenologyRecomputeRequested).toBe("Saison neu berechnen angefordert");
expect(DOMAIN_EVENT_LABELS.WinterCheckRecorded).toBe("Winterkontrolle erfasst");
});
it("has a German label for every backend domain event type", () => {
const catalog = resolve(process.cwd(), "../ApiCore-Backend/src/domain-events/domain-event-types.ts");
if (!existsSync(catalog)) return; // frontend checked out alone
const source = readFileSync(catalog, "utf8");
const block = source.slice(source.indexOf("const CATALOG"), source.indexOf("DOMAIN_EVENT_TYPES"));
const types = [...block.matchAll(/^\s+([A-Z][A-Za-z]+): true,/gm)].map((match) => match[1]);
expect(types.length).toBeGreaterThan(20);
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
});
});
@@ -55,6 +55,8 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
HoneyBatchCorrected: "Honigcharge korrigiert", HoneyBatchCorrected: "Honigcharge korrigiert",
PhenologyReported: "Pflanzenmeldung erfasst", PhenologyReported: "Pflanzenmeldung erfasst",
PhenologyCorrected: "Pflanzenmeldung korrigiert", PhenologyCorrected: "Pflanzenmeldung korrigiert",
PhenologyRecomputeRequested: "Saison neu berechnen angefordert",
WinterCheckRecorded: "Winterkontrolle erfasst",
SeasonPhaseChanged: "Saisonabschnitt gewechselt", SeasonPhaseChanged: "Saisonabschnitt gewechselt",
}; };
@@ -6,7 +6,7 @@
**Architecture:** Neue Module `veterinary-medicines` (Katalog, Packungen, Bewegungsbuch), `apiary-restrictions` (Sperrbezirk) und `compliance` (Einstellungen, Anzeigen, Handler, Prüfbereitschaft mit einer reinen Regel-Datei je Prüfpunkt). Folgen laufen über Ereignisse des Rückgrats (`ColonyCreated`, `ColonyMoved`, `ApiaryChanged`, `TskReferenceDayReached`) und Handler, die nur über `tx` schreiben. Die Prüfbereitschaft wird live aus den Fachdaten berechnet; gespeichert werden nur Quittungen. Die Etikett-Prüfung und das Bestandsbuch nutzen je eine gemeinsame reine Funktion für Druck, Designer und Prüfbereitschaft. Rechtswerte sind Einstellungen mit Vermerk „Annahme – vom Inhaber zu prüfen“, nie Code. **Architecture:** Neue Module `veterinary-medicines` (Katalog, Packungen, Bewegungsbuch), `apiary-restrictions` (Sperrbezirk) und `compliance` (Einstellungen, Anzeigen, Handler, Prüfbereitschaft mit einer reinen Regel-Datei je Prüfpunkt). Folgen laufen über Ereignisse des Rückgrats (`ColonyCreated`, `ColonyMoved`, `ApiaryChanged`, `TskReferenceDayReached`) und Handler, die nur über `tx` schreiben. Die Prüfbereitschaft wird live aus den Fachdaten berechnet; gespeichert werden nur Quittungen. Die Etikett-Prüfung und das Bestandsbuch nutzen je eine gemeinsame reine Funktion für Druck, Designer und Prüfbereitschaft. Rechtswerte sind Einstellungen mit Vermerk „Annahme – vom Inhaber zu prüfen“, nie Code.
**Tech Stack:** NestJS 11, Prisma 7.9 (`@prisma/adapter-pg`), PostgreSQL 18, pdfkit 0.19, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, Vitest 4 + RTL. Keine neue Abhängigkeit. **Tech Stack:** NestJS 11, Prisma 7.9 (`@prisma/adapter-pg`), PostgreSQL (Betrieb 18.6; die Test-DB auf Port 5433 ist **17**, also keine Funktion nutzen, die erst ab 18 existiert), pdfkit 0.19, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, Vitest 4 + RTL. Keine neue Abhängigkeit.
**Spec:** `docs/superpowers/specs/2026-10-13-t6-compliance-design.md` (bindend, mit Inhaber-Entscheidungen vom 2026-10-13). Bausteine aus `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md` (Rückgrat), `docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md` (T3) und `docs/superpowers/plans/2026-10-13-t4-logik-ketten.md` (T4, läuft vor T6). **Spec:** `docs/superpowers/specs/2026-10-13-t6-compliance-design.md` (bindend, mit Inhaber-Entscheidungen vom 2026-10-13). Bausteine aus `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md` (Rückgrat), `docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md` (T3) und `docs/superpowers/plans/2026-10-13-t4-logik-ketten.md` (T4, läuft vor T6).
@@ -16,8 +16,9 @@
**Voraussetzungen (nicht neu bauen, genau so nutzen)** **Voraussetzungen (nicht neu bauen, genau so nutzen)**
- Rückgrat Tasks 1–26 außer 17 und 25 sind im Arbeitsbaum. Genutzt werden: `DomainEventsService.record/recordAll(tx, …)`, `@OnDomainEvent(types, '<consumer>.<name>')` (Handler-ID `^[a-z0-9-]+\.[a-z0-9-]+$`), `dailyDedupeKey`, `utcDay`, `inCatchUpWindow`, `DAILY_EVALUATION_RULES` (`src/jobs/daily-evaluation.rules.ts`), `AuditLogService.record(entry, tx)`, `lockRowForUpdate`/`lockRowsForShare` (`src/prisma/row-lock.ts`), `runBulk` (`src/common/bulk/run-bulk.ts`, Callback `(tx, entry, colony, bulkId)`), `lockColony`/`colonyTransitionEvents` (`src/colonies/colony-events.ts`), `LIVING_COLONY_WHERE`/`isLivingColonyStatus`, `loggableError`, `PermissionResolverService.getPermissions(userId)`, `drainDomainEvents` (`test/utils/domain-events.ts`), `@Idempotent()`, `IsOptionalNotNull`, `optionalDate`, `fifoOrder` (`src/stock/stock-ledger.ts`), `PDF_MARGIN`/`drawTable`/`fillWidth`/`formatDay`/`orDash` (`src/export/pdf-table.ts`). - Rückgrat Tasks 1–26 außer 17 und 25 sind im Arbeitsbaum. Genutzt werden: `DomainEventsService.record/recordAll(tx, …)`, `@OnDomainEvent(types, '<consumer>.<name>')` (Handler-ID `^[a-z0-9-]+\.[a-z0-9-]+$`), `dailyDedupeKey`, `utcDay`, `inCatchUpWindow`, `DAILY_EVALUATION_RULES` (`src/jobs/daily-evaluation.rules.ts`), `AuditLogService.record(entry, tx)`, `lockRowForUpdate`/`lockRowsForShare` (`src/prisma/row-lock.ts`), `runBulk` (`src/common/bulk/run-bulk.ts`, Callback `(tx, entry, colony, bulkId)`), `lockColony`/`colonyTransitionEvents` (`src/colonies/colony-events.ts`), `LIVING_COLONY_WHERE`/`isLivingColonyStatus`, `loggableError`, `PermissionResolverService.getPermissions(userId)`, `drainDomainEvents` (`test/utils/domain-events.ts`), `@Idempotent()`, `IsOptionalNotNull`, `optionalDate`, `fifoOrder` (`src/stock/stock-ledger.ts`), `PDF_MARGIN`/`drawTable`/`fillWidth`/`formatDay`/`orDash` (`src/export/pdf-table.ts`).
- T3 ist gemergt: `TreatmentsService.createBulk` mit der gemeinsamen privaten Aufbereitung `treatmentRow` + `recordIn` (T6 erweitert genau diese Funktionen), `GET /treatments/last-by-product`, Frontend `PrefillNote`, `NumberStepper`, `RecordSelect`, `useCaptureMutation`, `applyPrefill`. - T3 ist gemergt: `TreatmentsService.createBulk` (über `runBulk`) mit der gemeinsamen Aufbereitung `treatmentRow(dto, apiaryId, actingUserId)` (**freie Modulfunktion** in `treatments.service.ts`, kein Klassenmitglied; sie spreizt `...dto`) und der privaten Methode `recordIn(tx, data, actingUserId, bulkId?)` (T6 erweitert genau diese beiden), `GET /treatments/last-by-product`, Frontend `PrefillNote` (`src/components/treatments/prefill-note.tsx`), `NumberStepper`, `RecordSelect`, `useCaptureMutation`, `applyPrefill`. `create` sperrt das Volk mit `lockRowForUpdate(tx, 'Colony', …)`; `update` kennt `apiaryId` heute nicht und spreizt `...dto`.
- T4 ist gemergt: Meldungen je Nutzer (`NotificationsService.record/recordForUsers/recordForPermission(permission | permissions[], FanOutNotificationInput, tx)`, `NotificationCategory`, `assertAppLink`), `LOCKABLE_TABLES` als Einträge `{ table, key }`, `src/treatments/withdrawal-window.ts` (`withdrawalWindowOf`, `isInWindow`, `WithdrawalWindow`), `TimeEventsStep` über `DAILY_EVALUATION_RULES`, `berlinToday()` (`src/common/dates.ts`), `ColonySplit`. - T4 ist gemergt: Meldungen je Nutzer (`NotificationsService.record(input, tx?)`, `recordForUsers(userIds, input, tx?)`, `recordForPermission(permission | permissions[], FanOutNotificationInput, tx?)`; `NotificationCategory` ist ein Prisma-Enum `TASK_DUE | DIGEST | SUGGESTION | WITHDRAWAL | SEASON | SYSTEM`; `assertAppLink` und `notificationDedupeKey` in `src/notifications/notification-link.ts`; die Empfängerabfrage ist die **private** Methode `recipients(db, keys, userIds)`; jede neue Zeile plant über `NotificationDeliveryPlanner.planIn` die Telegram-Zustellung in derselben Transaktion), `LOCKABLE_TABLES` als Einträge `{ table, key }`, `src/treatments/withdrawal-window.ts` (`withdrawalWindowOf(t)` → `WithdrawalWindow { treatmentId, colonyId, product, fromDay, untilDay }` oder `null`, `isInWindow(window, day)`: `fromDay <= day < untilDay`, `withdrawalUntilDay`), `src/withdrawal/withdrawal-text.ts` (`lastBlockedDay`, `withdrawalRangeText`) und `WithdrawalCheckService.conflictsForHarvests` (`src/withdrawal/`), `TimeEventsStep` (`src/jobs/steps/time-events.step.ts`) über `DAILY_EVALUATION_RULES`, Berliner Tage in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`, `addMonthsClamped`; Annahme 41), `DbClient` (`src/prisma/db-client.ts`), `ColonySplit`, `Product.shelfLifeMonths` (Migration `20261015100200_t4_product_shelf_life`, T6 baut es nicht), `roundKg` (exportiert aus `src/honey-batches/honey-plausibility.ts`), Frontend `StepUpDialog`, `PrefillStatus`, `describeSaveError`/`saveErrorText` (`src/lib/honey-batches/errors.ts`), `refreshOn401: false`, `ConfirmDialog` mit `triggerDescribedBy`/`triggerDisabled`, `ToggleChip` mit `disabled`, `NEVER_CACHED_API_PATHS` (`public/sw-routes.js`), `DOMAIN_EVENT_LABELS` (`src/lib/system/labels.ts`).
- Tagesauswertung: Eine Regel ist `DailyRule<T>` mit `occurrences(db: PrismaService, today: Date, ctx: { seasons, day })` (kein `evaluate`); `ctx.day` ist der **Berliner** Tag des Laufs, `today = dayToDate(ctx.day)`; `CATCH_UP_DAYS = 7` ist exportiert; neue Typen kommen in die Union `DailyEventType`.
- Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben. - Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben.
**Migrationen (Zeitstempel fest, alle nach der höchsten T4-Migration `20261015110000_t4_telegram`; alles additiv)** **Migrationen (Zeitstempel fest, alle nach der höchsten T4-Migration `20261015110000_t4_telegram`; alles additiv)**
@@ -28,6 +29,7 @@
| 20261016100100 | `t6_label_particulars` | `SiteSettings.foodBusinessName`, `foodBusinessAddress`; `Product.legalName`; `LabelTemplate.minXHeightMm Float @default(1.2)` + CHECK 0,5–5; `AuditAction` + `PRINT` | | 20261016100100 | `t6_label_particulars` | `SiteSettings.foodBusinessName`, `foodBusinessAddress`; `Product.legalName`; `LabelTemplate.minXHeightMm Float @default(1.2)` + CHECK 0,5–5; `AuditAction` + `PRINT` |
| 20261016100200 | `t6_authority_compliance` | `ComplianceSettings` (Vorgabe `netQuantityDigitRules` = `[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]`); `AuthorityNotice` mit Enums; `ApiaryRestriction` + CHECK `"endsOn" IS NULL OR "endsOn" >= "startsOn"`; `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte `compliance:*` samt Vergabe | | 20261016100200 | `t6_authority_compliance` | `ComplianceSettings` (Vorgabe `netQuantityDigitRules` = `[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]`); `AuthorityNotice` mit Enums; `ApiaryRestriction` + CHECK `"endsOn" IS NULL OR "endsOn" >= "startsOn"`; `ComplianceAcknowledgement`; `DocumentEntityType` + `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`; Rechte `compliance:*` samt Vergabe |
- Geprüft am 2026-10-15 (HEAD 9760ce1): Die neueste Migration ist `20261015110000_t4_telegram`, alle drei T6-Stempel liegen dahinter. T4 hat weder `AuditAction.PRINT` noch eine der drei `DocumentEntityType`-Werte noch Rechte `veterinary-medicines:*`/`compliance:*` angelegt (`Product.shelfLifeMonths` gibt es seit T4, T6 ergänzt dort nur `legalName`). Doppelt oder widersprüchlich ist nichts. Hängt ein weiterer Strang vor T6 eine Migration an, rücken die Stempel hinter dessen höchsten.
- Erzeugt mit `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script` gegen die Test-DB, von Hand nachgearbeitet (Kopfkommentar wie `20261012100000_calendar_sync_claims`). Nie `db push`, nie `migrate dev`. `ALTER TYPE … ADD VALUE` läuft in der Migrationstransaktion; kein neuer Wert wird in derselben Migration benutzt. - Erzeugt mit `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script` gegen die Test-DB, von Hand nachgearbeitet (Kopfkommentar wie `20261012100000_calendar_sync_claims`). Nie `db push`, nie `migrate dev`. `ALTER TYPE … ADD VALUE` läuft in der Migrationstransaktion; kein neuer Wert wird in derselben Migration benutzt.
- Test-DB migrieren (Git Bash, in `ApiCore-Backend/`): `export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate`. Läuft Task 1 in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an. - Test-DB migrieren (Git Bash, in `ApiCore-Backend/`): `export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate`. Läuft Task 1 in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an.
@@ -44,26 +46,27 @@
- Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen nur mit `compliance:read` bzw. `veterinary-medicines:read`; nie in öffentlichen Endpunkten, Shop-Feed, Webhooks oder Ereignis-Payloads. - Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen nur mit `compliance:read` bzw. `veterinary-medicines:read`; nie in öffentlichen Endpunkten, Shop-Feed, Webhooks oder Ereignis-Payloads.
**Fachwerte aus der Spec** **Fachwerte aus der Spec**
- Rest einer Packung = `lot.quantity + Σ movement.quantity`, gerundet auf Tausendstel (`roundToThousandths`, `src/common/quantity.ts`; `roundKg` in `bottling.service.ts` nutzt ihn). Immer berechnet, nie gespeichert. Negativer Rest ist erlaubt und wird in der Prüfbereitschaft gemeldet. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht. - Rest einer Packung = `lot.quantity + Σ movement.quantity`, gerundet auf Tausendstel (`roundToThousandths`, `src/common/quantity.ts`; `roundKg` in `honey-plausibility.ts` und `bottling.service.ts` nutzen ihn). Immer berechnet, nie gespeichert. Negativer Rest ist erlaubt und wird in der Prüfbereitschaft gemeldet. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht.
- Behandlung (Spec 5.2): Packung plus `supplier`/`purchaseProof`/`batchNumber`/`prescribingVet` → 400 „Diese Angabe kommt aus der Packung“; `medicineId` und `product` weichen ab (ohne Groß-/Kleinschreibung) → 400 „Mittel und Katalogeintrag passen nicht zusammen“; Katalogeintrag inaktiv → Anlegen 400; Packung eines anderen Mittels → 400; `doseQuantity` ≤ 0 → 400; `medicineId: null` bei gesetzter Packung → 400. Der Server füllt `activeIngredient`, `withdrawalPeriodDays`, `method`, `doseQuantity` nie still nach; nur `product` wird zum Handelsnamen. - Behandlung (Spec 5.2): Packung plus `supplier`/`purchaseProof`/`batchNumber`/`prescribingVet` → 400 „Diese Angabe kommt aus der Packung“; `medicineId` und `product` weichen ab (ohne Groß-/Kleinschreibung) → 400 „Mittel und Katalogeintrag passen nicht zusammen“; Katalogeintrag inaktiv → Anlegen 400; Packung eines anderen Mittels → 400; `doseQuantity` ≤ 0 → 400; `medicineId: null` bei gesetzter Packung → 400. Der Server füllt `activeIngredient`, `withdrawalPeriodDays`, `method`, `doseQuantity` nie still nach; nur `product` wird zum Handelsnamen.
- Etikett (Spec 5.4): Pflicht `LEGAL_NAME`, `NET_QUANTITY`, `BEST_BEFORE`, `LOT`, `ORIGIN`, `FOOD_BUSINESS` sperren den Druck (409 `LABEL_PARTICULARS_MISSING`, `{ gaps }`); Warnungen `LOT_PREFIX`, `X_HEIGHT`, `NET_QUANTITY_HEIGHT`, `TEXT_CLIPPED` sperren nie. Probedruck `?proof=true` immer möglich, Aufdruck „MUSTER – nicht verkaufsfähig“ in 50 % Grau. Jeder Druck schreibt Audit `PRINT` am `BottlingBatch`: `{ templateId, copies, proof, gapCodes, warningCodes }`. Höhe in mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica x-Höhe 523, Versalhöhe 718); 6 pt → 1,11 mm Warnung, 7 pt → 1,29 mm in Ordnung; 500-g-Glas mit Regel 4 mm → Füllmenge ab 15,8 pt. `minXHeightMm` Vorgabe 1,2, Bereich 0,5–5. - Etikett (Spec 5.4): Pflicht `LEGAL_NAME`, `NET_QUANTITY`, `BEST_BEFORE`, `LOT`, `ORIGIN`, `FOOD_BUSINESS` sperren den Druck (409 `LABEL_PARTICULARS_MISSING`, `{ gaps }`); Warnungen `LOT_PREFIX`, `X_HEIGHT`, `NET_QUANTITY_HEIGHT`, `TEXT_CLIPPED` sperren nie. Probedruck `?proof=true` immer möglich, Aufdruck „MUSTER – nicht verkaufsfähig“ in 50 % Grau. Jeder Druck schreibt Audit `PRINT` am `BottlingBatch`: `{ templateId, copies, proof, gapCodes, warningCodes }`. Höhe in mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica x-Höhe 523, Versalhöhe 718); 6 pt → 1,11 mm Warnung, 7 pt → 1,29 mm in Ordnung; 500-g-Glas mit Regel 4 mm → Füllmenge ab 15,8 pt. `minXHeightMm` Vorgabe 1,2, Bereich 0,5–5.
- Sperrbezirk (Spec 5.6): aktiv bei `startsOn` ≤ Tag ≤ `endsOn` (oder `endsOn` leer), Tag = UTC-Tag der DB-Uhr. Sperre bei `move` (Quell- oder Zielstand), `create` im Stand, Stand-Korrektur über `update` (alter oder neuer Stand), `merge` über verschiedene Stände. Keine Sperre bei `split`. Ernte nur Warnung. `restrictionOverrideReason` 10–500 Zeichen, nur mit `compliance:manage` (sonst 403), landet im Audit und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“), wird nie am Volk gespeichert. Fehler: 409 `{ code: 'RESTRICTED_ZONE', message, restrictionIds }`. `apiaryIds` 1–50. - Sperrbezirk (Spec 5.6): aktiv bei `startsOn` ≤ Tag ≤ `endsOn` (oder `endsOn` leer), Tag = UTC-Tag der DB-Uhr (Spec 5.6 sagt ausdrücklich UTC, begründet mit der Tagesauswertung, die seit T4 aber Berliner Tage nutzt, Annahme 41; **bleibt UTC bis zur Entscheidung des Inhabers**, siehe Aktualisierung, Frage 1; der Tag steht nur in `dbUtcDay`, ein Wechsel ist eine Zeile). Alle **anderen** Tage der T6 sind Berliner Tage (`berlinToday()`, `berlinDayOf`, `addDays` aus `src/common/dates.ts`): Vorgabe von `referenceDate`/`sentOn`, Stichtag, „überfällig“, `today` der Prüfbereitschaft. Sperre bei `move` (Quell- oder Zielstand), `create` im Stand, Stand-Korrektur über `update` (alter oder neuer Stand), `merge` über verschiedene Stände. Keine Sperre bei `split`. Ernte nur Warnung. `restrictionOverrideReason` 10–500 Zeichen, nur mit `compliance:manage` (sonst 403), landet im Audit und in `ColonyApiaryHistory.notes` („Ausnahme Sperrbezirk: …“), wird nie am Volk gespeichert. Fehler: 409 `{ code: 'RESTRICTED_ZONE', message, restrictionIds }`. `apiaryIds` 1–50.
- Behördenpaket (Spec 5.5): Bezugsstand = jüngste `SENT`-Anzeige der Art `REGISTRATION`/`HERD_CHANGE` (nach `sentOn`); `REGISTRATION` nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen“, 409); `TSK_REPORT` braucht `year`; mit `sentOn` im Anlegen entsteht `SENT` gleich. Neu zählen und Löschen nur im Entwurf; `openKey` = `REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>` nur bei `DRAFT`; zweiter Entwurf → 409 `{ code: 'DRAFT_EXISTS', existingId }`. Mutex: Zeile `ComplianceSettings` per `lockRowForUpdate`. Einstellungen: `noticeOnColonyCountChange`/`noticeOnApiaryChange` Vorgabe `true`, `tskReferenceDay` Vorgabe `01-01` (Format `MM-TT`, `02-29` und `1-1` → 400), `tskReportDueDay` leer, `tskExcludedColonyTypes` leer. Lebende Völker über `LIVING_COLONY_WHERE` an aktiven Ständen. Nachholfenster der Tagesauswertung 7 Tage. - Behördenpaket (Spec 5.5): Bezugsstand = jüngste `SENT`-Anzeige der Art `REGISTRATION`/`HERD_CHANGE` (nach `sentOn`); `REGISTRATION` nur ohne Bezugsstand, `HERD_CHANGE` nur mit („Erst die Erstanzeige erfassen“, 409); `TSK_REPORT` braucht `year`; mit `sentOn` im Anlegen entsteht `SENT` gleich. Neu zählen und Löschen nur im Entwurf; `openKey` = `REGISTRATION` | `HERD_CHANGE` | `TSK_REPORT:<Jahr>` nur bei `DRAFT`; zweiter Entwurf → 409 `{ code: 'DRAFT_EXISTS', existingId }`. Mutex: Zeile `ComplianceSettings` per `lockRowForUpdate`. Einstellungen: `noticeOnColonyCountChange`/`noticeOnApiaryChange` Vorgabe `true`, `tskReferenceDay` Vorgabe `01-01` (Format `MM-TT`, `02-29` und `1-1` → 400), `tskReportDueDay` leer, `tskExcludedColonyTypes` leer. Lebende Völker über `LIVING_COLONY_WHERE` an aktiven Ständen. Nachholfenster der Tagesauswertung 7 Tage.
- Prüfbereitschaft (Spec 5.7): 18 Prüfpunkte in fünf Bereichen (`STAMMDATEN`, `BEHOERDEN`, `TIERARZNEIMITTEL`, `ETIKETTEN`, `ERNTE`), Schwere `PFLICHT` | `PRUEFEN`; Quittieren nur bei `acknowledgeable`, Notiz 10–2000 Zeichen, Fingerabdruck muss zum aktuell berechneten passen (sonst 409), Quittung zurücknehmbar; Behandlungen der letzten 6 Kalenderjahre, Ernten der letzten 2 Jahre (`HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2`); `subjects` höchstens 50, Rest in `more`. Jeder offene Punkt ist rot, Schwere steht als Wort („Pflicht“/„Prüfen“) daneben. - Prüfbereitschaft (Spec 5.7): 18 Prüfpunkte in fünf Bereichen (`STAMMDATEN`, `BEHOERDEN`, `TIERARZNEIMITTEL`, `ETIKETTEN`, `ERNTE`), Schwere `PFLICHT` | `PRUEFEN`; Quittieren nur bei `acknowledgeable`, Notiz 10–2000 Zeichen, Fingerabdruck muss zum aktuell berechneten passen (sonst 409), Quittung zurücknehmbar; Behandlungen der letzten 6 Kalenderjahre, Ernten der letzten 2 Jahre (`HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2`); `subjects` höchstens 50, Rest in `more`. Jeder offene Punkt ist rot, Schwere steht als Wort („Pflicht“/„Prüfen“) daneben.
- Meldungen: höchstens eine ungelesene je Thema und Nutzer (`relatedType = 'ComplianceItem'`, `relatedId` `herd-change` bzw. `tsk-report:<Jahr>`). Texte exakt: „Bestand geändert: jetzt {n} Völker an {m} Ständen. Angezeigt: {a} an {b}. Anzeige ans Veterinäramt prüfen.“ (Priorität `HINWEIS`), „TSK-Stichtag {TT.MM.JJJJ}: {n} Völker gezählt. Bitte prüfen und melden.“ (`WARNUNG`). - Meldungen: höchstens eine ungelesene je Thema und Nutzer (`relatedType = 'ComplianceItem'`, `relatedId` `herd-change` bzw. `tsk-report:<Jahr>`), Kategorie `SYSTEM` (T4 hat keine Behörden-Kategorie; Telegram bekommt sie nur, wenn der Nutzer `SYSTEM` unter seinen Telegram-Kategorien gewählt hat, Vorgabe sind `TASK_DUE` und `DIGEST`; Texte unter 600 Zeichen, ohne Adresse, also im Telegram-Format; der Link steht in `link`, nicht im Text). Texte exakt: „Bestand geändert: jetzt {n} Völker an {m} Ständen. Angezeigt: {a} an {b}. Anzeige ans Veterinäramt prüfen.“ (Priorität `HINWEIS`), „TSK-Stichtag {TT.MM.JJJJ}: {n} Völker gezählt. Bitte prüfen und melden.“ (`WARNUNG`).
- Eingaben: `MaxLength` für jeden Freitext (Namen 200, Anschriften 300, Begründungen 500, Notizen 2000); Mengen `IsPositive` und endlich; IDs im cuid-Zeichensatz (`/^[A-Za-z0-9_-]+$/`). - Eingaben: `MaxLength` für jeden Freitext (Namen 200, Anschriften 300, Begründungen 500, Notizen 2000); Mengen `IsPositive` und endlich; IDs im cuid-Zeichensatz (`/^[A-Za-z0-9_-]+$/`).
- Keine Arzneimittel- oder Rechtsdaten im Code oder Seed: Der Katalog startet leer; Wächter `seed-no-medicines.spec.ts`. - Keine Arzneimittel- oder Rechtsdaten im Code oder Seed: Der Katalog startet leer; Wächter `seed-no-medicines.spec.ts`.
**Projektregeln** **Projektregeln**
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter. - Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter.
- Datensatz, Audit und Ereignis in **einer** Transaktion. Handler sind reine DB-Schreiber (nur `tx`, keine Netzaufrufe, `handler-contract.spec.ts` deckt `compliance.handlers.ts` ab). Zeitvergleiche in SQL gegen `now()`. - Datensatz, Audit und Ereignis in **einer** Transaktion. Handler sind reine DB-Schreiber (nur `tx`, keine Netzaufrufe, `handler-contract.spec.ts` deckt `compliance.handlers.ts` ab). Zeitvergleiche in SQL gegen `now()`.
- **Read-modify-write nur unter Zeilensperre:** `before` wird **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` gelesen. Alle neuen Tabellen kommen in `LOCKABLE_TABLES` – in **einem** Task (Task 1). Sperr-Reihenfolge = Reihenfolge der Map; die T6-Tabellen stehen hinter den bisherigen (`Colony` → … → `Treatment` → `VeterinaryMedicine` → `VeterinaryMedicineLot` → `ComplianceSettings` → `AuthorityNotice` → …). Bestehende Services, die T6 anfasst und `before` außerhalb lesen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`, `ApiariesService.*`), stellt der jeweilige Task auf die Sperre um. - **Read-modify-write nur unter Zeilensperre:** `before` wird **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` gelesen. Alle neuen Tabellen kommen in `LOCKABLE_TABLES` – in **einem** Task (Task 1). Sperr-Reihenfolge = Reihenfolge der Map. **Offene Entscheidung des Inhabers, hier umgesetzt: `Apiary` sperrt vor `Colony`.** Task 1 zieht `Apiary` an die erste Stelle der Map (heute steht es hinter `NotificationPreference`); die übrigen T6-Tabellen stehen hinter den bisherigen (`Apiary` → `Colony` → … → `Treatment` → … → `NotificationDelivery` → `LabelTemplate` → `SiteSettings` → `VeterinaryMedicine` → `VeterinaryMedicineLot` → `ComplianceSettings` → `AuthorityNotice` → `ApiaryRestriction` → `ComplianceAcknowledgement`; `Product` steht schon in der Map). Die Völker-Pfade, die Stände und Völker zusammen sperren, nehmen zuerst die Stände (`lockRowsForShare(tx, 'Apiary', ids)`, aufsteigend), dann das Volk (Task 8). `ApiaryRestrictionsService` sperrt die betroffenen Stände mit `lockRowsForUpdate`, damit eine Sperre und ein laufendes Umsetzen sich hintereinander einreihen. Bestehende Services, die T6 anfasst und `before` außerhalb lesen (`SiteSettingsService.update`, `ProductsService.update`, `LabelTemplatesService.update`, `ApiariesService.*`), stellt der jeweilige Task auf die Sperre um (`LabelTemplatesService.update` liest schon in der Transaktion; prüfen, ob `before` nach der Sperre kommt).
- Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20). Neue oder geänderte Update-DTOs stehen in einem Null-Vertrags-Spec (Task 1 liefert dafür `describeNullContracts`; jede T6-Task legt ihre Verträge in eine **eigene** Spec-Datei, damit parallele Tasks nicht dieselbe Datei ändern). - Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20). Neue oder geänderte Update-DTOs stehen in einem Null-Vertrags-Spec (Task 1 liefert dafür `describeNullContracts`; jede T6-Task legt ihre Verträge in eine **eigene** Spec-Datei, damit parallele Tasks nicht dieselbe Datei ändern).
- Worker und Jobs nutzen den resolver-only `JobPermissionsModule`, nicht `PermissionsModule`: Kein Provider eines Moduls, das `WorkerModule` importiert, darf `PermissionResolverService` injizieren (die Prüfung von `restrictionOverrideReason` liegt darum in `ColoniesService`). Jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert. - Worker und Jobs nutzen den resolver-only `JobPermissionsModule`, nicht `PermissionsModule`: Kein Provider eines Moduls, das `WorkerModule` importiert, darf `PermissionResolverService` injizieren (die Prüfung von `restrictionOverrideReason` liegt darum in `ColoniesService`). Jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert.
- UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht (`DetailHeader`), seltene Felder eingeklappt (`FormSection`), Felder 44 px; Zustand nie nur über Farbe (Symbol + Wort); roter Text mit ≥ 4,5:1 (Token `destructive`); WCAG 2.2 AA; `aria-live="polite"` für Live-Listen; ein gesperrter Druckknopf bleibt fokussierbar (`aria-disabled` + `aria-describedby`); nach einem 409 springt der Fokus auf die Lückenliste. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch. - UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht (`DetailHeader`), seltene Felder eingeklappt (`FormSection`), Felder 44 px; Zustand nie nur über Farbe (Symbol + Wort); roter Text mit ≥ 4,5:1 (Token `destructive`); WCAG 2.2 AA; `aria-live="polite"` für Live-Listen; ein gesperrter Druckknopf bleibt fokussierbar (`aria-disabled` + `aria-describedby`); nach einem 409 springt der Fokus auf die Lückenliste. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch.
- Fehler und Logs nur über `loggableError()`, nie Payload oder Freitext. PDFs mit `Cache-Control: no-store`, Dateinamen ohne Nutzereingaben (`anzeige-<id>.pdf`, `pruefbericht.pdf`). - Fehler und Logs nur über `loggableError()`, nie Payload oder Freitext. PDFs mit `Cache-Control: no-store`, Dateinamen ohne Nutzereingaben (`anzeige-<id>.pdf`, `pruefbericht.pdf`).
- TDD: zuerst der fehlschlagende Test; Testnamen englisch. **Agents committen nicht.** Jeder Task endet mit „Verifikation“, „kein Commit“ und einer Dateiliste an den Orchestrator. - TDD: zuerst der fehlschlagende Test; Testnamen englisch. **Agents committen nicht.** Jeder Task endet mit „Verifikation“, „kein Commit“ und einer Dateiliste an den Orchestrator.
- Backend-Checks: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`; einzelne e2e-Datei `npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand`. Frontend: vor Next.js-Code `node_modules/next/dist/docs/` lesen (`AGENTS.md`); Checks `npm run lint && npm run typecheck && npx vitest run <pfade>`. - Backend-Checks: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci` (nie `npm run lint`, das Skript hat `--fix`); einzelne e2e-Datei `npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand`; alle e2e `npm run test:e2e -- --runInBand` (die Konfiguration hat `maxWorkers: 4` gegen **eine** Test-DB, `testTimeout` 20 s je Test: Tests mit PDF, Sammelerfassung oder `drainDomainEvents` schlank halten). Frontend: vor Next.js-Code `node_modules/next/dist/docs/` lesen (`AGENTS.md`); Checks `npm run lint && npm run typecheck && npx vitest run <pfade>` (`npm run test` = `vitest run`).
- **Die Test-DB ist nicht leer** (Reste früherer Läufe, Völker, Stände, Nutzer mit Rechten). E2E-Tests legen ihre Daten selbst an, erwarten nur das, was sie selbst erzeugt haben (Zahlen als Differenz vor/nach, Prüfpunkte über `key` + `subjectId`, Meldungen für die eigenen Testnutzer), und räumen T6-eigene Tabellen (`ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement`, `VeterinaryMedicine*`) in `beforeEach` per `deleteMany` auf. Sätze wie „ein leeres System“ oder „READY“ gelten für den **eigenen Ausschnitt** (die eigenen Stände/Völker/Vorlagen), nie für die ganze Datenbank; wo der Gesamtstatus gebraucht wird, prüft der Test die Regeln einzeln über `READINESS_RULES` mit einem selbst gebauten `ReadinessContext`.
- `ARCHITECTURE.md` und `TASKS.md` trägt der Orchestrator ein; jeder Task liefert seinen Doku-Text im Bericht („Docs“). Neue Annahmen bekommen die **nächste freie Nummer** zum Zeitpunkt des Eintrags. - `ARCHITECTURE.md` und `TASKS.md` trägt der Orchestrator ein; jeder Task liefert seinen Doku-Text im Bericht („Docs“). Neue Annahmen bekommen die **nächste freie Nummer** zum Zeitpunkt des Eintrags.
--- ---
@@ -110,11 +113,12 @@
Geteilte Dateien, deshalb je Welle nur ein Task: Geteilte Dateien, deshalb je Welle nur ein Task:
- `prisma/schema.prisma`, `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec), `src/auth/permissions/permission-catalog.ts`: nur Task 1. - `prisma/schema.prisma`, `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec), `src/auth/permissions/permission-catalog.ts`: nur Task 1.
- `src/app.module.ts`: 2 (W2), 9 (W3), 12 (W4); `src/worker/worker.module.ts`: nur 11. - `src/app.module.ts`: 2 (W2), 9 (W3), 12 (W4); `src/worker/worker.module.ts`: nur 11.
- `src/domain-events/domain-event-types.ts`, `src/colonies/colonies.service.ts`, `src/apiaries/apiaries.service.ts`: 7 (W2), dann 8 (W3, nur `colonies.service.ts` und DTOs). - `src/domain-events/domain-event-types.ts`, `src/colonies/colonies.service.ts`, `src/apiaries/apiaries.service.ts`: 7 (W2), dann 8 (W3, nur `colonies.service.ts` und DTOs). Von T4 geteilt und hier nur gelesen: `src/inspections/inspections.service.ts` (ruft `colonies.updateIn`; Task 8 ändert es nicht, nur den Vertrag von `updateIn`), `src/autopilot/autopilot.service.ts` (`syncApiaryIn`, Reihenfolge Stand → Volk ist schon die neue; nur Task 1 passt den Spec an), `src/honey-batches/honey-plausibility.ts` (`roundKg`): nur 2.
- `src/notifications/notifications.service.ts`, `src/jobs/daily-evaluation.rules.ts` (+ `src/jobs/steps/time-events.step.ts`, falls die Union der Ereignistypen dort gespiegelt wird): nur 11; `src/worker/worker.module.ts`: nur 11.
- `src/compliance/compliance.module.ts`: 9 (W3), 11 (W4); `authority-notices.controller.ts`: 9 (W3), 10 (W4); `src/compliance/readiness/readiness-context.ts` und `rules/index.ts`: 12 (W4), 13 (W5). - `src/compliance/compliance.module.ts`: 9 (W3), 11 (W4); `authority-notices.controller.ts`: 9 (W3), 10 (W4); `src/compliance/readiness/readiness-context.ts` und `rules/index.ts`: 12 (W4), 13 (W5).
- `src/label-templates/label-templates.service.ts`, `label-element.types.ts`: 5 (W2), 6 (W3). - `src/label-templates/label-templates.service.ts`, `label-element.types.ts`: 5 (W2), 6 (W3).
- `src/export/treatment-log.ts`, `src/treatments/*`: nur 3. `src/documents/*`: nur 4. `src/notifications/notifications.service.ts`, `src/jobs/daily-evaluation.rules.ts`: nur 11. - `src/export/treatment-log.ts`, `src/treatments/*`: nur 3. `src/documents/*`: nur 4. `src/notifications/notifications.service.ts`, `src/jobs/daily-evaluation.rules.ts`: nur 11.
- Frontend: `src/app/(app)/nav-config.ts` nur 14; `src/lib/compliance/types.ts` 14 (W5), danach nur lesen; `src/app/(app)/colonies/[id]/*`: 16 (W6, nur `treatment-edit-dialog.tsx` und `page.tsx`) und 19 (W6, nur `colony-actions.tsx`, `colony-edit-card.tsx`) – disjunkte Dateien; `src/app/(app)/storage/page.tsx`: nur 15. - Frontend: `src/app/(app)/nav-config.ts`, `src/lib/system/labels.ts` (+ `labels.spec.ts`), `public/sw-routes.js` + `public/sw.js` (`SW_ROUTES_VERSION`) + `src/lib/offline/sw-routes.spec.ts`, `src/lib/compliance/errors.ts`: nur 14 (18 und 19 lesen nur); `src/lib/documents/types.ts` (`DocumentEntityType` += die drei neuen Arten): nur 15 (W5; 18 und 19 in W6 lesen nur); `src/lib/compliance/types.ts` 14 (W5), danach nur lesen; `src/app/(app)/colonies/[id]/*`: 16 (W6, nur `treatment-edit-dialog.tsx` und `page.tsx`) und 19 (W6, nur `colony-actions.tsx`, `colony-edit-card.tsx`) – disjunkte Dateien; `src/app/(app)/storage/page.tsx`: nur 15.
- `ARCHITECTURE.md`, `TASKS.md`, `docs/pruefung-2026-09.md`: nur der Orchestrator. - `ARCHITECTURE.md`, `TASKS.md`, `docs/pruefung-2026-09.md`: nur der Orchestrator.
--- ---
@@ -149,10 +153,15 @@ Geteilte Dateien, deshalb je Welle nur ein Task:
export interface NullContract { dto: new () => object; notNull: string[]; clearable: string[] } export interface NullContract { dto: new () => object; notNull: string[]; clearable: string[] }
export function describeNullContracts(title: string, contracts: Record<string, NullContract>): void; // registriert die drei Prüfungen je Vertrag (leerer Patch, null auf notNull -> 400 genau dieses Felds, null auf clearable -> ok) export function describeNullContracts(title: string, contracts: Record<string, NullContract>): void; // registriert die drei Prüfungen je Vertrag (leerer Patch, null auf notNull -> 400 genau dieses Felds, null auf clearable -> ok)
// permission-catalog.ts: veterinary-medicines {read 'Ansehen', create 'Anlegen', update 'Bearbeiten'} Gruppe 'Tierarzneimittel'; // permission-catalog.ts: veterinary-medicines {read 'Ansehen', create 'Anlegen', update 'Bearbeiten'} Gruppe 'Tierarzneimittel';
// compliance {read 'Ansehen', manage 'Verwalten'} Gruppe 'Prüfbereitschaft & Behörden'; sortOrder hinter dem höchsten vorhandenen Wert // compliance {read 'Ansehen', manage 'Verwalten'} Gruppe 'Prüfbereitschaft & Behörden'; sortOrder 3700 und 3800 (der höchste vorhandene Wert ist 3600, `erp-inventory`)
// row-lock.ts: LOCKABLE_TABLES += Apiary, LabelTemplate, Product, SiteSettings, VeterinaryMedicine, VeterinaryMedicineLot, // row-lock.ts (Schlüssel jeweils "id"; ComplianceSettings und SiteSettings haben die Zeile "singleton"):
// ComplianceSettings, AuthorityNotice, ApiaryRestriction, ComplianceAcknowledgement (alle Schlüssel "id"), in dieser Reihenfolge hinter den bisherigen Einträgen // 1. Apiary rückt an die ERSTE Stelle der Map (vor Colony) und verschwindet aus der alten Position hinter NotificationPreference
// 2. LOCKABLE_TABLES += LabelTemplate, SiteSettings, VeterinaryMedicine, VeterinaryMedicineLot, ComplianceSettings, AuthorityNotice,
// ApiaryRestriction, ComplianceAcknowledgement, in dieser Reihenfolge hinter NotificationDelivery (Product und Apiary stehen schon in der Map, nicht doppelt eintragen)
// neue Reihenfolge: Apiary, Colony, Suggestion, Task, Queen, AutomationRule, BottlingBatch, Feeding, Harvest, Inspection, Product, ProductionBatch,
// RawMaterialLot, StockItem, Treatment, VarroaMeasurement, NotificationPreference, PhenologyReport, Extraction, HoneyBatch, NotificationDelivery, <die acht T6-Tabellen>
``` ```
- **Lock-Reihenfolge Stand → Volk.** Heute sperrt genau ein Pfad Stand und Volk in einer Transaktion: `AutopilotService.syncApiaryIn` (`src/autopilot/autopilot.service.ts`), und zwar schon Stand zuerst, dann alle Völker in einer Anweisung. Er braucht keine Codeänderung; nur der Kommentar der Map und `row-lock.spec.ts` ziehen nach. Kein bestehender Pfad sperrt Volk vor Stand. Die Aufrufer, die T6 für die neue Reihenfolge anpasst (vollständige Liste, alle in Task 8, Task 7 nur für Apiaries): `ColoniesService.create` (Stand vor dem Anlegen, `assertApiaryActive` läuft dabei über `tx` statt vor der Transaktion), `ColoniesService.update` (liest den Stand ungesperrt, sperrt alter und neuer Stand, dann `lockColony`, prüft unter der Sperre, dass der Stand unverändert ist, sonst 409), `ColoniesService.move` (ebenso), `ColoniesService.merge` (Stände aller beteiligten Völker, dann `lockRowsForUpdate` der Völker), `ApiariesService.update/deactivate/reactivate` (nur Stand, Task 7), `ApiaryRestrictionsService.create/update` (nur Stände, `FOR UPDATE`). Nicht geändert, aber zu schützen: `InspectionsService.create` → `ColoniesService.updateIn` läuft schon unter der Volkssperre, der Übernahme-Patch enthält nie `apiaryId` (`colony-takeover.ts`), `updateIn` sperrt darum **keinen** Stand; ein Test hält das fest.
- [ ] **Schritt 1: Failing Tests** - [ ] **Schritt 1: Failing Tests**
```ts ```ts
@@ -171,8 +180,10 @@ it('STAFF holds compliance:read and veterinary-medicines:create but not complian
it('READONLY holds the two read permissions only', ...); it('READONLY holds the two read permissions only', ...);
it('API_CLIENT holds none of the five new permissions', ...); it('API_CLIENT holds none of the five new permissions', ...);
// row-lock.spec.ts // row-lock.spec.ts
it('lists the T6 tables after Treatment in lock order', ...); it('keeps the map in lock order: Apiary before Colony, the T6 tables last', ...); // ersetzt den bestehenden Test 'keeps the map in lock order: Colony, Suggestion, ...' (Liste LOCK_ORDER vollständig)
it('locks ComplianceSettings by id', ...); it('every table of the map is lockable by id and each appears once', ...); // erfasst das Doppel-Eintragen von Product/Apiary
it.each([...T6-Tabellen])('locks %s by "id"', ...); // ergänzt die bestehende it.each-Liste
// autopilot.service.spec.ts: der bestehende Test, der ['Apiary','api-1'] vor den Völkern erwartet, bleibt grün (Wächter für die Reihenfolge)
// backup.service.spec.ts // backup.service.spec.ts
it('no T6 model is excluded from the backup or listed as legacy-unsealed', ...); it('no T6 model is excluded from the backup or listed as legacy-unsealed', ...);
it('restores medicine, lot, treatment and movement in foreign-key order', ...); it('restores medicine, lot, treatment and movement in foreign-key order', ...);
@@ -197,7 +208,7 @@ it('keeps every existing contract green after the move to describeNullContracts'
**Files** **Files**
- Create: `src/common/quantity.ts` (+ Spec), `src/veterinary-medicines/medicine-ledger.ts` (+ Spec), `veterinary-medicines.module.ts`, `veterinary-medicines.service.ts` (+ Spec), `veterinary-medicine-lots.service.ts` (+ Spec), `veterinary-medicines.controller.ts`, `veterinary-medicine-lots.controller.ts`, `dto/create-veterinary-medicine.dto.ts`, `update-veterinary-medicine.dto.ts`, `create-lot.dto.ts`, `update-lot.dto.ts`, `create-movement.dto.ts`, `medicine-list-query.dto.ts`, `lot-list-query.dto.ts`, `veterinary-medicines-null-contract.spec.ts`, `src/seed-no-medicines.spec.ts` (Wächter: `prisma/seed.ts` legt keine Tierarzneimittel an) - Create: `src/common/quantity.ts` (+ Spec), `src/veterinary-medicines/medicine-ledger.ts` (+ Spec), `veterinary-medicines.module.ts`, `veterinary-medicines.service.ts` (+ Spec), `veterinary-medicine-lots.service.ts` (+ Spec), `veterinary-medicines.controller.ts`, `veterinary-medicine-lots.controller.ts`, `dto/create-veterinary-medicine.dto.ts`, `update-veterinary-medicine.dto.ts`, `create-lot.dto.ts`, `update-lot.dto.ts`, `create-movement.dto.ts`, `medicine-list-query.dto.ts`, `lot-list-query.dto.ts`, `veterinary-medicines-null-contract.spec.ts`, `src/seed-no-medicines.spec.ts` (Wächter: `prisma/seed.ts` legt keine Tierarzneimittel an)
- Modify: `src/bottling/bottling.service.ts` (`roundKg` → `roundToThousandths`), `src/app.module.ts` - Modify: `src/bottling/bottling.service.ts` (die private `roundKg` entfällt, Aufrufer nehmen `roundToThousandths`), `src/honey-batches/honey-plausibility.ts` (T4 hat dort ein **exportiertes** `roundKg` mit gleichem Rumpf angelegt, Aufrufer u. a. `extractions.service.ts`: `export const roundKg = roundToThousandths;`, damit es nicht drei Kopien gibt; die private Kopie in `src/backup/legacy-honey-ledger.ts` bleibt, sie ist ein eingefrorener Altpfad), `src/app.module.ts`
- Test: neu `test/veterinary-medicines.e2e-spec.ts`; `test/bottling.e2e-spec.ts` (läuft unverändert grün) - Test: neu `test/veterinary-medicines.e2e-spec.ts`; `test/bottling.e2e-spec.ts` (läuft unverändert grün)
- Docs: neue Annahme „Katalog und Packungen“ (eigenes Bewegungsbuch statt `StockItem`/`BatchMovement`, Rest berechnet, negativer Rest erlaubt, Katalog startet leer – Annahme 10). - Docs: neue Annahme „Katalog und Packungen“ (eigenes Bewegungsbuch statt `StockItem`/`BatchMovement`, Rest berechnet, negativer Rest erlaubt, Katalog startet leer – Annahme 10).
@@ -271,7 +282,7 @@ it('a movement can neither be changed nor deleted (no such routes)', ...);
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci` - typecheck, lint, `npm test -- --ci`
- `npx jest --config ./test/jest-e2e.json test/veterinary-medicines.e2e-spec.ts test/bottling.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS - `npx jest --config ./test/jest-e2e.json test/veterinary-medicines.e2e-spec.ts test/bottling.e2e-spec.ts test/extractions.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
- kein Commit - kein Commit
--- ---
@@ -342,7 +353,7 @@ it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and
``` ```
- [ ] **Schritt 2:** `npx jest src/treatments src/export` und die e2e-Dateien → FAIL. - [ ] **Schritt 2:** `npx jest src/treatments src/export` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- `treatmentRow` bleibt die einzige Datenaufbereitung für `create`, `createBulk` und (Teilmenge) `update`; `resolveMedicineIn` wird aus allen drei Pfaden aufgerufen. Bulk: Prüfung einmal, Buchung je Volk in `recordIn` (nach dem `tx.treatment.create`, vor Audit). - `treatmentRow` (freie Modulfunktion, spreizt `...dto`) bleibt die einzige Datenaufbereitung für `create` und `createBulk`; `update` baut seine Daten weiter selbst (`...dto`, plus neu `apiaryId`), ruft aber wie die beiden anderen `resolveMedicineIn`. Die neuen Spalten `medicineId`/`medicineLotId`/`doseQuantity` fließen über das Spreizen; `supplier`/`purchaseProof`/`batchNumber`/`prescribingVet` bleiben bei gewählter Packung leer (der Server lehnt sie sonst ab, der Aufbereiter legt sie nie still an). Bulk: Prüfung einmal (vor `runBulk`), Buchung je Volk in `recordIn(tx, data, actingUserId, bulkId?)` (nach dem `tx.treatment.create`, vor Audit); `create` sperrt das Volk weiter mit `lockRowForUpdate(tx, 'Colony', …)` und die Packung danach (Reihenfolge der Map: Volk vor Packung).
- `update`: nach `lockRowForUpdate(tx, 'Treatment', id)` und gelesenem `before` die betroffenen Packungen (alte und neue) in aufsteigender ID sperren (`lockRowForUpdate(tx, 'VeterinaryMedicineLot', …)`), Summe der bisherigen `TREATMENT`-Bewegungen je Packung lesen, `treatmentBookingDelta` anwenden. Der zusammengeführte Zustand (`before` + DTO) wird geprüft, nicht das DTO allein; `null` leert das Feld. - `update`: nach `lockRowForUpdate(tx, 'Treatment', id)` und gelesenem `before` die betroffenen Packungen (alte und neue) in aufsteigender ID sperren (`lockRowForUpdate(tx, 'VeterinaryMedicineLot', …)`), Summe der bisherigen `TREATMENT`-Bewegungen je Packung lesen, `treatmentBookingDelta` anwenden. Der zusammengeführte Zustand (`before` + DTO) wird geprüft, nicht das DTO allein; `null` leert das Feld.
- Buchung: `tx.veterinaryMedicineMovement.create({ type: 'TREATMENT', quantity, treatmentId, lotId, userId })`; Audit der Behandlung bleibt unverändert, `afterData` enthält `medicineLotId`/`doseQuantity`. - Buchung: `tx.veterinaryMedicineMovement.create({ type: 'TREATMENT', quantity, treatmentId, lotId, userId })`; Audit der Behandlung bleibt unverändert, `afterData` enthält `medicineLotId`/`doseQuantity`.
- `treatment-log.ts`: `buildTreatmentLog` lädt `medicine` und `medicineLot` per `include`, ruft `resolveSourceValues`, Gruppierungsschlüssel über die aufgelösten Werte; `renderSheet` ruft `treatmentLogGaps(row)` und druckt `MISSING` genau bei gemeldeter Lücke (Tierarzt: Spalte „a) Lieferant …“). Fußnote ergänzen. Layout unverändert. - `treatment-log.ts`: `buildTreatmentLog` lädt `medicine` und `medicineLot` per `include`, ruft `resolveSourceValues`, Gruppierungsschlüssel über die aufgelösten Werte; `renderSheet` ruft `treatmentLogGaps(row)` und druckt `MISSING` genau bei gemeldeter Lücke (Tierarzt: Spalte „a) Lieferant …“). Fußnote ergänzen. Layout unverändert.
@@ -371,7 +382,7 @@ export const DOCUMENT_ENTITY_PERMISSIONS: Partial<Record<DocumentEntityType, { r
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' }, APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
}; };
DocumentsService.assertEntityAccess(userId: string, entityType: DocumentEntityType, mode: 'read' | 'write'): Promise<void>; // 403 ohne Recht; Arten ohne Eintrag: nichts zusätzlich DocumentsService.assertEntityAccess(userId: string, entityType: DocumentEntityType, mode: 'read' | 'write'): Promise<void>; // 403 ohne Recht; Arten ohne Eintrag: nichts zusätzlich
// findAll/create/readFile/update/delete bekommen `actingUserId`; update prüft alte UND neue Art; assertEntityExists kennt die drei neuen Arten (404 wie bisher) // create/update/delete haben `actingUserId` schon (für Audit und Upload); neu bekommen ihn `findAll` und `readFile` (Controller reicht `actor.id` durch); update prüft alte UND neue Art; assertEntityExists kennt die drei neuen Arten (404 wie bisher)
``` ```
- [ ] **Schritt 1: Failing Tests** - [ ] **Schritt 1: Failing Tests**
@@ -506,6 +517,7 @@ it('the public origin page exposes neither foodBusinessAddress nor legalName', .
**Interfaces** **Interfaces**
- Consumes: Task 1, Task 5 (`checkLabel`, `SAMPLE_BOTTLING`, `buildPlaceholderValues`). - Consumes: Task 1, Task 5 (`checkLabel`, `SAMPLE_BOTTLING`, `buildPlaceholderValues`).
- Ändert eine bestehende Signatur: `renderLabelsPdf(templateId, bottlingBatchId, startPosition = 1)` wird zu `renderLabelsPdf(templateId, bottlingBatchId, options, actingUserId)`. Aufrufer: `label-templates.controller.ts` und `label-templates.service.spec.ts` (beide in diesem Task anpassen); `buildPlaceholderValues` ist eine freie exportierte Funktion mit einem Parameter (Task 5 gibt ihr den zweiten).
- Produces: - Produces:
```ts ```ts
export class CheckLabelTemplateDto { // POST /label-templates/check; nur Text-Elemente export class CheckLabelTemplateDto { // POST /label-templates/check; nur Text-Elemente
@@ -574,7 +586,11 @@ ColonyCreated: { apiaryId: string };
ColonyMoved: { fromApiaryId: string; toApiaryId: string }; ColonyMoved: { fromApiaryId: string; toApiaryId: string };
ApiaryChanged: { change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED' }; ApiaryChanged: { change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED' };
TskReferenceDayReached: { year: number; referenceDay: string }; // referenceDay = YYYY-MM-DD des Stichtags TskReferenceDayReached: { year: number; referenceDay: string }; // referenceDay = YYYY-MM-DD des Stichtags
// AggregateType += 'Apiary' | 'Compliance'; CATALOG += die vier Typen // AggregateType += 'Compliance' ('Apiary' gibt es seit T4b schon); CATALOG += die vier Typen (der Record über die Union erzwingt es);
// Handler-Registrierung: die Erzeuger (ColoniesService, ApiariesService) laufen nur in der API, die Handler (Task 11) liegen in `ComplianceModule`,
// das AppModule UND WorkerModule importieren; `@OnDomainEvent` steht auf einem Singleton-Provider (Handler-ID `compliance.herd-change`, `compliance.tsk-reference-day`)
// Frontend: DOMAIN_EVENT_LABELS (src/lib/system/labels.ts) bekommt die vier Typen in Task 14: ColonyCreated 'Volk angelegt', ColonyMoved 'Volk umgesetzt',
// ApiaryChanged 'Stand geändert', TskReferenceDayReached 'TSK-Stichtag erreicht'
// Erzeuger: ColoniesService.create -> ColonyCreated (in der Transaktion); split -> ColonyCreated für den Ableger ZUSÄTZLICH zu ColonySplit (T4); // Erzeuger: ColoniesService.create -> ColonyCreated (in der Transaktion); split -> ColonyCreated für den Ableger ZUSÄTZLICH zu ColonySplit (T4);
// move und update mit Standwechsel -> ColonyMoved; ApiariesService.create/update/deactivate/reactivate: Datensatz + Audit(tx) + ApiaryChanged in EINER Transaktion // move und update mit Standwechsel -> ColonyMoved; ApiariesService.create/update/deactivate/reactivate: Datensatz + Audit(tx) + ApiaryChanged in EINER Transaktion
``` ```
@@ -604,7 +620,7 @@ it('updating only the name of an apiary leaves no ApiaryChanged', ...); // tes
- [ ] **Schritt 2:** `npx jest src/domain-events src/colonies src/apiaries src/common/after-commit-usage.spec.ts src/webhooks` und die e2e-Dateien → FAIL. - [ ] **Schritt 2:** `npx jest src/domain-events src/colonies src/apiaries src/common/after-commit-usage.spec.ts src/webhooks` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Der Webhook `colony.created` bleibt im Altpfad nach dem Commit. `assertApiaryActive` bleibt vor der Transaktion (Fehlerbild unverändert). Ereignisse werden über `this.events.record(tx, …)`/`recordAll` im selben `$transaction`-Block geschrieben. - Der Webhook `colony.created` bleibt im Altpfad nach dem Commit. `assertApiaryActive` bleibt vor der Transaktion (Fehlerbild unverändert). Ereignisse werden über `this.events.record(tx, …)`/`recordAll` im selben `$transaction`-Block geschrieben.
- `ApiariesService` übernimmt `DomainEventsService` und `lockRowForUpdate` (Apiary); `ApiariesModule` importiert `DomainEventsModule`. `deactivate` zählt lebende Völker nach der Sperre in der Transaktion. - `ApiariesService` hat heute keine Transaktion (`prisma.apiary.update` + `auditLog.record` getrennt, `deactivate` zählt außerhalb): alle vier Schreibpfade werden `$transaction`. Er übernimmt `DomainEventsService` und `lockRowForUpdate(tx, 'Apiary', id)`; `ApiariesModule` importiert `DomainEventsModule`. `deactivate` zählt lebende Völker nach der Sperre in der Transaktion; weil Völker-Pfade den Stand mit `FOR SHARE` halten (Task 8), wartet das Deaktivieren auf ein laufendes Umsetzen in diesen Stand.
- Die Vergleichsfelder für `LOCATION_CHANGED` kommen aus `before` (unter Sperre) gegen den gespeicherten Stand. - Die Vergleichsfelder für `LOCATION_CHANGED` kommen aus `before` (unter Sperre) gegen den gespeicherten Stand.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
@@ -635,7 +651,7 @@ export function assertNoActiveRestriction(tx: Prisma.TransactionClient, apiaryId
export function dbUtcDay(db: DbClient): Promise<string>; // utcDay(SELECT now()) export function dbUtcDay(db: DbClient): Promise<string>; // utcDay(SELECT now())
// DTOs: restrictionOverrideReason?: string (@IsOptional @IsString @MinLength(10) @MaxLength(500)) an Create/Update/Move/Merge; null gilt wie weggelassen // DTOs: restrictionOverrideReason?: string (@IsOptional @IsString @MinLength(10) @MaxLength(500)) an Create/Update/Move/Merge; null gilt wie weggelassen
// ApiaryRestrictionsService // ApiaryRestrictionsService
create(dto: CreateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction[]>; // apiaryIds 1-50, eine Zeile je Stand, eine Transaktion, Audit je Zeile; @Idempotent create(dto: CreateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction[]>; // apiaryIds 1-50, eine Zeile je Stand, eine Transaktion, Audit je Zeile; @Idempotent; zuerst `lockRowsForUpdate(tx, 'Apiary', apiaryIds)` (unbekannte Stände: 404 nach der Sperre, alles zurück); `update` sperrt den Stand der Sperre ebenso, dann `ApiaryRestriction`
update(id: string, dto: UpdateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction>; // endsOn >= startsOn auf dem zusammengeführten Stand (400), unter Sperre update(id: string, dto: UpdateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction>; // endsOn >= startsOn auf dem zusammengeführten Stand (400), unter Sperre
findActive(): Promise<ActiveRestrictionInfo[]>; findForApiary(apiaryId: string): Promise<ApiaryRestriction[]>; findActive(): Promise<ActiveRestrictionInfo[]>; findForApiary(apiaryId: string): Promise<ApiaryRestriction[]>;
// GET /apiary-restrictions?active=true, GET /apiaries/:id/restrictions (apiaries:read); POST, PATCH (compliance:manage) // GET /apiary-restrictions?active=true, GET /apiaries/:id/restrictions (apiaries:read); POST, PATCH (compliance:manage)
@@ -657,7 +673,10 @@ it('update with a changed apiary checks old and new apiary, a name change checks
it('create in a restricted apiary is refused', ...); it('create in a restricted apiary is refused', ...);
it('the override reason is never part of the data written to the colony', ...); it('the override reason is never part of the data written to the colony', ...);
it('an override writes the reason into the audit entry and into ColonyApiaryHistory.notes', ...); it('an override writes the reason into the audit entry and into ColonyApiaryHistory.notes', ...);
it('the restriction check runs after the colony row is locked, inside the transaction', ...); it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', ...); // Reihenfolge der Aufrufe auf einem Fake-tx
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', ...);
it('updateIn called from an inspection takeover locks no apiary', ...);
it('a restriction created while a move into that apiary is in flight: the move commits first, the restriction waits (FOR UPDATE vs FOR SHARE)', ...); // e2e, zwei Verbindungen
// apiary-restrictions.service.spec.ts // apiary-restrictions.service.spec.ts
it('creates one row per apiary in one transaction and rolls all back when one apiary is unknown', ...); it('creates one row per apiary in one transaction and rolls all back when one apiary is unknown', ...);
it('rejects more than 50 apiaries and an endsOn before startsOn', ...); it('rejects more than 50 apiaries and an endsOn before startsOn', ...);
@@ -674,7 +693,7 @@ it('READONLY may read restrictions but not create them (403)', ...);
``` ```
- [ ] **Schritt 2:** `npx jest src/compliance src/apiary-restrictions src/colonies` und die e2e-Dateien → FAIL. - [ ] **Schritt 2:** `npx jest src/compliance src/apiary-restrictions src/colonies` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- In `ColoniesService.create/update/move/merge`: `const { restrictionOverrideReason, ...data } = dto;` – nur `data` geht an Prisma (sonst „Unknown argument“; `UpdateColonyDto` ist per `PickType` gebaut und bekommt das Feld ausdrücklich). Berechtigung einmal pro Anfrage: `canOverride = (await resolver.getPermissions(userId)).has('compliance:manage')`. Prüfung **innerhalb** der Transaktion nach `lockColony` (bei `create` vor `tx.colony.create`), Tag aus `dbUtcDay(tx)`. - In `ColoniesService.create/update/move/merge`: `const { restrictionOverrideReason, ...data } = dto;` – nur `data` geht an Prisma (sonst „Unknown argument“; `UpdateColonyDto` ist per `PickType` gebaut und bekommt das Feld ausdrücklich). Berechtigung einmal pro Anfrage: `canOverride = (await resolver.getPermissions(userId)).has('compliance:manage')`. Prüfung **innerhalb** der Transaktion, **nach den Sperren Stand → Volk** (bei `create` nach der Standsperre, vor `tx.colony.create`), Tag aus `dbUtcDay(tx)`. Sperrmuster (Lock-Reihenfolge, Task 1): (1) Stand-IDs ungesperrt lesen (`move`/`update`: `colony.apiaryId` plus Ziel; `merge`: Stände von Überlebendem und allen vereinigten Völkern), (2) `lockRowsForShare(tx, 'Apiary', ids)` aufsteigend, (3) `lockColony` bzw. `lockRowsForUpdate(tx, 'Colony', …)`, (4) unter der Sperre prüfen, dass `apiaryId` noch der gelesene ist, sonst 409 „Das Volk wurde inzwischen umgesetzt. Bitte neu laden.“, (5) `assertNoActiveRestriction`. `assertApiaryActive` läuft dabei über `tx` (heute über `this.prisma` vor der Transaktion). `updateIn(tx, …)` sperrt keinen Stand selbst: `update()` macht Schritte 1–2 und ruft `updateIn` danach; der Aufruf aus `InspectionsService` (unter Volkssperre, Patch ohne `apiaryId`) bleibt unverändert.
- `move`: Quell- und Zielstand; `update`: alter und neuer Stand nur bei Wechsel; `merge`: alle beteiligten Stände, nur wenn mehr als ein verschiedener Stand beteiligt ist; `split`: keine Prüfung. - `move`: Quell- und Zielstand; `update`: alter und neuer Stand nur bei Wechsel; `merge`: alle beteiligten Stände, nur wenn mehr als ein verschiedener Stand beteiligt ist; `split`: keine Prüfung.
- Override: `notes` der `ColonyApiaryHistory` = `Ausnahme Sperrbezirk: <Begründung>` (bei `update`/`move` vor ein vorhandenes `notes` gestellt), Audit `afterData` bekommt `restrictionOverride: { reason, restrictionIds }`. - Override: `notes` der `ColonyApiaryHistory` = `Ausnahme Sperrbezirk: <Begründung>` (bei `update`/`move` vor ein vorhandenes `notes` gestellt), Audit `afterData` bekommt `restrictionOverride: { reason, restrictionIds }`.
- `ApiaryRestrictionsModule` injiziert selbst keinen Resolver und wird nicht in `WorkerModule` benötigt. - `ApiaryRestrictionsModule` injiziert selbst keinen Resolver und wird nicht in `WorkerModule` benötigt.
@@ -696,7 +715,7 @@ it('READONLY may read restrictions but not create them (403)', ...);
- Docs: Annahme „Behördenpaket und Sperrbezirk“ (Bezugsstand, Abweichung, Lebenszyklus, Mutex, `totalColonies` ist die Zahl der Anzeige und bleibt korrigierbar). - Docs: Annahme „Behördenpaket und Sperrbezirk“ (Bezugsstand, Abweichung, Lebenszyklus, Mutex, `totalColonies` ist die Zahl der Anzeige und bleibt korrigierbar).
**Interfaces** **Interfaces**
- Consumes: Task 1, `LIVING_COLONY_WHERE`, `utcDay`. - Consumes: Task 1, `LIVING_COLONY_WHERE`, `berlinToday`/`dayToDate` (`src/common/dates.ts`). Tests: Die Test-DB ist nicht leer (Projektregeln): „REGISTRATION nur ohne Bezugsstand“ und „Bezugsstand“ laufen nach `authorityNotice.deleteMany()`; Zählungen (`totalLiving`) vergleicht der Test gegen die Zahl, die `loadHerdApiaryRows` im selben Test liefert.
- Produces: - Produces:
```ts ```ts
// tsk-day.ts // tsk-day.ts
@@ -765,9 +784,9 @@ it('a SENT registration with sentOn in the past is the baseline of the next herd
``` ```
- [ ] **Schritt 2:** `npx jest src/compliance` und `test/authority-notices.e2e-spec.ts` → FAIL. - [ ] **Schritt 2:** `npx jest src/compliance` und `test/authority-notices.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- `create`: Transaktion, Einstellungszeile per Upsert sicherstellen, `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')` als Mutex, Bezugsstand lesen, Regeln nach Global Constraints prüfen, Bestandsbild zählen (`loadHerdApiaryRows(tx)` → `buildHerdSnapshot`), `openKey` nur bei `DRAFT`; Konflikt (vorhandener Entwurf oder `P2002` auf `openKey`) → 409 `DRAFT_EXISTS` mit `existingId`. `referenceDate` Vorgabe: heutiger UTC-Tag der DB-Uhr. - `create`: Transaktion, Einstellungszeile per Upsert sicherstellen, `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')` als Mutex, Bezugsstand lesen, Regeln nach Global Constraints prüfen, Bestandsbild zählen (`loadHerdApiaryRows(tx)` → `buildHerdSnapshot`), `openKey` nur bei `DRAFT`; Konflikt (vorhandener Entwurf oder `P2002` auf `openKey`) → 409 `DRAFT_EXISTS` mit `existingId`. `referenceDate` Vorgabe: `berlinToday()` (Annahme 41; kein UTC-Tag).
- Zustandswechsel: `lockRowForUpdate(tx, 'AuthorityNotice', id)`, `before` danach lesen, Übergang prüfen (409 mit deutscher Meldung), schreiben, Audit `UPDATE` mit `beforeData`/`afterData`. `reopen` setzt `openKey` neu und prüft den Mutex; `cancel` und `mark-sent` setzen `openKey = null`. - Zustandswechsel: `lockRowForUpdate(tx, 'AuthorityNotice', id)`, `before` danach lesen, Übergang prüfen (409 mit deutscher Meldung), schreiben, Audit `UPDATE` mit `beforeData`/`afterData`. `reopen` setzt `openKey` neu und prüft den Mutex; `cancel` und `mark-sent` setzen `openKey = null`.
- `markSent` verlangt `sentOn` (Vorgabe: heutiger UTC-Tag); `PATCH` ändert `totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes` (nicht `kind`/`status`); auf `CANCELLED` 409. - `markSent` verlangt `sentOn` (Vorgabe: `berlinToday()`); `PATCH` ändert `totalColonies`, `referenceDate`, `sentOn`, `sentVia`, `reference`, `notes` (nicht `kind`/`status`); auf `CANCELLED` 409.
- `ComplianceModule` exportiert die Services; **kein** Provider mit `PermissionResolverService`. - `ComplianceModule` exportiert die Services; **kein** Provider mit `PermissionResolverService`.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
@@ -831,7 +850,7 @@ it('the pdf route needs compliance:read', ...);
**Files** **Files**
- Create: `src/compliance/compliance.handlers.ts` (+ `compliance.handlers.spec.ts`) - Create: `src/compliance/compliance.handlers.ts` (+ `compliance.handlers.spec.ts`)
- Modify: `src/notifications/notifications.service.ts` (+ Spec; `recipientsFor`, `recordForPermissionOnce`), `src/jobs/daily-evaluation.rules.ts` (+ Spec), `src/compliance/compliance.module.ts` (Provider), `src/worker/worker.module.ts` (importiert `ComplianceModule`) - Modify: `src/notifications/notifications.service.ts` (+ Spec; nur `recordForPermissionOnce`, die vorhandene private `recipients` wird mitgenutzt), `src/jobs/daily-evaluation.rules.ts` (+ Spec), `src/compliance/compliance.module.ts` (Provider), `src/worker/worker.module.ts` (importiert `ComplianceModule`)
- Test: `test/authority-notices.e2e-spec.ts` (ergänzen), `test/daily-evaluation.e2e-spec.ts` - Test: `test/authority-notices.e2e-spec.ts` (ergänzen), `test/daily-evaluation.e2e-spec.ts`
- Docs: ARCHITECTURE §10 Handler-Tabelle: `compliance.herd-change`, `compliance.tsk-reference-day`; Meldungen ohne Dublette. - Docs: ARCHITECTURE §10 Handler-Tabelle: `compliance.herd-change`, `compliance.tsk-reference-day`; Meldungen ohne Dublette.
@@ -839,17 +858,20 @@ it('the pdf route needs compliance:read', ...);
- Consumes: Task 7 (Ereignisse), Task 9 (`herdStatusIn`, `createDraftIn`, `tskReferenceDayOf`), T4 (`recordForUsers`, Kategorie `SYSTEM`, `DAILY_EVALUATION_RULES`). - Consumes: Task 7 (Ereignisse), Task 9 (`herdStatusIn`, `createDraftIn`, `tskReferenceDayOf`), T4 (`recordForUsers`, Kategorie `SYSTEM`, `DAILY_EVALUATION_RULES`).
- Produces: - Produces:
```ts ```ts
NotificationsService.recipientsFor(db: DbClient, permissions: readonly PermissionKey[]): Promise<string[]>; // die Abfrage aus recordForPermission, herausgezogen (keine zweite Kopie) // Die Empfängerabfrage gibt es schon als private Methode `recipients(db, keys, userIds)` (T4); kein `recipientsFor`, keine zweite Kopie:
// recordForPermissionOnce liegt in derselben Klasse und nutzt `recipients` und `fanOut`/`recordForUsers`.
NotificationsService.recordForPermissionOnce(permission: PermissionKey | readonly PermissionKey[], NotificationsService.recordForPermissionOnce(permission: PermissionKey | readonly PermissionKey[],
input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & { relatedType: string; relatedId: string }, tx: Prisma.TransactionClient): Promise<Notification[]>; input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & { relatedType: string; relatedId: string }, tx: Prisma.TransactionClient): Promise<Notification[]>;
// pg_advisory_xact_lock(hashtextextended('notify-once:' || relatedType || ':' || relatedId, 0)), dann Empfänger ohne ungelesene Zeile derselben relatedType/relatedId, dann recordForUsers ohne dedupeKey // pg_advisory_xact_lock(hashtextextended('notify-once:' || relatedType || ':' || relatedId, 0)), dann `recipients` (Rechte, aktiv), davon die ohne ungelesene Zeile
// derselben relatedType/relatedId (read = false), dann recordForUsers(neueEmpfänger, { ...input, requiredPermissions: <die Empfängerrechte> }) ohne dedupeKeyPrefix
// (die Telegram-Zustellung plant `write` → `planIn` selbst; Wächter no-broadcast-notifications.spec.ts bleibt grün, weil nur notifications.service.ts Zeilen schreibt)
// compliance.handlers.ts // compliance.handlers.ts
@OnDomainEvent(['ColonyCreated','ColonyMoved','ColonyEnded','ColonyStatusChanged','ApiaryChanged'], 'compliance.herd-change') @OnDomainEvent(['ColonyCreated','ColonyMoved','ColonyEnded','ColonyStatusChanged','ApiaryChanged'], 'compliance.herd-change')
herdChange(event: AnyDomainEventEnvelope<…>, tx: Prisma.TransactionClient): Promise<void>; // ColonyStatusChanged nur beim Wechsel lebend <-> beendet herdChange(event: AnyDomainEventEnvelope<…>, tx: Prisma.TransactionClient): Promise<void>; // ColonyStatusChanged nur beim Wechsel lebend <-> beendet
@OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day') @OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day')
tskReferenceDay(event: AnyDomainEventEnvelope<'TskReferenceDayReached'>, tx: Prisma.TransactionClient): Promise<void>; tskReferenceDay(event: AnyDomainEventEnvelope<'TskReferenceDayReached'>, tx: Prisma.TransactionClient): Promise<void>;
// daily-evaluation.rules.ts: DailyEventType += 'TskReferenceDayReached'; neue Regel in DAILY_EVALUATION_RULES // daily-evaluation.rules.ts: DailyEventType += 'TskReferenceDayReached'; neue Regel in DAILY_EVALUATION_RULES
export const tskReferenceDayRule: DailyRule<'TskReferenceDayReached'>; // Jahr und Vorjahr, Tag = tskReferenceDayOf(Jahr, settings.tskReferenceDay), Vorgabe 01-01 ohne Einstellungszeile export const tskReferenceDayRule: DailyRule<'TskReferenceDayReached'>; // occurrences(db: PrismaService, today, { day }): Jahr von ctx.day (Berliner Tag) und Vorjahr, Tag = tskReferenceDayOf(Jahr, settings.tskReferenceDay), Vorgabe 01-01 ohne Einstellungszeile (db.complianceSettings.findUnique({ where: { id: 'singleton' } })); catchUpEvents filtert auf CATCH_UP_DAYS = 7
``` ```
- [ ] **Schritt 1: Failing Tests** - [ ] **Schritt 1: Failing Tests**
@@ -867,7 +889,8 @@ it('a second handler run for the same event adds no second draft or notification
it('recordForPermissionOnce skips users with an unread notification of the same topic', ...); it('recordForPermissionOnce skips users with an unread notification of the same topic', ...);
it('recordForPermissionOnce writes again after the user read the earlier one', ...); it('recordForPermissionOnce writes again after the user read the earlier one', ...);
it('recordForPermissionOnce takes the advisory lock before reading the unread rows', ...); it('recordForPermissionOnce takes the advisory lock before reading the unread rows', ...);
it('recordForPermission still works through recipientsFor', ...); it('recordForPermissionOnce plans the Telegram delivery for a user with SYSTEM chosen and none for a user without', ...);
it('recordForPermission is unchanged', ...);
// daily-evaluation.rules.spec.ts // daily-evaluation.rules.spec.ts
it('emits TskReferenceDayReached on the configured day', ...); it('emits TskReferenceDayReached on the configured day', ...);
it('catches up within 7 days exactly once', ...); it('catches up within 7 days exactly once', ...);
@@ -875,7 +898,7 @@ it('covers the previous year\'s day in early January', ...);
it('uses 01-01 when no settings row exists', ...); it('uses 01-01 when no settings row exists', ...);
// handler-contract.spec.ts läuft unverändert und deckt die neue Datei ab // handler-contract.spec.ts läuft unverändert und deckt die neue Datei ab
// e2e // e2e
it('a new colony changes the herd: after draining one unread notification per permitted user', ...); // Messkriterium 3 it('a new colony changes the herd: after draining one unread notification per permitted user', ...); // Messkriterium 3; "permitted" = die im Test angelegten Nutzer mit compliance:manage, andere Nutzer der Test-DB zählen nicht; Baseline über eine SENT-Anzeige mit totalColonies = aktueller Stand, damit die Abweichung vom selbst gesetzten Stand ausgeht
it('a second colony within one unread notice adds no second notification', ...); it('a second colony within one unread notice adds no second notification', ...);
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', ...); it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', ...);
it('daily evaluation twice on the reference day: one event, one draft', ...); it('daily evaluation twice on the reference day: one event, one draft', ...);
@@ -883,11 +906,11 @@ it('a user without compliance:manage gets no herd notification', ...);
``` ```
- [ ] **Schritt 2:** `npx jest src/compliance src/notifications src/jobs src/domain-events` und die e2e-Dateien → FAIL. - [ ] **Schritt 2:** `npx jest src/compliance src/notifications src/jobs src/domain-events` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Meldungen: `category: 'SYSTEM'` (T4 hat keinen Wert für Behörden; die Telegram-Kategorienliste bleibt unverändert), `relatedType: 'ComplianceItem'`, `relatedId: 'herd-change'` bzw. `tsk-report:<Jahr>`, `link: '/compliance'` bzw. `/compliance/notices/<id>`, Empfänger `compliance:manage`. T4 vergibt `dedupeKey` dauerhaft eindeutig; hier bewusst **keiner**, die Dublette steuert nur „ungelesen“. - Meldungen: `category: 'SYSTEM'` (T4 hat keinen Wert für Behörden; die Telegram-Kategorienliste bleibt unverändert, Zustellung per Telegram nur für Nutzer, die `SYSTEM` gewählt haben, Frage 2), `relatedType: 'ComplianceItem'`, `relatedId: 'herd-change'` bzw. `tsk-report:<Jahr>`, `link: '/compliance'` bzw. `/compliance/notices/<id>`, Empfänger `compliance:manage`. T4 vergibt `dedupeKey` dauerhaft eindeutig; hier bewusst **keiner**, die Dublette steuert nur „ungelesen“.
- Handler lesen nur über `tx`; `herdStatusIn(tx, now)` mit `now` aus `SELECT now()` über `tx`. `herdChange` legt nie eine Anzeige an. - Handler lesen nur über `tx`; `herdStatusIn(tx, now)` mit `now` aus `SELECT now()` über `tx`. `herdChange` legt nie eine Anzeige an.
- `tskReferenceDay`: `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')`; gibt es für das Jahr `SENT` oder einen offenen Entwurf, kehrt der Handler zurück; sonst `createDraftIn(tx, { kind: 'TSK_REPORT', year, referenceDate }, null)` und Meldung. - `tskReferenceDay`: `lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')`; gibt es für das Jahr `SENT` oder einen offenen Entwurf, kehrt der Handler zurück; sonst `createDraftIn(tx, { kind: 'TSK_REPORT', year, referenceDate }, null)` und Meldung.
- Regel: je Jahr eine `DailyOccurrence` mit `aggregateType: 'Compliance'`, `aggregateId: 'tsk:<Jahr>'`, `payload: { year, referenceDay: <Tag> }`; Schlüssel über `catchUpEvents` → `dailyDedupeKey`. - Regel: je Jahr eine `DailyOccurrence` mit `aggregateType: 'Compliance'`, `aggregateId: 'tsk:<Jahr>'`, `payload: { year, referenceDay: <Tag> }`; Schlüssel über `catchUpEvents` → `dailyDedupeKey`.
- `WorkerModule` importiert `ComplianceModule`; `AppModule` hat es seit Task 9. - `WorkerModule` importiert `ComplianceModule`; `AppModule` hat es seit Task 9 (Worker = API: der Dispatcher findet die Handler per DI-Entdeckung in beiden Wurzeln; `test/worker-runtime.e2e-spec.ts` und `domain-event-registry.spec.ts` sollen `compliance.herd-change` und `compliance.tsk-reference-day` in beiden Kontexten sehen). Weder `ComplianceModule` noch ein davon importiertes Modul darf `PermissionsModule` importieren; der Worker hat nur `JobPermissionsModule`.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
- typecheck, lint, `npm test -- --ci` - typecheck, lint, `npm test -- --ci`
@@ -953,12 +976,13 @@ it('revoke locks the acknowledgement row and refuses an already revoked one', ..
// tsk-report: open from the reference day without a SENT report; "überfällig" after tskReportDueDay; a DRAFT of last year keeps last year's item open // tsk-report: open from the reference day without a SENT report; "überfällig" after tskReportDueDay; a DRAFT of last year keeps last year's item open
// restriction-active: one finding listing the apiaries with an active restriction // restriction-active: one finding listing the apiaries with an active restriction
// test/compliance-readiness.e2e-spec.ts // test/compliance-readiness.e2e-spec.ts
it('an empty system reports the Stammdaten and registration items open and status OPEN', ...); it('without site data and without a SENT registration the Stammdaten and registration items are open and the status is OPEN', ...); // T6-Tabellen vorher leeren, SiteSettings im Test setzen/leeren; nichts über fremde Daten behaupten
it('complete master data and a SENT registration make those items DONE', ...); it('complete master data and a SENT registration make those items DONE', ...);
it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', ...); it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', ...);
``` ```
- [ ] **Schritt 2:** `npx jest src/compliance/readiness` und `test/compliance-readiness.e2e-spec.ts` → FAIL. - [ ] **Schritt 2:** `npx jest src/compliance/readiness` und `test/compliance-readiness.e2e-spec.ts` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- `today` des Kontexts ist `berlinToday()`; „Stichtag erreicht“, „überfällig“ und die Rückblickfenster rechnen mit Berliner Tagen (`addDays`, `daysBetween`); nur `restriction-active` nutzt denselben Tag wie der Wächter (`dbUtcDay`, Frage 1), damit Anzeige und Sperre nie auseinanderlaufen.
- `evaluate`: Kontext einmal laden (begrenzt, nur benötigte Spalten), jede Regel ausführen, Findings zu Items (`fingerprint = fingerprintOf({ key, subjectId, parts })`), gültige Quittung (gleicher `itemKey`, `subjectId`, `fingerprint`, `revokedAt` null; neueste gewinnt) → `ACKNOWLEDGED`. Regeln ohne Findings → ein `DONE`-Item mit dem Regeltitel. - `evaluate`: Kontext einmal laden (begrenzt, nur benötigte Spalten), jede Regel ausführen, Findings zu Items (`fingerprint = fingerprintOf({ key, subjectId, parts })`), gültige Quittung (gleicher `itemKey`, `subjectId`, `fingerprint`, `revokedAt` null; neueste gewinnt) → `ACKNOWLEDGED`. Regeln ohne Findings → ein `DONE`-Item mit dem Regeltitel.
- Maskierung: Hat der Aufrufer `rule.subjectPermission` nicht, bleiben `subjects` leer und `more` = Anzahl; Titel und Detailtexte der Regeln enthalten keine Namen. - Maskierung: Hat der Aufrufer `rule.subjectPermission` nicht, bleiben `subjects` leer und `more` = Anzahl; Titel und Detailtexte der Regeln enthalten keine Namen.
- Quittieren: Item aus der aktuellen Auswertung nehmen (`key` + `subjectId`), `acknowledgeable` prüfen, Fingerabdruck vergleichen, in einer Transaktion schreiben mit Audit `CREATE` (`entityType: 'ComplianceAcknowledgement'`). `revoke`: Sperre auf der Quittung, `revokedAt = now()` (SQL), `revokedByUserId`, Audit. - Quittieren: Item aus der aktuellen Auswertung nehmen (`key` + `subjectId`), `acknowledgeable` prüfen, Fingerabdruck vergleichen, in einer Transaktion schreiben mit Audit `CREATE` (`entityType: 'ComplianceAcknowledgement'`). `revoke`: Sperre auf der Quittung, `revokedAt = now()` (SQL), `revokedByUserId`, Audit.
@@ -1017,7 +1041,7 @@ it('hides subject labels of the medicine area without veterinary-medicines:read
it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', ...); it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', ...);
it('prints FEHLT for an empty holder', ...); it('prints FEHLT for an empty holder', ...);
// e2e // e2e
it('complete data in every area makes GET /compliance/readiness free of open items with the status READY', ...); // Messkriterium 4 it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten, Behörden and Etiketten rules', ...); // Messkriterium 4; die ganze Test-DB kann fremde Lücken enthalten, darum Prüfung je key + subjectId; der Gesamtstatus READY wird als Unit-Test von ReadinessService mit leerem Kontext belegt (Task 12)
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', ...); // Messkriterium 1 it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', ...); // Messkriterium 1
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', ...); it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', ...);
it('a negative rest after a treatment from a too small lot opens lot-negative', ...); it('a negative rest after a treatment from a too small lot opens lot-negative', ...);
@@ -1025,6 +1049,7 @@ it('a negative rest after a treatment from a too small lot opens lot-negative',
- [ ] **Schritt 2:** `npx jest src/compliance/readiness` und die e2e-Dateien → FAIL. - [ ] **Schritt 2:** `npx jest src/compliance/readiness` und die e2e-Dateien → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- `treatment-log-gaps`: dieselbe Funktionskette wie das PDF (`resolveSourceValues` → `groupTreatmentLog` → `treatmentLogGaps`); je Quelle ohne Packung Subjekt `treatment` (`parentId` = `colonyId`), mit Packung und nur Packungsfeldern Subjekt `lot`. `missing` nennt deutsche Feldnamen („Lieferant“, „Beleg“, „Menge“, „Wartezeit“, „Tierarzt“). - `treatment-log-gaps`: dieselbe Funktionskette wie das PDF (`resolveSourceValues` → `groupTreatmentLog` → `treatmentLogGaps`); je Quelle ohne Packung Subjekt `treatment` (`parentId` = `colonyId`), mit Packung und nur Packungsfeldern Subjekt `lot`. `missing` nennt deutsche Feldnamen („Lieferant“, „Beleg“, „Menge“, „Wartezeit“, „Tierarzt“).
- `harvest-withdrawal` baut **keine** zweite Fensterlogik: Fenster über `withdrawalWindowOf(treatment)` / `isInWindow(window, berlinDayOf(harvest.date))`, Konflikte der Ernten über `WithdrawalCheckService.conflictsForHarvests` (`src/withdrawal/`), Text über `withdrawalRangeText`/`lastBlockedDay` (`src/withdrawal/withdrawal-text.ts`); der Regel-Test vergleicht gegen genau diese Helfer. Berührt `ReadinessModule` den Dienst, importiert es `WithdrawalModule`.
- Lader: Behandlungen mit `select` der benötigten Spalten und `date >= 1. Januar (Jahr − 5)`; Packungen mit einer Gruppen-Abfrage der Bewegungssummen; Ernten ab `heute − 2 Jahre`; Vorlagen mit der Zielabfüllung (jüngste mit Restbestand über `summarizeStock` der `BatchMovement`, sonst jüngste, sonst `SAMPLE_BOTTLING`). - Lader: Behandlungen mit `select` der benötigten Spalten und `date >= 1. Januar (Jahr − 5)`; Packungen mit einer Gruppen-Abfrage der Bewegungssummen; Ernten ab `heute − 2 Jahre`; Vorlagen mit der Zielabfüllung (jüngste mit Restbestand über `summarizeStock` der `BatchMovement`, sonst jüngste, sonst `SAMPLE_BOTTLING`).
- `label-template-*`: `checkLabel` mit den Textelementen der gespeicherten Vorlage (Bild-, Linien- und QR-Elemente bleiben außen vor). - `label-template-*`: `checkLabel` mit den Textelementen der gespeicherten Vorlage (Bild-, Linien- und QR-Elemente bleiben außen vor).
- Bericht: A4 hoch, Kopf mit Halter und Datum, Gesamtstatus, je Bereich eine Tabelle (Status „Offen“/„Erledigt“/„Quittiert“, Schwere, Titel, Norm, bei Quittungen Notiz, Nutzer, Datum). - Bericht: A4 hoch, Kopf mit Halter und Datum, Gesamtstatus, je Bereich eine Tabelle (Status „Offen“/„Erledigt“/„Quittiert“, Schwere, Titel, Norm, bei Quittungen Notiz, Nutzer, Datum).
@@ -1044,7 +1069,8 @@ Gemeinsam für alle Frontend-Tasks: Vor Next.js-Code `node_modules/next/dist/doc
**Files** **Files**
- Create: `src/lib/compliance/types.ts`, `src/lib/compliance/api.ts` (+ Spec), `src/lib/compliance/fix-links.ts` (+ Spec), `src/lib/compliance/use-readiness.ts`, `src/app/(app)/compliance/page.tsx` (+ `page.spec.tsx`), `src/app/(app)/compliance/readiness-item-card.tsx` (+ Spec), `src/app/(app)/compliance/acknowledge-dialog.tsx` (+ Spec), `src/components/today/compliance-line.tsx` (+ Spec) - Create: `src/lib/compliance/types.ts`, `src/lib/compliance/api.ts` (+ Spec), `src/lib/compliance/fix-links.ts` (+ Spec), `src/lib/compliance/use-readiness.ts`, `src/app/(app)/compliance/page.tsx` (+ `page.spec.tsx`), `src/app/(app)/compliance/readiness-item-card.tsx` (+ Spec), `src/app/(app)/compliance/acknowledge-dialog.tsx` (+ Spec), `src/components/today/compliance-line.tsx` (+ Spec)
- Modify: `src/app/(app)/nav-config.ts` (+ `nav-config.spec.ts`), `src/app/(app)/dashboard/page.tsx` (die Zeile) - Create (zusätzlich): `src/lib/compliance/errors.ts` (+ Spec; liest die 409-Körper `DRAFT_EXISTS` → `existingId`, `RESTRICTED_ZONE` → `restrictions`, veralteter Fingerabdruck; baut auf `describeSaveError`/`saveErrorText` aus `src/lib/honey-batches/errors.ts` auf, siehe `confirmationRequired` als Vorbild, keine zweite Fehlerlogik)
- Modify: `src/app/(app)/nav-config.ts` (+ `nav-config.spec.ts`), `src/app/(app)/dashboard/page.tsx` (die Zeile), `src/lib/system/labels.ts` (+ `labels.spec.ts`: `DOMAIN_EVENT_LABELS` += `ColonyCreated` „Volk angelegt“, `ColonyMoved` „Volk umgesetzt“, `ApiaryChanged` „Stand geändert“, `TskReferenceDayReached` „TSK-Stichtag erreicht“), `public/sw-routes.js` + `public/sw.js` + `src/lib/offline/sw-routes.spec.ts` (siehe unten)
**Interfaces** **Interfaces**
- Consumes: Task 12/13 (`ReadinessReport`, `ReadinessItem`, `ReadinessSubject`), `DetailHeader`, `FormSection`, `ConfirmDialog`. - Consumes: Task 12/13 (`ReadinessReport`, `ReadinessItem`, `ReadinessSubject`), `DetailHeader`, `FormSection`, `ConfirmDialog`.
@@ -1059,6 +1085,8 @@ export function fixLinkFor(itemKey: string, subject: { type: string; id: string;
// vet-office -> /compliance/settings; registration, herd-change, tsk-report -> /compliance/notices; medicine-withdrawal-missing -> /medicines // vet-office -> /compliance/settings; registration, herd-change, tsk-report -> /compliance/notices; medicine-withdrawal-missing -> /medicines
``` ```
- Nav: Eintrag „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`). - Nav: Eintrag „Prüfbereitschaft“ (Symbol `ShieldCheck`, Gruppe „Betrieb“, Recht `compliance:read`).
- **Service Worker (nie zwischenspeichern).** Belege, Anschriften, Koordinaten und Aktenzeichen dürfen nicht im Cache des Geräts landen (gleiche Begründung wie `/apiaries`, `/export/`, `/backup`). `NEVER_CACHED_API_PATHS` in `public/sw-routes.js` bekommt (Tests in `sw-routes.spec.ts` zuerst): `/api/v1/compliance(/|$)`, `/api/v1/authority-notices(/|$)`, `/api/v1/apiary-restrictions(/|$)`, `/api/v1/apiaries/[^/]+/restrictions/?$`, `/api/v1/veterinary-medicines(/|$)`, `/api/v1/veterinary-medicine-lots(/|$)`, `/api/v1/documents(/|$)` (Anhänge zu Packung, Anzeige und Verfügung; hat der Datei-Download eine andere Wurzel, die prüfen) und `/api/v1/label-templates/[^/]+/check/` (enthält Anschrift). Danach `SW_ROUTES_VERSION` in `public/sw.js` auf den neuen Inhalts-Hash heben (der Spec `sw-routes.spec.ts` prüft, dass die Version zum Inhalt passt). Die Formulare der Tasks 15 bis 19 laufen online; die Offline-Warteschlange der Behandlung (Task 16) bleibt unberührt.
- Wiederverwenden statt neu bauen (gilt für 14 bis 19): `describeSaveError`/`saveErrorText` (Fehlerzeile), `PrefillStatus` (Task 16, Vorbelegung aus dem Katalog; Speichern bleibt erlaubt, Annahme 53), `ConfirmDialog` mit `triggerDescribedBy`/`triggerDisabled` (Rückfragen, Task 19), `ToggleChip` mit `disabled` (Mehrfachauswahl der gezählten Völkerarten in Task 18), `DetailHeader`, `FormSection`, `ChoiceGroup`, `NumberStepper`, `RecordSelect`, `DocumentUploadCard`. `StepUpDialog` und `refreshOn401: false` sind für Nachweis-Aufrufe (Passwort, Backup-Import, Telegram) da; die Spec verlangt für die T6 keinen Nachweis (siehe Frage 5), also hier nicht einbauen.
- [ ] **Schritt 1: Failing Tests** - [ ] **Schritt 1: Failing Tests**
```ts ```ts
@@ -1080,15 +1108,23 @@ it.each([...])('maps %s to %s', ...); // jede
it('renders only when openCount > 0 and the user holds compliance:read', ...); it('renders only when openCount > 0 and the user holds compliance:read', ...);
// nav-config.spec.ts // nav-config.spec.ts
it('shows Prüfbereitschaft only with compliance:read', ...); it('shows Prüfbereitschaft only with compliance:read', ...);
// sw-routes.spec.ts
it.each([...die neuen Pfade])('never caches %s', ...); // inkl. Großschreibung, Schrägstrich am Ende, Query
it('keeps caching the unrelated routes it cached before', ...);
it('SW_ROUTES_VERSION in sw.js matches the content of sw-routes.js', ...); // bestehender Test, bleibt grün
// labels.spec.ts
it('DOMAIN_EVENT_LABELS names ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached', ...);
// errors.spec.ts
it('reads DRAFT_EXISTS with existingId, RESTRICTED_ZONE with its restrictions and a stale fingerprint', ...);
``` ```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → FAIL. - [ ] **Schritt 2:** `npx vitest run src/lib/compliance src/lib/offline src/lib/system "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Seite `/compliance` nach Spec 9: Status oben, Punkte nach Bereich (fünf Abschnitte), offene Punkte rot mit Symbol + „Offen“ + Schwere-Chip + Titel + eine Zeile Detail + Norm klein; „Mehr“-Menü mit Links auf Bericht (PDF, authentifizierter Download über den vorhandenen Export-Helfer), Bestandsbuch, `/compliance/notices`, `/compliance/restrictions`, `/compliance/settings`. - Seite `/compliance` nach Spec 9: Status oben, Punkte nach Bereich (fünf Abschnitte), offene Punkte rot mit Symbol + „Offen“ + Schwere-Chip + Titel + eine Zeile Detail + Norm klein; „Mehr“-Menü mit Links auf Bericht (PDF, authentifizierter Download über den vorhandenen Export-Helfer), Bestandsbuch, `/compliance/notices`, `/compliance/restrictions`, `/compliance/settings`.
- Hauptaktion „Nächsten Punkt lösen“ springt auf das `fixLinkFor` des ersten offenen `PFLICHT`-Punkts; alles grün: „Bestandsbuch {Jahr} drucken“. - Hauptaktion „Nächsten Punkt lösen“ springt auf das `fixLinkFor` des ersten offenen `PFLICHT`-Punkts; alles grün: „Bestandsbuch {Jahr} drucken“.
- Links auf Seiten, die spätere Tasks liefern, sind einfache `Link`s; die Seiten entstehen in den Tasks 15, 18, 19. - Links auf Seiten, die spätere Tasks liefern, sind einfache `Link`s; die Seiten entstehen in den Tasks 15, 18, 19.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
- `npm run lint && npm run typecheck && npx vitest run src/lib/compliance "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → PASS - `npm run lint && npm run typecheck && npx vitest run src/lib/compliance src/lib/offline src/lib/system "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"` → PASS
- Browser: Desktop und 390 px, Leerzustand „Prüfbereit“, Tastatur (Tab-Reihenfolge, sichtbarer Fokus), `prefers-reduced-motion` - Browser: Desktop und 390 px, Leerzustand „Prüfbereit“, Tastatur (Tab-Reihenfolge, sichtbarer Fokus), `prefers-reduced-motion`
- kein Commit - kein Commit
@@ -1098,7 +1134,8 @@ it('shows Prüfbereitschaft only with compliance:read', ...);
**Files** **Files**
- Create: `src/lib/medicines/types.ts`, `src/lib/medicines/api.ts` (+ Spec), `src/app/(app)/medicines/page.tsx` (+ Spec), `src/app/(app)/medicines/medicine-form-dialog.tsx` (+ Spec), `src/app/(app)/medicines/[id]/page.tsx`, `src/app/(app)/medicines/[id]/lot-form-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/page.tsx`, `src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx` (je + Spec) - Create: `src/lib/medicines/types.ts`, `src/lib/medicines/api.ts` (+ Spec), `src/app/(app)/medicines/page.tsx` (+ Spec), `src/app/(app)/medicines/medicine-form-dialog.tsx` (+ Spec), `src/app/(app)/medicines/[id]/page.tsx`, `src/app/(app)/medicines/[id]/lot-form-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/page.tsx`, `src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx`, `src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx` (je + Spec)
- Modify: `src/app/(app)/storage/page.tsx` (Karte „Tierarzneimittel“ mit Link auf `/medicines`) - Modify: `src/app/(app)/storage/page.tsx` (Karte „Tierarzneimittel“ mit Link auf `/medicines`), `src/lib/documents/types.ts` (`DocumentEntityType` += `MEDICINE_LOT` | `AUTHORITY_NOTICE` | `APIARY_RESTRICTION`, einmal für 15, 18, 19; Spec in `src/lib/documents`)
- Fehlerzeilen über `saveErrorText`/`describeSaveError` (`src/lib/honey-batches/errors.ts`), nicht neu schreiben.
**Interfaces** **Interfaces**
- Consumes: Task 2 (`MedicineView`, `LotView`, `LotDetailView`, `MedicineSuggestion`), `DocumentUploadCard` (Dokumentart `MEDICINE_LOT`), `ChoiceGroup`, `NumberStepper`. - Consumes: Task 2 (`MedicineView`, `LotView`, `LotDetailView`, `MedicineSuggestion`), `DocumentUploadCard` (Dokumentart `MEDICINE_LOT`), `ChoiceGroup`, `NumberStepper`.
@@ -1172,6 +1209,7 @@ it('the colony page opens the edit dialog for ?editTreatment=<id>', ...);
``` ```
- [ ] **Schritt 2:** `npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"` → FAIL. - [ ] **Schritt 2:** `npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Ladezustand und Fehler der Katalog-Vorbelegung zeigt `PrefillStatus` (`src/components/warnings/prefill-status.tsx`) wie bei der Behandlungs-Vorbelegung aus T3; Speichern bleibt erlaubt (Annahme 53). Das Muster `PrefillNote`/`useCaptureMutation`/`applyPrefill` aus T3 gilt unverändert, die Katalog-Vorbelegung kommt als weitere Quelle dazu.
- Die Vorbelegung bleibt sichtbar im Formular (Server füllt nie still nach). Das Wetter für den Temperaturhinweis kommt aus `GET /apiaries/:id/weather` (`apiaries:read`); fehlt es, entfällt der Hinweis ohne Fehler. - Die Vorbelegung bleibt sichtbar im Formular (Server füllt nie still nach). Das Wetter für den Temperaturhinweis kommt aus `GET /apiaries/:id/weather` (`apiaries:read`); fehlt es, entfällt der Hinweis ohne Fehler.
- Dosis im `NumberStepper` mit Einheit des Mittels; Offline wie T3 (Warteschlange); Idempotency-Key unverändert. - Dosis im `NumberStepper` mit Einheit des Mittels; Offline wie T3 (Warteschlange); Idempotency-Key unverändert.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
@@ -1226,6 +1264,7 @@ it('Vom Bienenhalter übernehmen fills the fields visibly and saves only on Spei
- [ ] **Schritt 2:** `npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"` → FAIL. - [ ] **Schritt 2:** `npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- `LabelCompliancePanel` ist eine eigene Datei (der Designer hat schon 695 Zeilen) und bekommt nur das Ergebnis; der Designer ruft den Hook mit den aktuellen Textelementen. - `LabelCompliancePanel` ist eine eigene Datei (der Designer hat schon 695 Zeilen) und bekommt nur das Ergebnis; der Designer ruft den Hook mit den aktuellen Textelementen.
- Fehlerzeilen und Feldfehler über `describeSaveError`/`saveErrorText`; das 409 `LABEL_PARTICULARS_MISSING` liest `check.ts` (Muster `confirmationRequired`). Das Ergebnis von `checkTemplateForBottling` enthält die Anschrift: nie in Query-Keys mit Persistenz oder im Service-Worker-Cache ablegen (Pfad `/label-templates/:id/check/…` steht in Task 14 in `NEVER_CACHED_API_PATHS`).
- Druckkarte: ruft vor dem Anzeigen `checkTemplateForBottling`; bei Lücken rote Liste mit Links (Stammdaten → Verwaltung > Automatisierung, Produkt → `/products/{id}`, Designer → `/settings/label-templates/{id}`); Hauptknopf „Etiketten als PDF“ mit `aria-disabled`, daneben „Probedruck (MUSTER)“. - Druckkarte: ruft vor dem Anzeigen `checkTemplateForBottling`; bei Lücken rote Liste mit Links (Stammdaten → Verwaltung > Automatisierung, Produkt → `/products/{id}`, Designer → `/settings/label-templates/{id}`); Hauptknopf „Etiketten als PDF“ mit `aria-disabled`, daneben „Probedruck (MUSTER)“.
- Stammdaten-Tab: Felder `foodBusinessName` (200) und `foodBusinessAddress` (300), Knopf übernimmt `holderName`/`holderAddress` in die Formularwerte, ohne zu speichern. - Stammdaten-Tab: Felder `foodBusinessName` (200) und `foodBusinessAddress` (300), Knopf übernimmt `holderName`/`holderAddress` in die Formularwerte, ohne zu speichern.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
@@ -1267,6 +1306,7 @@ it('STAFF sees the pages read-only without action buttons', ...);
``` ```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance "src/app/(app)/compliance"` → FAIL. - [ ] **Schritt 2:** `npx vitest run src/lib/compliance "src/app/(app)/compliance"` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Fehler über `saveErrorText` und `lib/compliance/errors.ts` (Task 14); gezählte Völkerarten in „Erweitert“ als `ToggleChip`-Gruppe; Rückfragen („Anzeige stornieren“) mit `ConfirmDialog`. Kein Nachweis-Dialog (Frage 5).
- Detailseite nach Spec 9 mit Schritt-Anzeige (Symbol + Wort je Schritt); Formulare mit `FormSection`; „Annahme – bitte prüfen“ als sichtbarer Vermerk am Feld, nicht nur als Tooltip. Anlege-Anfragen mit `Idempotency-Key`. Dokument-Upload (Kopie, Eingangsbestätigung) über `DocumentUploadCard` mit `AUTHORITY_NOTICE`. - Detailseite nach Spec 9 mit Schritt-Anzeige (Symbol + Wort je Schritt); Formulare mit `FormSection`; „Annahme – bitte prüfen“ als sichtbarer Vermerk am Feld, nicht nur als Tooltip. Anlege-Anfragen mit `Idempotency-Key`. Dokument-Upload (Kopie, Eingangsbestätigung) über `DocumentUploadCard` mit `AUTHORITY_NOTICE`.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
- [ ] **Schritt 5: Verifikation** - [ ] **Schritt 5: Verifikation**
@@ -1311,6 +1351,7 @@ it('the Verfügung is uploaded via DocumentUploadCard for APIARY_RESTRICTION', .
``` ```
- [ ] **Schritt 2:** `npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"` → FAIL. - [ ] **Schritt 2:** `npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"` → FAIL.
- [ ] **Schritt 3: Implementieren** - [ ] **Schritt 3: Implementieren**
- Die Rückfrage der Ausnahme ist ein `ConfirmDialog`; ist der Umsetzen-Knopf gesperrt, nutzt er `triggerDisabled` plus `triggerDescribedBy` (Verweis auf die rote Meldung). `RESTRICTED_ZONE` und die Mitteilung „Volk wurde inzwischen umgesetzt“ (409 aus Task 8) liest `lib/compliance/errors.ts`. Der Mutations-Aufruf mit `restrictionOverrideReason` nutzt den bestehenden `apiFetch`; kein `refreshOn401: false` (kein Nachweis-Aufruf). Die Stand-Antworten mit Anschrift stehen im Service-Worker-Pfad der Stand-Seite bereits unter `NEVER_CACHED_API_PATHS`, `/apiaries/:id/restrictions` neu (Task 14).
- Aktive Sperren einmal je Seite laden (`useActiveRestrictions`, gemeinsamer Query-Key). Der Server entscheidet endgültig; die Client-Prüfung blendet nur früh aus. Die Berechtigung für die Ausnahme kommt aus dem vorhandenen Auth-Kontext. - Aktive Sperren einmal je Seite laden (`useActiveRestrictions`, gemeinsamer Query-Key). Der Server entscheidet endgültig; die Client-Prüfung blendet nur früh aus. Die Berechtigung für die Ausnahme kommt aus dem vorhandenen Auth-Kontext.
- Roter Text mit Token `destructive`, Symbol + Wort; Fokus springt bei einer 409-Antwort auf die Meldung. - Roter Text mit Token `destructive`, Symbol + Wort; Fokus springt bei einer 409-Antwort auf die Meldung.
- [ ] **Schritt 4:** → PASS. - [ ] **Schritt 4:** → PASS.
@@ -1323,9 +1364,9 @@ it('the Verfügung is uploaded via DocumentUploadCard for APIARY_RESTRICTION', .
## Abschluss und Rollout (Orchestrator) ## Abschluss und Rollout (Orchestrator)
- [ ] Gesamtprüfung Backend: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`, `npm run test:e2e`, `npm run build`; Frontend: `npm run lint && npm run typecheck && npm run test && npm run build`. Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0. - [ ] Gesamtprüfung Backend: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`, `npm run test:e2e -- --runInBand` (gegen die Test-DB 17 auf Port 5433, 20 s je Test), `npm run build`; Frontend: `npm run lint && npm run typecheck && npm run test && npm run build`. Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0.
- [ ] Wächter prüfen: Backup-Modellabdeckung, `handler-contract.spec.ts`, `after-commit-usage.spec.ts`, Null-Vertrags-Specs der Tasks 2, 3, 5, 8, 9, Platzhalter-Fixture (Task 5 und 17) und der Seed-Wächter `src/seed-no-medicines.spec.ts` (Task 2 legt ihn an: `prisma/seed.ts` enthält kein `veterinaryMedicine`). - [ ] Wächter prüfen: Backup-Modellabdeckung, `handler-contract.spec.ts`, `after-commit-usage.spec.ts`, Null-Vertrags-Specs der Tasks 2, 3, 5, 8, 9, Platzhalter-Fixture (Task 5 und 17) und der Seed-Wächter `src/seed-no-medicines.spec.ts` (Task 2 legt ihn an: `prisma/seed.ts` enthält kein `veterinaryMedicine`).
- [ ] Docs aus den Berichten: `ARCHITECTURE.md` vier neue Annahmen mit je der nächsten freien Nummer (Katalog und Packungen; Etikett-Pflichtangaben; Behördenpaket und Sperrbezirk; Prüfbereitschaft), Annahme 15 ergänzen, §10 um die vier Ereignisse und die beiden Handler, Dokumente-Rechte je Art; `docs/pruefung-2026-09.md` Abschnitt „Prüfbereitschaft“; `TASKS.md` (T6). - [ ] Docs aus den Berichten: `ARCHITECTURE.md` vier neue Annahmen mit je der nächsten freien Nummer (Stand 2026-10-15 ist 58 die letzte, also ab 59: Katalog und Packungen; Etikett-Pflichtangaben; Behördenpaket und Sperrbezirk; Prüfbereitschaft; dazu die Sperr-Reihenfolge Stand → Volk in §10/Annahme zu Zeilensperren und die Ausnahme „Sperrbezirk-Tag = UTC“, falls bestätigt), §2 Seitenstruktur (`compliance/`, `medicines/`) und Service-Worker-Absatz (neue `NEVER_CACHED_API_PATHS`), Annahme 15 ergänzen, §10 um die vier Ereignisse und die beiden Handler, Dokumente-Rechte je Art; `docs/pruefung-2026-09.md` Abschnitt „Prüfbereitschaft“; `TASKS.md` (T6).
- [ ] Rollout nach Spec 13 (jeder Schritt mit Freigabe des Inhabers): Voraussetzung Rückgrat-Runbook bis Schritt 3 und T4 live; Image mit den drei Migrationen (alles additiv, Rückweg ohne Verlust); danach Checkliste für den Inhaber: 1. Stammdaten Lebensmittelunternehmer, 2. Produkte Bezeichnung, 3. Etikettenvorlagen im Designer („Pflichtangaben-Block einfügen“), 4. Einstellungen Veterinäramt, Registriernummer, Stichtag und Frist der Tierseuchenkasse (Annahme), 5. Katalog aus den bisherigen Behandlungen übernehmen und aktuelle Packungen erfassen, 6. Erstanzeige „bereits angezeigt am …“, 7. Prüfbereitschaft durchgehen, bis sie grün ist. Vor dem Rollout einmal die Paragraphen-Nummern der Spec gegen gesetze-im-internet.de bzw. EUR-Lex abgleichen. - [ ] Rollout nach Spec 13 (jeder Schritt mit Freigabe des Inhabers): Voraussetzung Rückgrat-Runbook bis Schritt 3 und T4 live; Image mit den drei Migrationen (alles additiv, Rückweg ohne Verlust); danach Checkliste für den Inhaber: 1. Stammdaten Lebensmittelunternehmer, 2. Produkte Bezeichnung, 3. Etikettenvorlagen im Designer („Pflichtangaben-Block einfügen“), 4. Einstellungen Veterinäramt, Registriernummer, Stichtag und Frist der Tierseuchenkasse (Annahme), 5. Katalog aus den bisherigen Behandlungen übernehmen und aktuelle Packungen erfassen, 6. Erstanzeige „bereits angezeigt am …“, 7. Prüfbereitschaft durchgehen, bis sie grün ist. Vor dem Rollout einmal die Paragraphen-Nummern der Spec gegen gesetze-im-internet.de bzw. EUR-Lex abgleichen.
- [ ] Ein zentraler Commit, Nachricht endet mit `Co-Authored-By: Claude Sonnet 5.5 <[email protected]>`; Push gebündelt. - [ ] Ein zentraler Commit, Nachricht endet mit `Co-Authored-By: Claude Sonnet 5.5 <[email protected]>`; Push gebündelt.
@@ -1400,3 +1441,68 @@ it('the Verfügung is uploaded via DocumentUploadCard for APIARY_RESTRICTION', .
- Jeder Task schließt mit Verifikation und „kein Commit“; Doku-Texte gehen über den Bericht an den Orchestrator. - Jeder Task schließt mit Verifikation und „kein Commit“; Doku-Texte gehen über den Bericht an den Orchestrator.
- Geteilte Dateien je Welle höchstens einmal (Liste unter „Abhängigkeiten und Parallelisierung“). Paare, die nur durch Wellen getrennt sind: 2/9/12 (`app.module.ts`), 7/8 (`colonies.service.ts`), 9/11 (`compliance.module.ts`), 9/10 (`authority-notices.controller.ts`), 12/13 (`readiness-context.ts`, `rules/index.ts`), 5/6 (`label-templates.service.ts`). - Geteilte Dateien je Welle höchstens einmal (Liste unter „Abhängigkeiten und Parallelisierung“). Paare, die nur durch Wellen getrennt sind: 2/9/12 (`app.module.ts`), 7/8 (`colonies.service.ts`), 9/11 (`compliance.module.ts`), 9/10 (`authority-notices.controller.ts`), 12/13 (`readiness-context.ts`, `rules/index.ts`), 5/6 (`label-templates.service.ts`).
- Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte Formeln und Texte; die Spec bleibt die Quelle der Einzelheiten. - Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte Formeln und Texte; die Spec bleibt die Quelle der Einzelheiten.
---
## Aktualisierung 2026-10-15 (nach T4)
Stand: HEAD 9760ce1 (T4 komplett). Der Plan wurde Punkt für Punkt gegen den Code geprüft; nur diese Datei wurde geändert, kein Code, kein Commit. Neben dem Plan läuft eine Nacharbeit, die die Durchsicht-Intervall-Funktion in `src/season/inspection-due.ts` teilt und `intervalDays` zu `GET /phenology/state` ergänzt; beides wird als vorhanden behandelt, T6 berührt es nicht.
**Änderungen (je eine Zeile)**
1. Tech Stack: Postgres der Test-DB ist 17 (Port 5433), Betrieb 18.6; keine 18-only-Funktionen.
2. Voraussetzungen T3: `treatmentRow` ist eine freie Modulfunktion (spreizt `...dto`), `recordIn(tx, data, actingUserId, bulkId?)` private Methode; `create` sperrt mit `lockRowForUpdate`; `update` kennt `apiaryId` noch nicht.
3. Voraussetzungen T4 vollständig und mit echten Namen: `NotificationCategory` als Prisma-Enum, `recipients` ist privat, `planIn` plant Telegram in der Schreib-Transaktion, `notificationDedupeKey`, `DbClient`, `WithdrawalWindow`-Felder, `withdrawal-text.ts`, `WithdrawalCheckService`, `TimeEventsStep`, Berliner Tage (Annahme 41).
4. Tagesregel: `DailyRule.occurrences(db: PrismaService, today, { seasons, day })` statt erfundener `evaluate`; Union `DailyEventType` erweitern; `ctx.day` ist ein Berliner Tag; `CATCH_UP_DAYS` exportiert.
5. Migrationen: Stempel geprüft (alle hinter `20261015110000_t4_telegram`); kein Konflikt mit T4 (`Product.shelfLifeMonths` existiert, `AuditAction.PRINT`, `DocumentEntityType`-Werte und Rechte noch nicht).
6. Rechte: `sortOrder` 3700 und 3800 (höchster Wert ist 3600).
7. Lock-Reihenfolge: `Apiary` an die erste Stelle von `LOCKABLE_TABLES` (Task 1); `Product` steht schon in der Map und wird nicht doppelt eingetragen; acht neue Tabellen (`LabelTemplate`, `SiteSettings`, `VeterinaryMedicine`, `VeterinaryMedicineLot`, `ComplianceSettings`, `AuthorityNotice`, `ApiaryRestriction`, `ComplianceAcknowledgement`).
8. Lock-Reihenfolge, Aufrufer (vollständig): heute sperrt nur `AutopilotService.syncApiaryIn` Stand und Volk zusammen (schon Stand zuerst, keine Codeänderung); zu ändern sind `ColoniesService.create/update/move/merge`, `ApiariesService.update/deactivate/reactivate` und `ApiaryRestrictionsService.create/update`; `InspectionsService` ruft `updateIn` unter Volkssperre weiter, `updateIn` sperrt keinen Stand.
9. Task 1 Tests: `row-lock.spec.ts` Reihenfolge-Test ersetzt, Einmaligkeit jeder Tabelle, `it.each` um T6-Tabellen; Autopilot-Spec als Wächter.
10. Task 8: Sperrmuster Stand (FOR SHARE, aufsteigend) vor Volk, Neuvergleich des Standes unter der Sperre (409), `assertApiaryActive` in die Transaktion, `ApiaryRestrictionsService` sperrt Stände FOR UPDATE; vier neue Tests.
11. Task 7: `AggregateType` kennt `'Apiary'` schon (nur `'Compliance'` ergänzen); `ApiariesService` hat heute keine Transaktion, alle vier Pfade werden umgebaut; Handler-Registrierung in einem Modul, das App und Worker importieren, festgehalten.
12. Task 14: `DOMAIN_EVENT_LABELS` (`src/lib/system/labels.ts`) bekommt die vier Ereignisse mit deutschen Namen; Test dazu.
13. Task 14: Service Worker darf Compliance-Daten nie zwischenspeichern; acht neue Pfade in `NEVER_CACHED_API_PATHS`, `SW_ROUTES_VERSION` heben, Tests in `sw-routes.spec.ts`.
14. Task 14: neue `src/lib/compliance/errors.ts` auf Basis von `describeSaveError`/`saveErrorText` (Muster `confirmationRequired`) statt eigener Fehlerlogik; 18 und 19 lesen nur.
15. Tasks 14 bis 19: Wiederverwendungsliste (`PrefillStatus`, `ConfirmDialog` `triggerDisabled`/`triggerDescribedBy`, `ToggleChip` `disabled`, `describeSaveError`, `DocumentUploadCard`); `StepUpDialog` und `refreshOn401: false` bewusst nicht (Frage 5).
16. Task 15: `src/lib/documents/types.ts` (`DocumentEntityType` plus drei Arten) fehlte im Plan; gehört Task 15, 18 und 19 lesen.
17. Task 16: Katalog-Vorbelegung zeigt Lade-/Fehlerzustand mit `PrefillStatus` (Annahme 53).
18. Task 17: Fehler über `describeSaveError`, 409 `LABEL_PARTICULARS_MISSING` über Leser in `check.ts`; Prüfantwort enthält die Anschrift und kommt nie in Persistenz oder Cache.
19. Task 18/19: Fehler- und Dialogmuster der Wiederverwendungsliste eingetragen.
20. Task 2: `roundKg` gibt es seit T4 exportiert in `honey-plausibility.ts`; die Kopie delegiert an `roundToThousandths`, keine dritte Kopie; `test/extractions.e2e-spec.ts` in die Verifikation.
21. Task 3: Beschreibung von `treatmentRow`/`update` an den Code angepasst; Reihenfolge Volk vor Packung.
22. Task 4: `create`/`update`/`delete` der Dokumente haben `actingUserId` schon; neu nur `findAll` und `readFile`.
23. Task 6: Signaturwechsel von `renderLabelsPdf(templateId, bottlingBatchId, startPosition = 1)` samt Aufrufern (Controller, Spec) benannt.
24. Task 9: Standardtage `referenceDate`/`sentOn` und `today` der Prüfbereitschaft sind Berliner Tage (`berlinToday()`), nicht UTC der DB-Uhr (Annahme 41).
25. Sperrbezirk-Tag bleibt UTC (Spec 5.6 sagt es ausdrücklich), im Plan markiert, Wechsel ist eine Zeile in `dbUtcDay`; Frage 1.
26. Task 11: `recipientsFor` gestrichen (die private `recipients` existiert); `recordForPermissionOnce` nutzt sie; Test für Telegram-Planung mit/ohne Kategorie `SYSTEM`.
27. Task 11: Kategorie `SYSTEM` bestätigt; Texte (unter 600 Zeichen, ohne Adresse, Link im Feld `link`) passen ins Telegram-Format; Zustellung nur bei gewählter Kategorie (Frage 2).
28. Task 11: Worker = API: `ComplianceModule` in beiden Wurzeln, kein `PermissionsModule` in der Kette, Tests in `worker-runtime.e2e-spec.ts` und `domain-event-registry.spec.ts`.
29. Task 13: `harvest-withdrawal` nutzt `withdrawalWindowOf`/`isInWindow`/`WithdrawalCheckService.conflictsForHarvests`/`withdrawalRangeText` statt eigener Fensterlogik.
30. Geteilte Dateien je Welle: `labels.ts`, `sw-routes.js`/`sw.js`/`sw-routes.spec.ts`, `compliance/errors.ts` nur Task 14; `documents/types.ts` nur Task 15; `honey-plausibility.ts` nur Task 2; `inspections.service.ts` und `autopilot.service.ts` nur gelesen.
31. Gates: `npm run lint` hat `--fix`, der Plan nutzt `npx eslint`; e2e `testTimeout` 20 s, `maxWorkers: 4`, Gesamtlauf mit `--runInBand`; Frontend `npm run test` ist `vitest run`.
32. Test-DB ist nicht leer: neue Projektregel mit Aufräumen der T6-Tabellen, Differenzen statt absoluten Zahlen, Prüfung je `key` + `subjectId`; Tests in Tasks 9, 11, 12, 13 umformuliert („leeres System“, „READY“).
33. Abschluss: Annahmen ab Nummer 59, zusätzlich §2 (Seiten) und Service-Worker-Absatz, Sperr-Reihenfolge als Annahme.
**Geprüft und unverändert**: `DomainEventsService`, `@OnDomainEvent`, `dailyDedupeKey`, `utcDay`, `inCatchUpWindow`, `AuditLogService.record(entry, tx)`, `lockRowForUpdate`/`lockRowsForShare`/`lockRowsForUpdate`, `runBulk` (Callback `(tx, entry, colony, bulkId)`), `lockColony`/`colonyTransitionEvents`, `LIVING_COLONY_WHERE`, `loggableError`, `drainDomainEvents`, `@Idempotent`, `IsOptionalNotNull`, `optionalDate`, `fifoOrder`, `pdf-table.ts`-Helfer, `JobPermissionsModule`, `groupTreatmentLog`, `summarizeStock`, `ColonySplit`, `GET /treatments/last-by-product`, `PrefillNote`; Annahmen 10, 11, 15, 20 stimmen mit ARCHITECTURE überein. Spec-Abdeckung: jeder Abschnitt hat weiter einen Task; nichts, was T4 geliefert hat, wird doppelt gebaut (Haltbarkeit, Meldungs-Fan-out, Fenster-Helfer, `roundKg` wurden zusammengeführt oder wiederverwendet).
**Offene Fragen an den Inhaber**
1. Sperrbezirk-Tag: Spec 5.6 sagt UTC-Tag; die Tagesauswertung nutzt seit T4 Berliner Tage. Kurz nach Mitternacht Berliner Zeit gilt eine Sperre, die heute beginnt, dann noch nicht, und eine, die gestern endete, noch. Vorschlag: Berliner Tag (Spec 5.6 anpassen). Bis zur Antwort bleibt UTC.
2. Die Meldungen „Bestand geändert“ und „TSK-Stichtag“ tragen die Kategorie `SYSTEM`; Telegram bekommt sie nur, wenn der Nutzer `SYSTEM` gewählt hat (Vorgabe nur `TASK_DUE` und `DIGEST`). Reicht das, oder soll es eine eigene Kategorie `COMPLIANCE` geben (Migration, Profil-UI, Telegram-Auswahl)?
3. Service Worker: Der Plan schließt alle Compliance-, Packungs- und Dokument-Pfade vom Cache aus (dort kein Offline-Lesen). Gewollt? Das Präfix `/api/v1/documents` trifft damit auch Fotos der Durchsicht.
4. Lock-Reihenfolge Stand vor Volk ist wie beschlossen umgesetzt; bitte bestätigen, dass ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, mit 409 „bitte neu laden“ enden darf (statt automatisch zu wiederholen).
5. Soll die Ausnahme vom Sperrbezirk (`restrictionOverrideReason`) einen Nachweis (Step-up wie beim Backup-Import) verlangen? Die Spec sieht keinen vor; der Plan baut keinen ein.
6. Weiterhin offen aus der Spec: Paragraphen-Nummern, Fristen und Landesrecht Bayern (Annahmen als Einstellungen, vor dem Rollout gegen gesetze-im-internet.de/EUR-Lex abgleichen); es wurden keine Rechtswerte in den Plan oder Code geschrieben.
### Inhaber-Entscheidungen 2026-10-15 (bindend, gehen Spec und Plantext oben vor)
1. **Sperrbezirk-Tag = Berliner Kalendertag** (`berlinToday()`), nicht UTC (Spec 5.6 wird angepasst). Aktiv bei `startsOn` ≤ Berliner Tag ≤ `endsOn`. Gilt für Task 8 (Wächter-Helfer), Task 12/13 (Prüfbereitschaft) und Task 19 (Stand-Band). Die Zeile `dbUtcDay` entfällt; Vergleich über den Berliner Tag wie in der Tagesauswertung.
2. **Eigene Meldungs-Kategorie `COMPLIANCE`** („Behörden & Prüfung“) für die Hinweise „Bestand geändert“ und „TSK-Stichtag“.
- Task 1: `NotificationCategory` + `COMPLIANCE` (eigene Migration, Wert nicht in derselben Migration benutzt). Standardmäßig für neue und bestehende Nutzer in der App und für Telegram ausgewählt: Vorgabe-Liste erweitern; bestehende `NotificationPreference`-Zeilen bekommen den Wert per Daten-Migration dazu.
- Task 11 nutzt `COMPLIANCE`.
- Frontend (Task 18): Label „Behörden & Prüfung“ in `lib/notifications/labels.ts` und in der Kategorienauswahl der Telegram-Karte.
3. **Ausnahme vom Sperrbezirk ohne Passwort-Abfrage:** Begründung 10–500 Zeichen, nur `compliance:manage`, Audit + Standhistorie (wie Spec).
### Orchestrator-Rulings 2026-10-15
4. **Service Worker:** Nicht `/api/v1/documents` als ganzes Präfix sperren, sonst sind Durchsichtsfotos offline weg. Nie gecacht werden die Compliance-, Arzneimittel- und Sperrbezirk-Pfade. Dokumente nur, wenn der Pfad die neuen Arten (`MEDICINE_LOT`, `AUTHORITY_NOTICE`, `APIARY_RESTRICTION`) eindeutig erkennen lässt. Sonst schützt der Server mit `Cache-Control: no-store` auf diesen Downloads und Listen; der SW respektiert `no-store`. Task 14 prüft das und testet es.
5. **Gleichzeitige Standänderung zwischen Lesen und Sperre:** 409 mit „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch (wie bisher in der App).