feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

+105 -2
View File
@@ -63,12 +63,114 @@ async function createVersion2Queue(entry: object): Promise<void> {
db.close();
}
/** A device that last opened the app with DB v3 (before plant reports). */
async function createVersion3Queue(inspection: object): Promise<void> {
const db = await openDB(DB_NAME, 3, {
upgrade(database) {
for (const store of [...VERSION_2_STORES, "pending-bulk"]) {
database.createObjectStore(store, {
keyPath: "id",
autoIncrement: true,
});
}
const photos = database.createObjectStore("pending-photos", {
keyPath: "id",
});
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await db.add("pending-inspections", inspection);
db.close();
}
describe("offline queue database", () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
it("upgrading v2 → v3 keeps already queued inspections", async () => {
it.each([
["a fresh install (0)", 0],
["version 1", 1],
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
if (from === 1) {
const old = await openDB(DB_NAME, 1, {
upgrade(database) {
database.createObjectStore("pending-inspections", {
keyPath: "id",
autoIncrement: true,
});
},
});
await old.add("pending-inspections", {
input: INSPECTION,
queuedAt: "2026-10-01T08:00:00.000Z",
});
old.close();
}
const db = await loadDb();
await db.queuePhenologyReport(
{ phase: "APFEL", date: "2026-04-20" },
"plant-key-0000000003",
"user-1",
);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
upgraded.close();
});
it("db upgrade 3 -> 4 keeps queued inspections and adds the plant report queue", async () => {
await createVersion3Queue({
input: INSPECTION,
idempotencyKey: "key-from-v3",
ownerUserId: "user-1",
queuedAt: "2026-10-01T08:00:00.000Z",
});
const db = await loadDb();
const entries = await db.getPendingEntries("pending-inspections");
expect(entries).toEqual([
{
id: 1,
input: INSPECTION,
idempotencyKey: "key-from-v3",
ownerUserId: "user-1",
queuedAt: "2026-10-01T08:00:00.000Z",
},
]);
await db.queuePhenologyReport(
{ phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
"key-plant-0000000001",
"user-1",
);
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([
expect.objectContaining({
input: { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
idempotencyKey: "key-plant-0000000001",
ownerUserId: "user-1",
}),
]);
expect(await db.summarizeQueue("user-1")).toEqual({
own: 2,
failed: 0,
foreign: 0,
});
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
upgraded.close();
});
it("upgrading v2 → v4 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
@@ -90,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(3);
expect(upgraded.version).toBe(4);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
@@ -98,6 +200,7 @@ describe("offline queue database", () => {
"pending-photos",
"offline-packs",
"meta",
"pending-phenology-reports",
]),
);
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([