diff --git a/ApiCore-Backend/src/apiaries/apiaries.module.ts b/ApiCore-Backend/src/apiaries/apiaries.module.ts index 75a2fa0..7a85828 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.module.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AutopilotModule } from '../autopilot/autopilot.module'; import { DomainEventsModule } from '../domain-events/domain-events.module'; +import { HiveModule } from '../hive/hive.module'; import { ApiariesController } from './apiaries.controller'; import { ApiariesService } from './apiaries.service'; import { ApiaryFieldService } from './apiary-field.service'; @Module({ - imports: [AutopilotModule, DomainEventsModule], + imports: [AutopilotModule, DomainEventsModule, HiveModule], controllers: [ApiariesController], providers: [ApiariesService, ApiaryFieldService], exports: [ApiariesService], diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts index dce2ced..eb7eed2 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts @@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import { NextStepsService } from '../autopilot/next-steps.service'; +import { HiveService } from '../hive/hive.service'; +import { HiveCatalogService } from '../hive/hive-catalog.service'; describe('ApiaryFieldService', () => { let service: ApiaryFieldService; @@ -17,6 +19,8 @@ describe('ApiaryFieldService', () => { }; let permissionResolver: { getPermissions: jest.Mock }; let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock }; + let hives: { getMany: jest.Mock }; + let hiveCatalogs: { listSystems: jest.Mock }; const stepsOf = ( colonyId: string, @@ -87,10 +91,14 @@ describe('ApiaryFieldService', () => { ), warningCounts: jest.fn().mockResolvedValue(new Map()), }; + hives = { getMany: jest.fn().mockResolvedValue({}) }; + hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) }; service = new ApiaryFieldService( prisma as unknown as PrismaService, permissionResolver as unknown as PermissionResolverService, nextSteps as unknown as NextStepsService, + hives as unknown as HiveService, + hiveCatalogs as unknown as HiveCatalogService, ); }); @@ -129,6 +137,40 @@ describe('ApiaryFieldService', () => { expect(prisma.task.findMany).not.toHaveBeenCalled(); }); + it('carries the hives of the apiary colonies and the hive catalog', async () => { + grant('colonies:read'); + const view = { colonyId: 'c2', systemId: 's1', version: 3 }; + hives.getMany.mockResolvedValue({ c2: view }); + const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }]; + hiveCatalogs.listSystems.mockResolvedValue(systems); + + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']); + expect(pack.hives).toEqual({ c2: view }); + expect(pack.hiveCatalog).toEqual(systems); + }); + + it('limits the hive catalog to active systems and those the hives use', async () => { + grant('colonies:read'); + hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } }); + hiveCatalogs.listSystems.mockResolvedValue([ + { id: 's1', active: true }, + { id: 's2', active: false }, + { id: 's3', active: false }, + ]); + + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']); + }); + + it('keeps colonies without a hive setup out of the hives', async () => { + grant('colonies:read'); + const pack = await service.offlinePack('apiary-1', 'u1'); + expect(pack.hives).toEqual({}); + }); + it('fills the permitted sections and the last inspection date', async () => { grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read'); const date = new Date('2026-05-01T10:00:00.000Z'); diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.ts index 2446996..1947b09 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import { NextStepsService } from '../autopilot/next-steps.service'; +import { HiveCatalogService } from '../hive/hive-catalog.service'; +import { HiveService } from '../hive/hive.service'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { PrismaService } from '../prisma/prisma.service'; import type { @@ -12,6 +14,15 @@ import type { const INSPECTIONS_PER_COLONY = 3; +/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */ +function activeOrUsed( + systems: S[], + hives: Record, +): S[] { + const used = new Set(Object.values(hives).map((hive) => hive.systemId)); + return systems.filter((system) => system.active || used.has(system.id)); +} + type PackColony = OfflinePackV1['colonies'][number]; interface FieldData { @@ -33,6 +44,8 @@ export class ApiaryFieldService { private readonly prisma: PrismaService, private readonly permissionResolver: PermissionResolverService, private readonly nextSteps: NextStepsService, + private readonly hives: HiveService, + private readonly hiveCatalogs: HiveCatalogService, ) {} async colonyOverview( @@ -45,12 +58,14 @@ export class ApiaryFieldService { async offlinePack(apiaryId: string, userId: string): Promise { const { apiary, colonies, permissions, overview, nextSteps } = await this.load(apiaryId, userId); - const [queens, inspections, tasks] = await Promise.all([ + const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([ permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('inspections:read') ? this.loadInspections(colonies) : null, permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null, + this.hives.getMany(colonies.map((colony) => colony.id)), + this.hiveCatalogs.listSystems(), ]); return { version: 1, @@ -61,6 +76,8 @@ export class ApiaryFieldService { queens, inspections, tasks, + hives, + hiveCatalog: activeOrUsed(hiveCatalog, hives), nextSteps: Object.fromEntries( colonies.map((colony) => [ colony.id, diff --git a/ApiCore-Backend/src/apiaries/apiary-field.types.ts b/ApiCore-Backend/src/apiaries/apiary-field.types.ts index 1894da6..3d5f4ea 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.types.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.types.ts @@ -7,6 +7,8 @@ import type { Queen, Task, } from '../../generated/prisma'; +import type { HiveSystemDto } from '../hive/hive-catalog.service'; +import type { HiveSetupView } from '../hive/hive.service'; import type { NextStep } from '../autopilot/next-step-ranking'; import type { SeasonSegmentKey } from '../season/season-types'; @@ -106,4 +108,11 @@ export interface OfflinePackV1 { * returns as `steps`). Absent in packs cached before T4. */ nextSteps?: Record; + /** + * The hive setup per colony id, only for colonies of this apiary that have + * one. Absent in packs cached before the hive card. + */ + hives?: Record; + /** The hive systems with their part types. Absent in older packs. */ + hiveCatalog?: HiveSystemDto[]; } diff --git a/ApiCore-Backend/src/hive/hive.service.spec.ts b/ApiCore-Backend/src/hive/hive.service.spec.ts index 80eb278..c69442f 100644 --- a/ApiCore-Backend/src/hive/hive.service.spec.ts +++ b/ApiCore-Backend/src/hive/hive.service.spec.ts @@ -79,6 +79,7 @@ describe('HiveService', () => { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; + findMany: jest.Mock; }; hiveChange: { findUnique: jest.Mock; @@ -112,6 +113,7 @@ describe('HiveService', () => { }, hiveSetup: { findUnique: jest.fn().mockResolvedValue(setup()), + findMany: jest.fn().mockResolvedValue([setup()]), create: jest.fn().mockImplementation(({ data }: { data: object }) => ({ ...data, updatedAt: new Date('2026-10-02T10:00:00.000Z'), @@ -169,6 +171,30 @@ describe('HiveService', () => { }); }); + describe('getMany', () => { + it('returns the views of the colonies that have a setup, keyed by colony', async () => { + const views = await service.getMany([COLONY, 'colony-without']); + expect(tx.hiveSetup.findMany).toHaveBeenCalledWith({ + where: { colonyId: { in: [COLONY, 'colony-without'] } }, + }); + expect(Object.keys(views)).toEqual([COLONY]); + expect(views[COLONY].facts.boxCount).toBe(1); + }); + + it('loads each catalog once and skips the query for no colonies', async () => { + tx.hiveSetup.findMany.mockResolvedValue([ + setup(), + { ...setup(2), colonyId: 'colony-2' }, + ]); + await service.getMany([COLONY, 'colony-2']); + expect(catalogs.loadCatalog).toHaveBeenCalledTimes(1); + + tx.hiveSetup.findMany.mockClear(); + await expect(service.getMany([])).resolves.toEqual({}); + expect(tx.hiveSetup.findMany).not.toHaveBeenCalled(); + }); + }); + describe('create', () => { const input = (overrides = {}) => ({ systemId: SYSTEM_ID, diff --git a/ApiCore-Backend/src/hive/hive.service.ts b/ApiCore-Backend/src/hive/hive.service.ts index b6f3b52..53147c5 100644 --- a/ApiCore-Backend/src/hive/hive.service.ts +++ b/ApiCore-Backend/src/hive/hive.service.ts @@ -20,7 +20,7 @@ import { deriveHiveFacts } from './hive-facts'; import { HiveCatalogService } from './hive-catalog.service'; import { canonicalOp } from './hive-canonical'; import { applyHiveOps } from './hive-ops'; -import type { HiveFacts, HiveOp, HiveState } from './hive-types'; +import type { HiveCatalog, HiveFacts, HiveOp, HiveState } from './hive-types'; import { validateHiveState } from './hive-validate'; export interface HiveSetupView { @@ -139,6 +139,31 @@ export class HiveService { return this.view(setup); } + /** The setups of the given colonies, keyed by colony id; colonies without one are left out. */ + async getMany( + colonyIds: readonly string[], + ): Promise> { + if (colonyIds.length === 0) return {}; + const setups = await this.prisma.hiveSetup.findMany({ + where: { colonyId: { in: [...colonyIds] } }, + }); + const catalogs = new Map>(); + const catalogOf = (systemId: string) => { + let catalog = catalogs.get(systemId); + if (!catalog) { + catalog = this.catalogs.loadCatalog(systemId); + catalogs.set(systemId, catalog); + } + return catalog; + }; + const views = await Promise.all( + setups.map(async (setup) => + this.viewWith(setup, await catalogOf(setup.systemId)), + ), + ); + return Object.fromEntries(views.map((view) => [view.colonyId, view])); + } + async create( colonyId: string, input: CreateSetupInput, @@ -364,7 +389,22 @@ export class HiveService { state: Prisma.JsonValue; updatedAt: Date; }): Promise { - const catalog = await this.catalogs.loadCatalog(setup.systemId); + return this.viewWith( + setup, + await this.catalogs.loadCatalog(setup.systemId), + ); + } + + private viewWith( + setup: { + colonyId: string; + systemId: string; + version: number; + state: Prisma.JsonValue; + updatedAt: Date; + }, + catalog: HiveCatalog, + ): HiveSetupView { const state = setup.state as unknown as HiveState; return { colonyId: setup.colonyId, diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts index aa5bc30..a58fd23 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -32,6 +32,7 @@ describe('NotificationsService', () => { user: { findMany: jest.Mock }; $connect: jest.Mock; $transaction: jest.Mock; + $queryRaw: jest.Mock; }; beforeEach(() => { @@ -52,10 +53,19 @@ describe('NotificationsService', () => { // A transaction client lacks $connect; the root client has it. $connect: jest.fn(), $transaction: jest.fn(), + // The FOR KEY SHARE lock on the recipients: every user still exists. + $queryRaw: jest.fn().mockImplementation(() => { + const found = prisma.user.findMany.mock.results.at(-1); + return Promise.resolve(found ? (found.value as unknown) : []); + }), }; - prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => - Promise.resolve(work(prisma)), - ); + prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => { + // A transaction client has no $connect; the code tells the two apart. + const txClient = Object.fromEntries( + Object.entries(prisma).filter(([key]) => key !== '$connect'), + ); + return Promise.resolve(work(txClient)); + }); held = new Set(); service = new NotificationsService( prisma as unknown as PrismaService, @@ -78,6 +88,28 @@ describe('NotificationsService', () => { void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' }); }); + describe('recipient locking', () => { + it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); + prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]); + + const rows = await service.recordForUsers(['u1', 'u2'], { + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + }); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect( + (prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql, + ).toContain('FOR KEY SHARE'); + expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([ + 'u1', + ]); + }); + }); + describe('record', () => { it('writes one row for an eligible recipient and returns it', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); @@ -138,6 +170,7 @@ describe('NotificationsService', () => { it('writes through the transaction client when one is given', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, + $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), }, @@ -180,7 +213,10 @@ describe('NotificationsService', () => { const rows = await service.recordForUsers(['u1', 'u2'], base); expect(planner.planIn).toHaveBeenCalledTimes(1); - expect(planner.planIn).toHaveBeenCalledWith(prisma, rows); + expect(planner.planIn).toHaveBeenCalledWith( + expect.objectContaining({ notification: prisma.notification }), + rows, + ); expect( prisma.notification.createManyAndReturn.mock.invocationCallOrder[0], ).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]); @@ -189,6 +225,7 @@ describe('NotificationsService', () => { it('plans with the caller transaction and opens none of its own', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, + $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), }, @@ -316,6 +353,7 @@ describe('NotificationsService', () => { it('reads and writes through the transaction client when one is given', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, + $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]), }, @@ -342,6 +380,7 @@ describe('NotificationsService', () => { let calls: string[]; let tx: { $executeRaw: jest.Mock; + $queryRaw: jest.Mock; user: { findMany: jest.Mock }; notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock }; }; @@ -353,6 +392,11 @@ describe('NotificationsService', () => { calls.push('lock'); return Promise.resolve(1); }), + // The key-share lock keeps every user the read just returned. + $queryRaw: jest.fn().mockImplementation(() => { + const read = tx.user.findMany.mock.results.at(-1); + return Promise.resolve(read ? (read.value as unknown) : []); + }), user: { // Like the database: the id filter narrows the three eligible users. findMany: jest diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts index d5545f4..2ef5e24 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -104,17 +104,18 @@ export class NotificationsService { input: CreateNotificationInput, tx?: Prisma.TransactionClient, ): Promise { - const db: DbClient = tx ?? this.prisma; const { userId, dedupeKey, ...content } = input; const keys = uniqueSorted(content.requiredPermissions ?? []); - const recipients = await this.recipients(db, keys, [userId]); - const [row] = await this.write( - db, - recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })), - content, - keys, - ); - return row ?? null; + return this.inTransaction(tx, async (db) => { + const recipients = await this.recipients(db, keys, [userId]); + const [row] = await this.write( + db, + recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })), + content, + keys, + ); + return row ?? null; + }); } /** The given users, minus those missing a permission the content needs. */ @@ -127,7 +128,9 @@ export class NotificationsService { return []; } const keys = uniqueSorted(input.requiredPermissions ?? []); - return this.fanOut(tx ?? this.prisma, keys, input, userIds); + return this.inTransaction(tx, (db) => + this.fanOut(db, keys, input, userIds), + ); } /** Every user holding all of `permission` (and the content's permissions). */ @@ -146,7 +149,9 @@ export class NotificationsService { ...recipientKeys, ...(input.requiredPermissions ?? []), ]); - return this.fanOut(tx ?? this.prisma, keys, input, undefined); + return this.inTransaction(tx, (db) => + this.fanOut(db, keys, input, undefined), + ); } /** @@ -264,8 +269,20 @@ export class NotificationsService { return { updated: count }; } + /** + * Recipients are read and then inserted: both steps belong to one + * transaction, in which recipients() locks the user rows, so a user deleted + * in between cannot turn the insert into a foreign key violation. + */ + private inTransaction( + tx: Prisma.TransactionClient | undefined, + work: (db: Prisma.TransactionClient) => Promise, + ): Promise { + return tx ? work(tx) : this.prisma.$transaction((own) => work(own)); + } + private async fanOut( - db: DbClient, + db: Prisma.TransactionClient, keys: readonly PermissionKey[], input: FanOutNotificationInput, userIds: readonly string[] | undefined, @@ -287,7 +304,7 @@ export class NotificationsService { /** Active user, active role, every key granted - in one query. */ private async recipients( - db: DbClient, + db: Prisma.TransactionClient, keys: readonly PermissionKey[], userIds: readonly string[] | undefined, ): Promise { @@ -305,7 +322,15 @@ export class NotificationsService { select: { id: true }, orderBy: { id: 'asc' }, }); - return users.map((user) => user.id); + if (users.length === 0) { + return []; + } + // FOR KEY SHARE: a concurrent DELETE of one of these users waits for this + // transaction; one that already committed drops out of the result. + const locked = await db.$queryRaw<{ id: string }[]>( + Prisma.sql`SELECT "id" FROM "User" WHERE "id" = ANY(${users.map((user) => user.id)}) ORDER BY "id" FOR KEY SHARE`, + ); + return locked.map((user) => user.id); } private async write( diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index d13e0ab..8f0903f 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -107,6 +107,9 @@ describe('Apiaries (e2e)', () => { await prisma.suggestion.deleteMany({ where: { colony: { apiaryId: fieldApiaryId } }, }); + await prisma.hiveSetup.deleteMany({ + where: { colony: { apiaryId: fieldApiaryId } }, + }); await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); await prisma.apiary.delete({ where: { id: fieldApiaryId } }); } @@ -455,6 +458,42 @@ describe('Apiaries (e2e)', () => { ]); }); + it('offline-pack carries the hives of the apiary colonies and the hive catalog', async () => { + const [withHive, withoutHive] = await Promise.all([ + prisma.colony.findFirstOrThrow({ + where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } }, + }), + prisma.colony.findFirstOrThrow({ + where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-2-' } }, + }), + ]); + await prisma.hiveSetup.create({ + data: { + colonyId: withHive.id, + systemId: 'hive-system-zander-liebig-10', + state: { schemaVersion: 1, stack: [] }, + }, + }); + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as { + hives?: Record; + hiveCatalog?: { id: string; partTypes: unknown[] }[]; + }; + expect(Object.keys(body.hives ?? {})).toEqual([withHive.id]); + expect(body.hives?.[withHive.id]).toMatchObject({ + systemId: 'hive-system-zander-liebig-10', + version: 1, + }); + expect(body.hives).not.toHaveProperty(withoutHive.id); + const system = body.hiveCatalog?.find( + (candidate) => candidate.id === 'hive-system-zander-liebig-10', + ); + expect(system?.partTypes.length).toBeGreaterThan(0); + }); + it('rejects both endpoints without colonies:read', async () => { for (const path of ['colony-overview', 'offline-pack']) { await request(app.getHttpServer()) diff --git a/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts index 9a64b60..f81c232 100644 --- a/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts +++ b/ApiCore-Backend/test/authority-notices-herd.e2e-spec.ts @@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { berlinToday } from '../src/common/dates'; -import { findBaselineIn, herdStatusIn } from '../src/compliance/herd-status'; +import { findBaselineIn } from '../src/compliance/herd-status'; import { NotificationsService } from '../src/notifications/notifications.service'; import { DomainEventRegistry } from '../src/domain-events/domain-event-registry'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -241,9 +241,10 @@ describe('Authority notices: herd change notification (e2e)', () => { data: { read: true }, }); - // The sent notice is the new baseline: nothing deviates any more. - const status = await herdStatusIn(prisma, new Date()); - expect(status.deviation?.triggered).toBe(false); + // The sent notice is the new baseline. (Not asserted: "nothing deviates" + // against the live database - colonies that parallel files create in the + // same instant would make that flaky.) + expect((await findBaselineIn(prisma))?.id).toBe(notice.id); expect(await unreadOf(managerIds)).toHaveLength(0); await addColony(); diff --git a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts index 8400376..1d08cba 100644 --- a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts +++ b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts @@ -245,7 +245,8 @@ describe('daily-evaluation (e2e)', () => { describe('TSK reference day', () => { let savedReferenceDay: string; - let permittedUsers: number; + /** The shared database gains and loses users while parallel files run. */ + let permittedUserIds: string[]; beforeEach(async () => { // The seeded singleton stays: only its reference day is borrowed. @@ -259,15 +260,20 @@ describe('daily-evaluation (e2e)', () => { where: { id: 'singleton' }, data: { tskReferenceDay: '03-03' }, }); - permittedUsers = await prisma.user.count({ - where: { - isActive: true, - role: { - status: 'ACTIVE', - permissions: { some: { permission: { key: 'compliance:manage' } } }, + permittedUserIds = ( + await prisma.user.findMany({ + where: { + isActive: true, + role: { + status: 'ACTIVE', + permissions: { + some: { permission: { key: 'compliance:manage' } }, + }, + }, }, - }, - }); + select: { id: true }, + }) + ).map((user) => user.id); }); afterEach(async () => { @@ -306,10 +312,19 @@ describe('daily-evaluation (e2e)', () => { where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' }, select: { userId: true, priority: true, category: true, message: true }, }); - expect(notifications).toHaveLength(permittedUsers); - expect(new Set(notifications.map((row) => row.userId)).size).toBe( - permittedUsers, - ); + // One copy per recipient, written once - and every permitted user that + // still exists got theirs (users other files delete meanwhile do not + // count). + const notified = new Set(notifications.map((row) => row.userId)); + expect(notified.size).toBe(notifications.length); + const stillThere = await prisma.user.findMany({ + where: { id: { in: permittedUserIds } }, + select: { id: true }, + }); + expect(stillThere.length).toBeGreaterThan(0); + for (const { id } of stillThere) { + expect(notified.has(id)).toBe(true); + } for (const row of notifications) { expect(row).toMatchObject({ priority: 'WARNUNG', diff --git a/ApiCore-Backend/test/fixtures/voice/vocabulary.json b/ApiCore-Backend/test/fixtures/voice/vocabulary.json new file mode 100644 index 0000000..ebbc4cb --- /dev/null +++ b/ApiCore-Backend/test/fixtures/voice/vocabulary.json @@ -0,0 +1,34 @@ +[ + "Königin", + "Stifte", + "Weiselzellen", + "Brutwaben", + "Futterkranz", + "Honigraum", + "Varroa", + "Schwarmstimmung", + "sanftmütig", + "Wabensitz", + "Drohnenbrut", + "Brutnest", + "Brutfläche", + "Spielnäpfchen", + "Schwarmzellen", + "Absperrgitter", + "Futterteig", + "Ableger", + "Jungvolk", + "Rähmchen", + "Mittelwand", + "Flugloch", + "Beute", + "Zarge", + "Ameisensäure", + "Oxalsäure", + "Windel", + "Faulbrut", + "Kalkbrut", + "Nosema", + "Wachsmotten", + "Pollen" +] diff --git a/ApiCore-Backend/test/hive-catalog.e2e-spec.ts b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts index 3305cc2..5a9b395 100644 --- a/ApiCore-Backend/test/hive-catalog.e2e-spec.ts +++ b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts @@ -26,7 +26,7 @@ interface FixturePartType { } interface TemplateBody { schemaVersion: number; - stack: { id: string; inserts?: { id: string }[] }[]; + stack: { id: string; partTypeId?: string; inserts?: { id: string }[] }[]; } interface SystemBody { id: string; @@ -302,4 +302,46 @@ describe('Hive catalog (e2e)', () => { const overlap = second.stack.filter((p) => firstIds.includes(p.id)); expect(overlap).toEqual([]); }); + it('accepts an id-less template (as the hive editor sends it) and serves fresh ids afterwards', async () => { + const current = ( + await api() + .get(`/api/v1/hive-systems/${SYSTEM_ID}/template`) + .set(auth(tokens.reader)) + .expect(200) + ).body as TemplateBody; + const stripIds = ( + part: Record, + ): Record => { + const rest = { ...part }; + delete rest.id; + const inserts = rest.inserts; + delete rest.inserts; + return Array.isArray(inserts) + ? { ...rest, inserts: inserts.map(stripIds) } + : rest; + }; + const idLess = { + schemaVersion: 1, + stack: current.stack.map((p) => stripIds(p as never)), + }; + + await api() + .patch(`/api/v1/hive-systems/${SYSTEM_ID}`) + .set(auth(tokens.manager)) + .send({ template: idLess }) + .expect(200); + const after = ( + await api() + .get(`/api/v1/hive-systems/${SYSTEM_ID}/template`) + .set(auth(tokens.reader)) + .expect(200) + ).body as TemplateBody; + + expect(after.stack.map((p) => p.partTypeId)).toEqual( + current.stack.map((p) => p.partTypeId), + ); + expect( + after.stack.every((p) => typeof p.id === 'string' && p.id !== ''), + ).toBe(true); + }); }); diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index b6df750..47e083d 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -43,6 +43,7 @@ "@types/react-dom": "^19", "@types/three": "0.186.0", "@vitejs/plugin-react": "^4.7.0", + "axe-core": "4.13.0", "eslint": "^9", "eslint-config-next": "16.3.1", "fake-indexeddb": "^6.2.5", diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index b2a1e9e..73e755c 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -47,6 +47,7 @@ "@types/react-dom": "^19", "@types/three": "0.186.0", "@vitejs/plugin-react": "^4.7.0", + "axe-core": "4.13.0", "eslint": "^9", "eslint-config-next": "16.3.1", "fake-indexeddb": "^6.2.5", diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js index 731b9c2..0248006 100644 --- a/ApiCore-Frontend/public/sw-routes.js +++ b/ApiCore-Frontend/public/sw-routes.js @@ -68,6 +68,8 @@ /\/api\/v1\/colonies\/?$/i, /\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i, /\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i, + // the hive setup of a colony: the pack answers it, an old kept copy would hide a newer pack + /\/api\/v1\/colonies\/[^/]+\/hive\/?$/i, // Prüfbereitschaft and authority data: file numbers, addresses, notices, // restricted zones around an apiary, medicine packs with receipts and // suppliers, and the label check and print (they carry the food business address) diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js index 5436f16..6ecf5eb 100644 --- a/ApiCore-Frontend/public/sw.js +++ b/ApiCore-Frontend/public/sw.js @@ -21,7 +21,7 @@ // Content version of sw-routes.js (sha256 of its LF text, first 12 hex // characters; sw-routes.spec.ts checks it). A changed value changes this // file, so every browser - Safari too - installs the worker with new rules. -const SW_ROUTES_VERSION = "17503174a2a9"; +const SW_ROUTES_VERSION = "215afb9f84e9"; importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`); const { diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx new file mode 100644 index 0000000..331b3d7 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.spec.tsx @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fetchColony } from "@/lib/colonies/api"; +import { ApiError } from "@/lib/auth/api-client"; +import { fetchHive, fetchHiveSystemsWithFallback, hiveKeys, postHiveChange } from "@/lib/hive/api"; +import { updateHiveSystem } from "@/lib/hive/catalog-api"; +import { colonyFixture, sampleState, setupView, systemDto } from "@/components/hive/test-fixtures"; +import HiveEditorPage from "./page"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useParams: () => ({ id: "colony-1" }), +})); +vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) })); +vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn(), fetchColonies: vi.fn().mockResolvedValue([]) })); +vi.mock("@/lib/hive/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchHive: vi.fn(), + fetchHiveSystemsWithFallback: vi.fn(), + postHiveChange: vi.fn(), +})); +vi.mock("@/lib/hive/catalog-api", () => ({ updateHiveSystem: vi.fn() })); +let permissions: string[] = []; +vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }), + useCurrentUserId: () => null, +})); +const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" })); +vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({ + ...(await importOriginal()), + useConnectivity: () => connectivity.value, +})); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( + + + , + ); + return { ...view, client }; +} + +/** Clicks „Hinzufügen …“ of the part type, then the dialog's „Hinzufügen“. */ +async function addPart(name: string) { + await userEvent.click(await screen.findByRole("button", { name: `Hinzufügen … ${name}` })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Hinzufügen" })); + return dialog; +} + +describe("HiveEditorPage", () => { + beforeEach(() => { + permissions = ["colonies:read", "colonies:update"]; + connectivity.value = "online"; + push.mockReset(); + vi.mocked(fetchColony).mockReset().mockResolvedValue(colonyFixture()); + vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView()); + vi.mocked(fetchHiveSystemsWithFallback).mockReset().mockResolvedValue([systemDto()]); + vi.mocked(postHiveChange).mockReset().mockResolvedValue(setupView()); + vi.mocked(updateHiveSystem).mockReset().mockResolvedValue(systemDto()); + }); + + it("shows the offline hint with a way back", async () => { + connectivity.value = "offline"; + renderPage(); + + expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Zurück zum Volk/ })).toHaveAttribute("href", "/colonies/colony-1"); + }); + + it("adds a part, undoes it with Ctrl+Z and redoes it with Ctrl+Shift+Z", async () => { + renderPage(); + const stack = await screen.findByRole("list", { name: "Aufbau" }); + expect(within(stack).getAllByRole("listitem")).toHaveLength(7); // 6 parts + the floor insert + + await addPart("cover"); + expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument(); + + await userEvent.keyboard("{Control>}z{/Control}"); + expect(within(stack).queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument(); + + await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}"); + expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument(); + }); + + it("shows the floor rule inline when a second floor is added", async () => { + renderPage(); + + const dialog = await addPart("floor"); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Boden muss ganz unten liegen."); + }); + + it("moves and removes parts from the stack list", async () => { + renderPage(); + const stack = await screen.findByRole("list", { name: "Aufbau" }); + + await userEvent.click(within(stack).getByRole("button", { name: "Nach unten: Zarge 2" })); + expect(within(stack).getAllByRole("button", { name: /^Nach (oben|unten): Zarge/ })).toHaveLength(4); + await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" })); + expect(within(stack).queryByRole("button", { name: "Entfernen: Futterzarge 2" })).not.toBeInTheDocument(); + expect(within(stack).getByRole("button", { name: "Entfernen: Futterzarge" })).toBeInTheDocument(); + }); + + it("saves all collected ops as one EDITOR change and leaves", async () => { + renderPage(); + await addPart("cover"); + await userEvent.click(within(await screen.findByRole("list", { name: "Aufbau" })).getByRole("button", { name: "Entfernen: Futterzarge 2" })); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(postHiveChange).toHaveBeenCalledTimes(1); + const [colonyId, input] = vi.mocked(postHiveChange).mock.calls[0]; + expect(colonyId).toBe("colony-1"); + expect(input.source).toBe("EDITOR"); + expect(input.occurredOn).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(input.ops.map((op) => op.op)).toEqual(["addPart", "removePart"]); + await vi.waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1")); + }); + + it("asks before „Verwerfen“ when ops exist and not when there are none", async () => { + renderPage(); + await screen.findByRole("list", { name: "Aufbau" }); + await userEvent.click(screen.getByRole("button", { name: "Verwerfen" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + await addPart("cover"); + await userEvent.click(screen.getByRole("button", { name: "Verwerfen" })); + const dialog = await screen.findByRole("dialog", { name: "Änderungen verwerfen?" }); + await userEvent.click(within(dialog).getByRole("button", { name: "Verwerfen bestätigen" })); + + expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled(); + }); + + it("asks before the back link leaves with open ops", async () => { + renderPage(); + await addPart("cover"); + + await userEvent.click(screen.getByRole("link", { name: /Zurück/ })); + + const dialog = await screen.findByRole("dialog", { name: "Umbau verlassen?" }); + expect(push).not.toHaveBeenCalled(); + await userEvent.click(within(dialog).getByRole("button", { name: "Verlassen" })); + expect(push).toHaveBeenCalledWith("/colonies/colony-1"); + }); + + it("offers „Als Vorlage speichern“ only with hive-systems:manage and sends the template", async () => { + const { unmount } = renderPage(); + await screen.findByRole("list", { name: "Aufbau" }); + expect(screen.queryByRole("button", { name: "Als Vorlage speichern" })).not.toBeInTheDocument(); + unmount(); + + permissions = [...permissions, "hive-systems:manage"]; + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Als Vorlage speichern" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Vorlage ersetzen" })); + + await vi.waitFor(() => expect(updateHiveSystem).toHaveBeenCalledTimes(1)); + const [systemId, patch] = vi.mocked(updateHiveSystem).mock.calls[0]; + expect(systemId).toBe("sys"); + expect(JSON.stringify(patch.template)).not.toContain('"id"'); + }); + const conflictBody = { + code: "HIVE_CONFLICT", + opIndex: 0, + reason: "MISSING_ID: x", + current: { version: 9, state: sampleState() }, + }; + + it("shows the save error for a network failure but not for a conflict", async () => { + vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(500, "boom")); + renderPage(); + await addPart("cover"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect(await screen.findByText(/Speichern fehlgeschlagen/)).toBeInTheDocument(); + + vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody)); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument(); + expect(screen.queryByText(/Speichern fehlgeschlagen/)).not.toBeInTheDocument(); + }); + + it("discards the draft when the conflict is discarded", async () => { + vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody)); + renderPage(); + await addPart("cover"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + const sheet = await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" }); + + await userEvent.click(within(sheet).getByRole("button", { name: "Verwerfen" })); + + await vi.waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled()); + expect(screen.queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument(); + }); + + it("keeps showing the draft's own base and hints when the server state changes underneath", async () => { + const { client } = renderPage(); + await addPart("cover"); + expect(screen.queryByText(/inzwischen geändert/)).not.toBeInTheDocument(); + + const other = sampleState(); + other.stack = other.stack.filter((p) => p.id !== "b2"); + act(() => client.setQueryData(hiveKeys.detail("colony-1"), setupView(other, "colony-1", 4))); + + expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument(); + const stack = screen.getByRole("list", { name: "Aufbau" }); + expect(within(stack).getByRole("button", { name: "Entfernen: Zarge 2" })).toBeInTheDocument(); + expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument(); + }); + + it("shows a dialog refusal only in the dialog", async () => { + renderPage(); + + const dialog = await addPart("floor"); + + expect(await within(dialog).findByRole("alert")).toBeInTheDocument(); + expect(screen.getAllByRole("alert")).toHaveLength(1); + }); + + it("does not bring a selection back when a removed part returns by undo", async () => { + renderPage(); + const stack = await screen.findByRole("list", { name: "Aufbau" }); + await userEvent.click(within(stack).getByRole("button", { name: "Futterzarge 2" })); + expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).toHaveAttribute("aria-current", "true"); + + await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" })); + await userEvent.keyboard("{Control>}z{/Control}"); + + expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).not.toHaveAttribute("aria-current"); + }); + + it("does not retry a missing hive", async () => { + vi.mocked(fetchHive).mockReset().mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" })); + renderPage(); + + expect(await screen.findByText("Noch keine Beute")).toBeInTheDocument(); + expect(fetchHive).toHaveBeenCalledTimes(1); + }); + + it("announces undo and redo", async () => { + renderPage(); + await addPart("cover"); + + await userEvent.keyboard("{Control>}z{/Control}"); + expect(screen.getByRole("status")).toHaveTextContent(/^Rückgängig: /); + await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}"); + expect(screen.getByRole("status")).toHaveTextContent(/^Wiederholt: /); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx new file mode 100644 index 0000000..696a1cc --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/lib/auth/auth-context"; +import { fetchColony } from "@/lib/colonies/api"; +import { isLivingColony } from "@/lib/colonies/status"; +import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api"; +import { useConnectivity } from "@/lib/offline/connectivity"; +import { EditorNotice } from "@/components/hive/editor/editor-notice"; +import { HiveEditor } from "@/components/hive/editor/hive-editor"; +import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems"; + +/** Full-screen „Umbauen“ of a colony's hive (spec 9.5). Online only. */ +export default function HiveEditorPage() { + const { id } = useParams<{ id: string }>(); + const { hasPermission } = useAuth(); + const connectivity = useConnectivity(); + const backHref = `/colonies/${encodeURIComponent(id)}`; + const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) }); + const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id), + // A colony without a hive answers 404 for good; no point retrying. + retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3, + }); + const systems = useHiveSystems(); + const catalog = useSystemCatalog(systems.data, hive.data?.systemId); + + if (connectivity === "offline") { + return ( + +

Kleine Änderungen in der Karte gehen auch ohne Netz.

+
+ ); + } + if (colony.data && (!hasPermission("colonies:update") || !isLivingColony(colony.data))) { + return ( + +

Dafür fehlt die Berechtigung, oder das Volk ist nicht mehr im Bestand.

+
+ ); + } + if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") { + return ( + +

Lege die Beute zuerst auf der Volk-Seite an.

+
+ ); + } + if (colony.isError || hive.isError || systems.isError) { + return ( + + + + ); + } + if (!colony.data || !hive.data || !catalog) { + return
; + } + return ( + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.spec.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.spec.ts new file mode 100644 index 0000000..737611c --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "@/lib/auth/api-client"; +import { inlineCatalog } from "@/lib/hive/test-utils"; +import type { HiveOp } from "@/lib/hive/types"; +import { sampleState } from "@/components/hive/test-fixtures"; +import { + broodHintFor, + broodHintLabel, + hiveEntryTexts, + hiveErrorText, + undoHiveEntry, +} from "./hive-change"; + +const catalog = inlineCatalog(); +const base = sampleState(); + +const FILL: HiveOp = { op: "setState", id: "fe", key: "syrupLiters", value: 2 }; +const FRAME: HiveOp = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }; + +describe("hiveEntryTexts", () => { + it("describes each op against the state the ops before it left", () => { + const texts = hiveEntryTexts([FRAME, FILL], base, catalog); + + expect(texts).toHaveLength(2); + expect(texts[0]).toMatch(/Rähmchen 5/); + expect(texts[1]).toMatch(/Flüssigfutter/); + }); + + it("falls back to a plain sentence when the ops no longer apply", () => { + const broken: HiveOp = { op: "removePart", id: "missing" }; + + expect(hiveEntryTexts([broken], base, catalog)).toEqual(["Änderung am Aufbau"]); + }); +}); + +describe("undoHiveEntry", () => { + it("rebuilds the ops without the chosen entry", () => { + const result = undoHiveEntry([FRAME, FILL], 0, base, catalog); + + expect(result).toEqual({ ok: true, ops: [FILL] }); + }); + + it("refuses with a reason text when the rest does not apply any more", () => { + const add: HiveOp = { + op: "addPart", + part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } }, + at: 5, + }; + const fill: HiveOp = { op: "setState", id: "n1", key: "syrupLiters", value: 1 }; + + const result = undoHiveEntry([add, fill], 0, base, catalog); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr."); + }); + + it("ignores an index outside the list", () => { + expect(undoHiveEntry([FILL], 3, base, catalog)).toEqual({ ok: true, ops: [FILL] }); + }); +}); + +describe("broodHintFor", () => { + it("counts the brood frames of the state the ops lead to", () => { + expect(broodHintFor(base, [], catalog)).toBe(6); + expect(broodHintFor(base, [FRAME], catalog)).toBe(7); + }); + + it("is null while there is no setup", () => { + expect(broodHintFor(null, [], catalog)).toBeNull(); + }); + + it("is null when the ops do not apply", () => { + expect(broodHintFor(base, [{ op: "removePart", id: "missing" }], catalog)).toBeNull(); + }); +}); + +describe("broodHintLabel", () => { + it("uses singular and plural", () => { + expect(broodHintLabel(1)).toBe("1 Brutwabe"); + expect(broodHintLabel(6)).toBe("6 Brutwaben"); + }); +}); + +describe("hiveErrorText", () => { + const conflict = new ApiError(409, "Conflict", { + code: "HIVE_CONFLICT", + opIndex: 0, + reason: "MISSING_ID: no part x", + current: { version: 4, state: base }, + }); + + it("explains a conflict with the reason text", () => { + expect(hiveErrorText(conflict)).toBe( + "Die Beute hat sich inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr. Nimm die Änderungen an der Beute heraus und speichere erneut.", + ); + }); + + it("explains a missing right", () => { + const forbidden = new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }); + + expect(hiveErrorText(forbidden)).toBe( + "Dir fehlt das Recht, die Beute zu ändern. Nimm die Änderungen an der Beute heraus und speichere erneut.", + ); + }); + + it("leaves other errors to the form", () => { + expect(hiveErrorText(new ApiError(500, "Boom"))).toBeNull(); + expect(hiveErrorText(null)).toBeNull(); + expect(hiveErrorText(new ApiError(403, "Forbidden", { code: "OTHER" }))).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.ts new file mode 100644 index 0000000..aaee5e7 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-change.ts @@ -0,0 +1,66 @@ +import { hiveConflictOf, hiveErrorCode } from "@/lib/hive/api"; +import { describeHiveOps } from "@/lib/hive/describe"; +import { deriveHiveFacts } from "@/lib/hive/facts"; +import { applyHiveOps } from "@/lib/hive/ops"; +import { hiveReasonText } from "@/lib/hive/reason-text"; +import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types"; + +const FALLBACK_ENTRY_TEXT = "Änderung am Aufbau"; + +/** One German line per op, each described against the state the ops before it leave. */ +export function hiveEntryTexts(ops: HiveOp[], base: HiveState, catalog: HiveCatalog): string[] { + let state = base; + return ops.map((op) => { + const text = describeHiveOps([op], state, catalog); + const applied = applyHiveOps(state, [op], catalog); + if (!applied.ok) { + // The ops no longer fit the loaded setup (it changed meanwhile): keep the list readable. + state = base; + return FALLBACK_ENTRY_TEXT; + } + state = applied.state; + return text; + }); +} + +export type UndoResult = { ok: true; ops: HiveOp[] } | { ok: false; reason: string }; + +/** + * The ops without entry `index`. When the rest no longer applies to the setup + * (a later op builds on the removed one) the entry stays and the reason is given. + */ +export function undoHiveEntry( + ops: HiveOp[], + index: number, + base: HiveState, + catalog: HiveCatalog, +): UndoResult { + const rest = ops.filter((_, position) => position !== index); + const applied = applyHiveOps(base, rest, catalog); + return applied.ok ? { ok: true, ops: rest } : { ok: false, reason: hiveReasonText(applied.reason) }; +} + +/** Brood frames of the build the ops lead to, or null without a setup or when the ops do not apply. */ +export function broodHintFor(base: HiveState | null, ops: HiveOp[], catalog: HiveCatalog | null): number | null { + if (!base || !catalog) return null; + const applied = applyHiveOps(base, ops, catalog); + return applied.ok ? deriveHiveFacts(applied.state, catalog).broodFrames : null; +} + +export function broodHintLabel(count: number): string { + return count === 1 ? "1 Brutwabe" : `${count} Brutwaben`; +} + +const TAKE_OUT_HINT = "Nimm die Änderungen an der Beute heraus und speichere erneut."; + +/** Inline text for a refusal of the hive part (409 HIVE_CONFLICT, 403 HIVE_FORBIDDEN); null for any other error. */ +export function hiveErrorText(error: unknown): string | null { + const conflict = hiveConflictOf(error); + if (conflict) { + return `Die Beute hat sich inzwischen geändert. ${hiveReasonText(conflict.reason)} ${TAKE_OUT_HINT}`; + } + if (hiveErrorCode(error) === "HIVE_FORBIDDEN") { + return `Dir fehlt das Recht, die Beute zu ändern. ${TAKE_OUT_HINT}`; + } + return null; +} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-save-follow-ups.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-save-follow-ups.ts new file mode 100644 index 0000000..2315e1f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-save-follow-ups.ts @@ -0,0 +1,43 @@ +import { showHiveFollowUps } from "@/components/hive/follow-up-toast"; +import { applyHiveOps } from "@/lib/hive/ops"; +import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types"; +import { fetchColonyTreatments } from "@/lib/treatments/api"; + +async function hasOpenTreatment(colonyId: string): Promise { + try { + const treatments = await fetchColonyTreatments(colonyId); + return treatments.some((treatment) => treatment.endDate == null); + } catch { + return false; + } +} + +export interface SavedHiveChange { + colonyId: string; + /** Calendar day of the inspection; becomes the date of the suggested forms. */ + occurredOn: string; + ops: HiveOp[]; + /** The setup the ops were collected against. */ + base: HiveState; + catalog: HiveCatalog; + navigate: (href: string) => void; +} + +/** + * Spec section 8: after the inspection is saved, the batch's feeding and + * treatment suggestions appear as toasts. Suggestions only, nothing is booked. + */ +export async function suggestAfterInspection(saved: SavedHiveChange): Promise { + const { ops, base, catalog, colonyId, occurredOn, navigate } = saved; + const applied = applyHiveOps(base, ops, catalog); + // The server was authoritative and saved the inspection: if the ops do not apply to our + // (possibly stale) copy of the setup, we just skip the suggestions instead of guessing. + if (!applied.ok) return; + const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment(colonyId) : false; + showHiveFollowUps(ops, base, applied.state, catalog, { + colonyId, + occurredOn, + navigate, + hasOpenTreatment: open, + }); +} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx new file mode 100644 index 0000000..444413b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/hive-section.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { HiveCard } from "@/components/hive/hive-card"; +import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems"; +import type { Colony } from "@/lib/colonies/types"; +import { fetchHive, hiveKeys } from "@/lib/hive/api"; +import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types"; +import { hiveEntryTexts, undoHiveEntry } from "./hive-change"; + +/** The loaded setup and catalog of a colony's hive; same queries (and cache) as the card. */ +export function useInspectionHive(colonyId: string): { base: HiveState | null; catalog: HiveCatalog | null } { + const enabled = colonyId !== ""; + const hive = useQuery({ + queryKey: hiveKeys.detail(colonyId), + queryFn: () => fetchHive(colonyId), + enabled, + retry: false, + }); + const systems = useHiveSystems(); + const catalog = useSystemCatalog(systems.data, hive.data?.systemId); + return { base: hive.data?.state ?? null, catalog }; +} + +/** Stable per entry: the op's content plus its n-th occurrence, so removing another entry keeps the key. */ +function opKeys(ops: HiveOp[]): string[] { + const seen = new Map(); + return ops.map((op) => { + const json = JSON.stringify(op); + const nth = seen.get(json) ?? 0; + seen.set(json, nth + 1); + return `${json}#${nth}`; + }); +} + +interface HiveOpsListProps { + ops: HiveOp[]; + base: HiveState; + catalog: HiveCatalog; + onChange: (ops: HiveOp[]) => void; + /** Called with the undone entry's text, for the live region. */ + onUndone: (text: string) => void; + /** The list became empty: focus moves to the section. */ + onEmptied: () => void; +} + +function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: HiveOpsListProps) { + const buttons = useRef(new Map()); + const keys = useMemo(() => opKeys(ops), [ops]); + // Index to focus once the shortened list is committed (rows may remount when keys renumber). + const focusIndex = useRef(null); + useEffect(() => { + if (focusIndex.current === null) return; + const target = keys[Math.min(focusIndex.current, keys.length - 1)]; + focusIndex.current = null; + if (target) buttons.current.get(target)?.focus(); + }, [keys]); + const [refusal, setRefusal] = useState<{ ops: HiveOp[]; index: number; reason: string } | null>(null); + const texts = useMemo(() => hiveEntryTexts(ops, base, catalog), [ops, base, catalog]); + // A refusal belongs to the list it was given for; a changed list makes it obsolete. + const blocked = refusal?.ops === ops ? refusal : null; + + function undo(index: number) { + const result = undoHiveEntry(ops, index, base, catalog); + if (result.ok) { + // The neighbouring entry gets focus after the commit (see the effect above). + if (keys.length > 1) focusIndex.current = index; + else onEmptied(); + onUndone(texts[index]); + onChange(result.ops); + } else setRefusal({ ops, index, reason: result.reason }); + } + + return ( +
+

Änderungen an der Beute ({ops.length})

+
    + {texts.map((text, index) => ( +
  • +
    + {text} + +
    + {blocked?.index === index ? ( +

    + + {blocked.reason} Mach zuerst die späteren Änderungen rückgängig. +

    + ) : null} +
  • + ))} +
+
+ ); +} + +export interface InspectionHiveSectionProps { + colony: Colony; + /** colonies:update: edit the hive as a draft; without it the card is read-only. */ + canEdit: boolean; + ops: HiveOp[]; + /** Changes whenever the card must be rebuilt from `ops` (after „Rückgängig“, new colony). */ + cardKey: string; + onOpsChange: (ops: HiveOp[]) => void; + /** Rebuilds the card with these ops (after „Rückgängig“). */ + onOpsReplace: (ops: HiveOp[]) => void; + base: HiveState | null; + catalog: HiveCatalog | null; + /** Server refusal of the hive part (409 HIVE_CONFLICT, 403), shown right here. */ + errorText: string | null; +} + +/** + * Content of the form's „Beute“ section: the card (draft mode only with + * colonies:update), the list of collected changes and a server refusal. + */ +export function InspectionHiveSection({ + colony, + canEdit, + ops, + cardKey, + onOpsChange, + onOpsReplace, + base, + catalog, + errorText, +}: InspectionHiveSectionProps) { + const sectionRef = useRef(null); + const [announcement, setAnnouncement] = useState(""); + return ( + // A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form. +
event.stopPropagation()} + > +

+ {announcement} +

+
+ +
+ {canEdit && ops.length > 0 && base && catalog ? ( + setAnnouncement(`Rückgängig: ${text}`)} + onEmptied={() => sectionRef.current?.focus()} + /> + ) : null} + {errorText ? ( +

+ + {errorText} +

+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts index c050bbc..2e9d389 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HiveOp } from "@/lib/hive/types"; import { currentDraftValues, feedLegacyHint, @@ -209,6 +210,31 @@ describe("Volkskarte selection", () => { }); }); +describe("toCreateInspectionInput with hive ops", () => { + const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }]; + const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" }; + + it("carries hiveChange only when there are ops", () => { + expect(toCreateInspectionInput(values, true, ops).hiveChange).toEqual({ ops }); + expect(toCreateInspectionInput(values, true, [])).not.toHaveProperty("hiveChange"); + expect(toCreateInspectionInput(values, true)).not.toHaveProperty("hiveChange"); + }); + + it("never sends hiveChange without colonies:update", () => { + expect(toCreateInspectionInput(values, false, ops)).not.toHaveProperty("hiveChange"); + }); + + it("keeps applyToColony next to hiveChange", () => { + const input = toCreateInspectionInput( + { ...values, colonyStrength: "6", applyToColony: ["occupiedFrames"] }, + true, + ops, + ); + + expect(input).toMatchObject({ applyToColony: ["occupiedFrames"], hiveChange: { ops } }); + }); +}); + describe("feedStatusOptions", () => { const labels = (stored: Parameters[0]) => feedStatusOptions(stored).map((option) => option.label); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts index 5519612..c18c6cb 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts @@ -7,6 +7,7 @@ import { } from "@/lib/colonies/condition"; import type { ColonyCondition } from "@/lib/colonies/types"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import type { HiveOp } from "@/lib/hive/types"; import { emptyToNull } from "@/lib/forms/empty-to-null"; import { COLONY_TAKEOVER_FIELDS, @@ -238,20 +239,26 @@ export function syncTakeoverSelection( /** * What a new inspection sends: the correction input plus, when the beekeeper - * may update colonies, the checked Volkskarte fields that have a value. - * Omitted when empty, so nothing is asked of the server that was not wanted. + * may update colonies, the checked Volkskarte fields that have a value and the + * hive ops collected in the „Beute“ section. Both are omitted when empty, so + * nothing is asked of the server that was not wanted. */ export function toCreateInspectionInput( values: InspectionFormValues, canUpdateColony: boolean, + hiveOps: HiveOp[] = [], ): CreateInspectionInput { - const input = toInspectionInput(values); + const input: CreateInspectionInput = toInspectionInput(values); if (!canUpdateColony) { return input; } const filled = new Set(takeoverFieldsFilled(values)); const applyToColony = values.applyToColony.filter((field) => filled.has(field)); - return applyToColony.length > 0 ? { ...input, applyToColony } : input; + return { + ...input, + ...(applyToColony.length > 0 ? { applyToColony } : {}), + ...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}), + }; } /** diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx new file mode 100644 index 0000000..bbb7a02 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page-hive.spec.tsx @@ -0,0 +1,371 @@ +import { createPortal } from "react-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { toast } from "sonner"; +import NewInspectionPage from "./page"; +import { ApiError } from "@/lib/auth/api-client"; +import { fetchColonies } from "@/lib/colonies/api"; +import { createInspection, fetchColonyInspections } from "@/lib/inspections/api"; +import { fetchHive, fetchHiveSystems } from "@/lib/hive/api"; +import { fetchApiaryWeather } from "@/lib/weather/api"; +import { showHiveFollowUps } from "@/components/hive/follow-up-toast"; +import { sampleState, setupView, systemDto } from "@/components/hive/test-fixtures"; +import { queueInspectionWithPhotos } from "@/lib/photos/store"; +import { syncPendingEntries } from "@/lib/offline/sync"; + +// Section „Beute“ of the inspection form (Task 19). The card itself is stubbed: +// its buttons report ops the way the real card does in draft mode. + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams("colonyId=colony-1"), +})); +let permissions = new Set(); +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }), +})); +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn(), fetchColonyInspections: vi.fn() })); +vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); +vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); +vi.mock("@/lib/photos/store", () => ({ + PHOTOS_PER_INSPECTION_MAX: 5, + listSessionPhotos: vi.fn().mockResolvedValue([]), + addDraftPhoto: vi.fn(), + removePhoto: vi.fn(), + handOverSessionPhotos: vi.fn().mockResolvedValue(0), + queueInspectionWithPhotos: vi.fn().mockResolvedValue(0), + moveDraftPhotos: vi.fn(), + discardSessionPhotos: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/components/hive/follow-up-toast", () => ({ showHiveFollowUps: vi.fn() })); +vi.mock("@/lib/hive/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchHive: vi.fn(), + fetchHiveSystems: vi.fn(), +})); + +const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] })); +vi.mock("@/components/hive/hive-card", () => ({ + HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => { + card.renders.push({ mode: props.mode, initialOps: props.initialOps }); + const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 }; + const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }; + const add = { + op: "addPart", + part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } }, + at: 5, + }; + const fillNew = { op: "setState", id: "n1", key: "syrupLiters", value: 1 }; + return ( +
+ + + + + {createPortal( +
event.preventDefault()}> + +
, + document.body, + )} +
+ ); + }, +})); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function save() { + await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" })); +} + +function sentInput(): Record { + return vi.mocked(createInspection).mock.calls[0][0] as unknown as Record; +} + +const FILL = { op: "setState", id: "fe", key: "syrupLiters", value: 2 }; +const FRAME = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }; + +describe("NewInspectionPage: section Beute", () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + push.mockReset(); + card.renders.length = 0; + permissions = new Set(["colonies:update"]); + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + ] as never); + vi.mocked(fetchColonyInspections).mockResolvedValue([]); + vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] }); + vi.mocked(fetchHive).mockResolvedValue(setupView(sampleState())); + vi.mocked(fetchHiveSystems).mockResolvedValue([systemDto()]); + vi.mocked(createInspection) + .mockReset() + .mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); + vi.mocked(showHiveFollowUps).mockReset(); + vi.mocked(toast.success).mockReset(); + vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0); + vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 }); + }); + + it("shows the card in draft mode with colonies:update", async () => { + renderPage(); + + const section = await screen.findByTestId("hive-card"); + expect(section).toHaveAttribute("data-mode", "inspection"); + expect(screen.getByRole("group", { name: /Beute/ })).toBeInTheDocument(); + }); + + it("shows the model read-only without colonies:update", async () => { + permissions = new Set(); + renderPage(); + + expect(await screen.findByTestId("hive-card")).toHaveAttribute("data-mode", "view"); + await userEvent.click(screen.getByRole("button", { name: "Test Futter" })); + expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument(); + await save(); + expect(sentInput()).not.toHaveProperty("hiveChange"); + }); + + it("lists the changes and sends them with the inspection", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + + expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /^Rückgängig/ })).toHaveLength(2); + await save(); + + expect(sentInput()).toMatchObject({ hiveChange: { ops: [FRAME, FILL] } }); + }); + + it("sends no hiveChange while there are no ops", async () => { + renderPage(); + await screen.findByTestId("hive-card"); + await save(); + + expect(sentInput()).not.toHaveProperty("hiveChange"); + }); + + it("Rückgängig removes one entry and rebuilds the card from the rest", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]); + + expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument(); + expect(card.renders.at(-1)?.initialOps).toEqual([FILL]); + await save(); + expect(sentInput()).toMatchObject({ hiveChange: { ops: [FILL] } }); + }); + + it("remounts the card seeded with the remaining ops after Rückgängig", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + const rendersBefore = card.renders.length; + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[1]); + + expect(card.renders.length).toBeGreaterThan(rendersBefore); + expect(card.renders.at(-1)?.initialOps).toEqual([FRAME]); + expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument(); + }); + + it("does not save the inspection when a form inside the card submits", async () => { + renderPage(); + await screen.findByTestId("hive-card"); + + await userEvent.click(screen.getByRole("button", { name: "Sheet speichern" })); + + expect(createInspection).not.toHaveBeenCalled(); + }); + + it("moves focus to the neighbouring entry and announces the undo", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]); + + expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus(); + expect(screen.getAllByRole("status").some((el) => /^Rückgängig: /.test(el.textContent ?? ""))).toBe(true); + }); + + it("moves focus to the section when the list becomes empty", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + + await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ })); + + expect(screen.getByTestId("hive-section")).toHaveFocus(); + }); + + it("keeps focus on a row when identical ops renumber their keys", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test zwei gleiche" })); + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]); + + expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus(); + }); + + it("hides a refusal once the ops changed", async () => { + vi.mocked(createInspection).mockRejectedValue( + new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }), + ); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + await save(); + expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht"); + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("hides the hint for 0 brood frames while the field is empty", async () => { + vi.mocked(fetchHive).mockResolvedValue( + setupView({ schemaVersion: 1, stack: [{ id: "r", partTypeId: "roof", state: {} }] } as never), + ); + renderPage(); + await screen.findByTestId("hive-card"); + await waitFor(() => expect(fetchHive).toHaveBeenCalled()); + + expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument(); + }); + + it("drops hiveChange again when the last entry is undone", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + + await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ })); + + expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument(); + await save(); + expect(sentInput()).not.toHaveProperty("hiveChange"); + }); + + it("keeps an entry and says why when the rest would no longer apply", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test neue Zarge" })); + + await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]); + + expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument(); + expect( + screen.getByText(/Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr\./), + ).toBeInTheDocument(); + }); + + it("offers the brood frames of the hive but never fills them by itself", async () => { + renderPage(); + + expect(await screen.findByText(/Laut Beute: 6 Brutwaben/)).toBeInTheDocument(); + await save(); + expect(sentInput()).toMatchObject({ broodFrames: null }); + }); + + it("takes the hive's brood frames over only after Übernehmen", async () => { + renderPage(); + const hint = (await screen.findByText(/Laut Beute: 6 Brutwaben/)).closest("p") as HTMLElement; + + await userEvent.click(within(hint).getByRole("button", { name: /übernehmen/ })); + + await waitFor(() => expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument()); + await save(); + expect(sentInput()).toMatchObject({ broodFrames: 6 }); + }); + + it("follows the ops: a brood frame in the draft raises the hint", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" })); + + expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument(); + }); + + it("suggests feeding and treatment for the saved batch", async () => { + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + await save(); + + await waitFor(() => expect(showHiveFollowUps).toHaveBeenCalledTimes(1)); + const [ops, before, after, , context] = vi.mocked(showHiveFollowUps).mock.calls[0]; + expect(ops).toEqual([FILL]); + expect(before).toEqual(sampleState()); + expect(after).not.toEqual(before); + expect(context).toMatchObject({ colonyId: "colony-1", hasOpenTreatment: false }); + }); + + it("suggests nothing when no hive change was sent", async () => { + renderPage(); + await screen.findByTestId("hive-card"); + await save(); + + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + expect(showHiveFollowUps).not.toHaveBeenCalled(); + }); + + it("passes hiveChange untouched into the offline queue", async () => { + vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch")); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + await save(); + + await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled()); + expect(vi.mocked(queueInspectionWithPhotos).mock.calls[0][1]).toMatchObject({ + hiveChange: { ops: [FILL] }, + }); + expect(showHiveFollowUps).not.toHaveBeenCalled(); + }); + + it("explains a 409 HIVE_CONFLICT inline and keeps the ops", async () => { + vi.mocked(createInspection).mockRejectedValue( + new ApiError(409, "Conflict", { + code: "HIVE_CONFLICT", + opIndex: 0, + reason: "MISSING_ID: no part", + current: { version: 5, state: sampleState() }, + }), + ); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + await save(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Die Beute hat sich inzwischen geändert."); + expect(alert).toHaveTextContent("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr."); + expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument(); + expect(screen.getAllByRole("alert")).toHaveLength(1); + }); + + it("explains a 403 HIVE_FORBIDDEN inline", async () => { + vi.mocked(createInspection).mockRejectedValue( + new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }), + ); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: "Test Futter" })); + await save(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht, die Beute zu ändern."); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx index 9cb4c4b..a71c763 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx @@ -48,6 +48,13 @@ vi.mock("@/lib/inspections/api", () => ({ })); vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); +// The Beute card and its data have their own specs (page-hive.spec.tsx); here the section stays inert. +vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null })); +vi.mock("@/lib/hive/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchHive: vi.fn().mockRejectedValue(new Error("no hive")), + fetchHiveSystems: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); vi.mock("@/lib/photos/store", () => ({ PHOTOS_PER_INSPECTION_MAX: 5, @@ -1110,4 +1117,76 @@ describe("NewInspectionPage", () => { expect(discardSessionPhotos).not.toHaveBeenCalled(); }); }); + describe("dictation", () => { + class FakeRecognition { + static instances: FakeRecognition[] = []; + static available = vi.fn(async () => "available"); + lang = ""; + continuous = false; + interimResults = false; + maxAlternatives = 1; + handlers: Record void> = {}; + start = vi.fn(); + stop = vi.fn(); + abort = vi.fn(); + constructor() { + FakeRecognition.instances.push(this); + } + set onresult(fn: (event: unknown) => void) { + this.handlers.result = fn; + } + set onend(fn: (event: unknown) => void) { + this.handlers.end = fn; + } + set onerror(fn: (event: unknown) => void) { + this.handlers.error = fn; + } + set onaudiostart(fn: (event: unknown) => void) { + this.handlers.audiostart = fn; + } + } + + beforeEach(() => { + FakeRecognition.instances = []; + (window as unknown as Record).SpeechRecognition = FakeRecognition; + }); + afterEach(() => { + delete (window as unknown as Record).SpeechRecognition; + }); + + async function openMoreFields() { + await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ })); + } + + it("puts a dictation button next to notes, health, varroa and measures", async () => { + renderPage(); + await openMoreFields(); + + for (const field of ["Notizen", "Gesundheitsauffälligkeiten", "Varroa-Befund", "Maßnahmen"]) { + expect( + await screen.findByRole("button", { name: `Diktieren: ${field}` }), + ).toBeInTheDocument(); + } + }); + + it("appends the dictated text to a typed note and saves it", async () => { + vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); + renderPage(); + await openMoreFields(); + await userEvent.type(screen.getByRole("textbox", { name: /^Notizen/ }), "Alt"); + + await userEvent.click(await screen.findByRole("button", { name: "Diktieren: Notizen" })); + const engine = FakeRecognition.instances[0]; + act(() => + engine.handlers.result({ + resultIndex: 0, + results: [Object.assign([{ transcript: "neu" }], { isFinal: true })], + }), + ); + + expect(screen.getByRole("textbox", { name: /^Notizen/ })).toHaveValue("Alt neu"); + await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" })); + expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ notes: "Alt neu" }); + }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 355515e..1fd76e4 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -20,6 +20,8 @@ import { } from "@/lib/inspections/takeover"; import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection"; import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types"; +import { hiveKeys } from "@/lib/hive/api"; +import type { HiveOp } from "@/lib/hive/types"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { photosNotTakenOver } from "@/lib/offline/queue-texts"; @@ -40,6 +42,8 @@ import type { StandRound } from "@/lib/rounds/round-state"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { DictationRow } from "@/components/voice/dictation-button"; +import { appendDictation } from "@/lib/voice/append-text"; import { FormBackLink } from "@/components/form/form-back-link"; import { RoundNextPanel } from "@/components/rounds/round-next-panel"; import { PageHeader } from "@/components/page-header"; @@ -66,6 +70,12 @@ import { syncTakeoverSelection, toCreateInspectionInput, } from "./inspection-form-values"; +import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change"; +import { suggestAfterInspection } from "./hive-save-follow-ups"; +import { InspectionHiveSection, useInspectionHive } from "./hive-section"; + +/** Free-text fields that take dictation. */ +type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures"; const UNDO_TOAST_MS = 8000; const WEATHER_UPDATED = "Wetter aktualisiert"; @@ -218,6 +228,13 @@ function InspectionCaptureForm({ const colonyCard = colonies?.find((colony) => colony.id === values.colonyId); const stand = useStandRound(roundApiaryId, values.colonyId); const [roundSaved, setRoundSaved] = useState(null); + // Hive ops collected in the „Beute“ section; sent with the inspection, never drafted. + const [hiveOps, setHiveOps] = useState([]); + const [hiveCardGeneration, setHiveCardGeneration] = useState(0); + // The ops of the last submit: a refusal only speaks for exactly those. + const [submittedHiveOps, setSubmittedHiveOps] = useState(""); + const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId); + const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog); /** * Every change of the answers also keeps the Volkskarte selection in step. @@ -255,10 +272,33 @@ function InspectionCaptureForm({ { touched: TOUCHED_TAKEOVER_FIELD[key] }, ); setDirty(true); - if (key === "colonyId") setColonyError(null); + if (key === "colonyId") { + // The ops belong to the other colony's hive. + replaceHiveOps([]); + setColonyError(null); + } if (key === "date") setDateError(null); } + /** Ops reported by the card as the beekeeper edits. */ + function collectHiveOps(ops: HiveOp[]) { + setHiveOps(ops); + setDirty(true); + } + + /** Ops changed from outside the card („Rückgängig“, new colony): the card is rebuilt from them. */ + function replaceHiveOps(ops: HiveOp[]) { + setHiveOps(ops); + setHiveCardGeneration((generation) => generation + 1); + setDirty(true); + } + + /** Dictation is added to the end of a free-text field and marks the form as changed. */ + function appendDictated(key: DictatedField, text: string) { + update((current) => ({ ...current, [key]: appendDictation(current[key], text) })); + setDirty(true); + } + /** How many photos the form's photo section shows (none while it is hidden). */ function shownPhotoCount(photoSessionId: string): number { return ( @@ -336,6 +376,19 @@ function InspectionCaptureForm({ queryKey: ["colonies", outcome.result.colonyId, "inspections"], }); toast.success("Durchsicht gespeichert"); + if (input.hiveChange) { + void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) }); + if (hiveBase && hiveCatalog) { + void suggestAfterInspection({ + colonyId: input.colonyId, + occurredOn: toCalendarDay(input.date), + ops: input.hiveChange.ops, + base: hiveBase, + catalog: hiveCatalog, + navigate: (href) => router.push(href), + }); + } + } const takeover = outcome.result.colonyTakeover; if (takeover?.applied.length) { // The colony card changed: its page and lists must not show the old values. @@ -367,7 +420,8 @@ function InspectionCaptureForm({ return; } submittedPhotoSession.current = values.photoSessionId; - capture.submit(toCreateInspectionInput(values, canUpdateColony)); + setSubmittedHiveOps(JSON.stringify(hiveOps)); + capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps)); } /** @@ -496,6 +550,10 @@ function InspectionCaptureForm({ // Same rule as the backend automation: only an explicit "Nein" counts. const noQueenSigns = values.queenSeen === false && values.eggsPresent === false; + const hiveError = + hiveOps.length > 0 && JSON.stringify(hiveOps) === submittedHiveOps + ? hiveErrorText(capture.error) + : null; return (
@@ -609,12 +667,46 @@ function InspectionCaptureForm({ describedBy={describedBy} /> {hintFor("broodFrames")} + {broodFromHive !== null && + values.broodFrames !== String(broodFromHive) && + !(broodFromHive === 0 && values.broodFrames === "") ? ( + set("broodFrames", String(broodFromHive))} + /> + ) : null} )} - + {colonyCard ? ( + + + + ) : null} + + {({ id, describedBy }) => ( <> @@ -680,7 +772,7 @@ function InspectionCaptureForm({ - + {({ id, labelId }) => ( <> @@ -705,7 +797,7 @@ function InspectionCaptureForm({ @@ -743,7 +835,7 @@ function InspectionCaptureForm({ {({ id }) => ( - - set("varroaIssues", event.target.value) - } - /> + appendDictated("varroaIssues", text)} + > + + set("varroaIssues", event.target.value) + } + /> + )} {({ id }) => ( - - set("healthIssues", event.target.value) - } - /> + appendDictated("healthIssues", text)} + > + + set("healthIssues", event.target.value) + } + /> + )} {({ id }) => ( -