feat: hive setup data model and API (Beute L1)
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s

Each colony can now hold its hive setup (Ist-Zustand) as validated
JSON with an append-only change log:

- catalog: hive systems and part types with dimensions, placement
  rules and state fields; Liebig-Zander 10 seeded with fixed ids
  (owner's equipment incl. Bausperre, Futterzarge, Nassenheider)
- pure domain module (operations, validator, derived facts) with
  shared JSON fixtures the frontend runs too
- API: catalog (hive-systems:manage), setup create/change/history/
  state at a day (colonies:read/update), 409 HIVE_CONFLICT with the
  current state, idempotent writes, HiveChanged event
- inspections can carry hive ops saved in the same transaction
- backup export/import of the new tables (backup wins per colony)
- docs: ARCHITECTURE section 13, Annahmen 95-100

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 21:23:23 +02:00
1 parent 8982d0796a
commit b48fb157c8
141 files changed
+15211 -17

No files matched your search

+2
View File
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { HiveModule } from './hive/hive.module';
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module';
import { ReadinessModule } from './compliance/readiness/readiness.module';
@@ -136,6 +137,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ApiKeysModule,
SystemModule,
VeterinaryMedicinesModule,
HiveModule,
ComplianceModule,
ReadinessModule,
],
@@ -412,6 +412,9 @@ export const PERMISSION_CATALOG = [
],
3800,
),
// Beute in 3D: hive systems and their part-type catalog. Reading the
// catalog and writing a colony's setup use the colonies permissions.
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -2,6 +2,7 @@ import { BadRequestException, Logger } from '@nestjs/common';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import {
BackupBundle,
BackupService,
LEGACY_UNSEALED_TABLES,
MODELS_EXCLUDED_FROM_BACKUP,
@@ -113,7 +114,15 @@ describe('BackupService', () => {
document: { findMany: jest.Mock; count: jest.Mock };
$transaction: jest.Mock;
};
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
let tx: Record<
string,
{
upsert: jest.Mock;
update?: jest.Mock;
create?: jest.Mock;
deleteMany?: jest.Mock;
}
> & {
permission: { findUnique: jest.Mock };
user: { upsert: jest.Mock; findMany: jest.Mock };
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
@@ -139,6 +148,10 @@ describe('BackupService', () => {
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
for (const key of ['hiveChange', 'hiveSetup']) {
tx[key].create = jest.fn().mockResolvedValue({});
tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 });
}
tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1948,6 +1961,80 @@ describe('BackupService', () => {
});
});
describe('importAll hive models', () => {
const hiveBundle = (): BackupBundle => ({
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
hiveSystem: [{ id: 'hive-system-1', name: 'System' }],
hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }],
colony: [{ id: 'colony-1' }, { id: 'colony-2' }],
hiveSetup: [
{ colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 },
{ colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 },
],
hiveChange: [
{ id: 'change-1', colonyId: 'colony-1', userId: 'known-user' },
{ id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' },
{ id: 'change-3', colonyId: 'colony-other', userId: 'known-user' },
],
},
});
it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => {
await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
expect(tx.hiveSystem.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'hive-system-1' } }),
);
expect(tx.hivePartType.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'hive-part-1' } }),
);
});
it('replaces setup and history of each restored colony, remapping the history user', async () => {
const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED);
expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({
where: { colonyId: { in: ['colony-1'] } },
});
expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({
where: { colonyId: { in: ['colony-1'] } },
});
expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1);
const created = tx.hiveChange.create!.mock.calls.map(
([arg]: [{ data: { id: string; userId: string } }]) => arg.data,
);
expect(created).toEqual([
expect.objectContaining({ id: 'change-1', userId: 'known-user' }),
expect.objectContaining({ id: 'change-2', userId: 'admin-1' }),
]);
expect(summary.imported).toMatchObject({
hiveSystem: 1,
hivePartType: 1,
hiveSetup: 2,
hiveChange: 3,
});
});
it('takes no hive table from an unsealed file and reports them', async () => {
const summary = await service.importAll(hiveBundle(), 'admin-1', {
trusted: false,
});
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
expect(tx.hiveSystem.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'hiveSystem',
'hivePartType',
'hiveSetup',
'hiveChange',
]),
);
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
@@ -329,9 +329,13 @@ export class BackupService {
data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
data.colonyLineage = await this.prisma.colonyLineage.findMany();
data.hiveSystem = await this.prisma.hiveSystem.findMany();
data.hivePartType = await this.prisma.hivePartType.findMany();
data.hiveSetup = await this.prisma.hiveSetup.findMany();
data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany();
data.hiveChange = await this.prisma.hiveChange.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany();
@@ -608,6 +612,26 @@ export class BackupService {
}
imported.queen = rowsOf('queen').length;
// Catalog first: a fresh install already carries the seeded rows with
// the same fixed ids, so upsert by id neither fails nor duplicates.
for (const row of rowsOf('hiveSystem')) {
await tx.hiveSystem.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.hiveSystem = rowsOf('hiveSystem').length;
for (const row of rowsOf('hivePartType')) {
await tx.hivePartType.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.hivePartType = rowsOf('hivePartType').length;
// Restore deliberately emits no ColonyCreated (no domain events).
for (const row of rowsOf('colony')) {
await tx.colony.upsert({
@@ -618,6 +642,33 @@ export class BackupService {
}
imported.colony = rowsOf('colony').length;
// A restore replaces, never merges: the backup's setup wins, so the
// existing setup and history of each restored colony go first (the
// history rows reference the setup). Their backup history follows
// after the inspections.
const hiveSetupRows = rowsOf('hiveSetup');
const restoredColonyIds = new Set(
rowsOf('colony').map((row) => row.id as string),
);
const setupsToReplace = hiveSetupRows.filter((row) =>
restoredColonyIds.has(row.colonyId as string),
);
const replacedColonyIds = setupsToReplace.map(
(row) => row.colonyId as string,
);
if (replacedColonyIds.length > 0) {
await tx.hiveChange.deleteMany({
where: { colonyId: { in: replacedColonyIds } },
});
await tx.hiveSetup.deleteMany({
where: { colonyId: { in: replacedColonyIds } },
});
}
for (const row of setupsToReplace) {
await tx.hiveSetup.create({ data: row as never });
}
imported.hiveSetup = hiveSetupRows.length;
for (const row of rowsOf('queen')) {
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
await tx.queen.update({
@@ -692,6 +743,21 @@ export class BackupService {
}
imported.inspection = rowsOf('inspection').length;
const replacedColonies = new Set(replacedColonyIds);
const hiveChangeRows = rowsOf('hiveChange');
for (const row of hiveChangeRows) {
if (!replacedColonies.has(row.colonyId as string)) {
continue;
}
await tx.hiveChange.create({
data: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.hiveChange = hiveChangeRows.length;
for (const row of rowsOf('taskTemplate')) {
await tx.taskTemplate.upsert({
where: { id: row.id as string },
@@ -1,6 +1,7 @@
import {
ColonyCondition,
ColonyStatus,
HiveChangeSource,
PhaseSource,
PhenologyPhase,
Prisma,
@@ -144,6 +145,15 @@ export interface DomainEventPayloads {
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
// webhooks.
TskReferenceDayReached: { year: number; referenceDay: string };
// Beute in 3D, aggregate Colony: the hive setup of a colony changed (INIT
// or a change). Ids and the source only, no content. Not forwarded to
// webhooks.
HiveChanged: {
colonyId: string;
version: number;
source: HiveChangeSource;
inspectionId: string | null;
};
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -188,6 +198,7 @@ const CATALOG: Record<DomainEventType, true> = {
ColonyMoved: true,
ApiaryChanged: true,
TskReferenceDayReached: true,
HiveChanged: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
expect(DOMAIN_EVENT_TYPES).toHaveLength(39);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
@@ -206,6 +206,7 @@ describe('domain event catalog', () => {
'ColonyMoved',
'ApiaryChanged',
'TskReferenceDayReached',
'HiveChanged',
]),
);
});
@@ -0,0 +1,82 @@
import { ArgumentMetadata, ValidationPipe } from '@nestjs/common';
import { CreateHiveChangeDto } from './create-hive-change.dto';
const pipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
const metadata: ArgumentMetadata = {
type: 'body',
metatype: CreateHiveChangeDto,
};
const body = (overrides: Record<string, unknown> = {}) => ({
occurredOn: '2026-10-15',
source: 'CARD',
ops: [{ op: 'setLabel', id: 'p1', label: 'Brutraum' }],
...overrides,
});
const accepted = (value: unknown) => pipe.transform(value, metadata);
const rejected = async (value: unknown) => {
await expect(accepted(value)).rejects.toMatchObject({ status: 400 });
};
describe('CreateHiveChangeDto', () => {
it('accepts every op shape', async () => {
const ops = [
{ op: 'addPart', part: { id: 'a', partTypeId: 't', state: {} }, at: 1 },
{ op: 'addInsert', hostId: 'h', part: { id: 'a' } },
{ op: 'removePart', id: 'a' },
{ op: 'movePart', id: 'a', to: 0 },
{ op: 'setState', id: 'a', key: 'k', value: 0.5 },
{ op: 'setState', id: 'a', key: 'k', value: 'OPEN' },
{ op: 'setFrame', boxId: 'b', slot: 1, type: 'HONEY', name: null },
{ op: 'setFrames', boxId: 'b', frames: [{ slot: 1, type: 'BROOD' }] },
{ op: 'setLabel', id: 'a', label: null },
];
await expect(accepted(body({ ops }))).resolves.toBeDefined();
});
it('refuses AI_SUGGESTION, INIT and INSPECTION as source', async () => {
for (const source of ['AI_SUGGESTION', 'INIT', 'INSPECTION']) {
await rejected(body({ source }));
}
});
it('refuses an unknown op name', async () => {
await rejected(body({ ops: [{ op: 'explode' }] }));
});
it('refuses wrong primitive types', async () => {
await rejected(body({ ops: [{ op: 'removePart', id: 5 }] }));
await rejected(body({ ops: [{ op: 'movePart', id: 'a', to: '1' }] }));
await rejected(
body({ ops: [{ op: 'setState', id: 'a', key: 'k', value: true }] }),
);
await rejected(body({ ops: [{ op: 'addPart', part: 'x', at: 0 }] }));
});
it('refuses a missing required field of an op', async () => {
await rejected(body({ ops: [{ op: 'removePart' }] }));
});
it('refuses an empty ops list', async () => {
await rejected(body({ ops: [] }));
});
it('refuses more than 100 ops and a non-array', async () => {
const op = { op: 'setLabel', id: 'p1', label: 'x' };
await expect(
accepted(body({ ops: Array.from({ length: 100 }, () => op) })),
).resolves.toBeDefined();
await rejected(body({ ops: Array.from({ length: 101 }, () => op) }));
await rejected(body({ ops: 'nope' }));
});
it('refuses a malformed day and unknown properties', async () => {
await rejected(body({ occurredOn: '15.10.2026' }));
await rejected(body({ extra: 1 }));
});
});
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { DAY_PATTERN } from './create-hive-setup.dto';
import { HiveOpDto, MAX_OPS_PER_REQUEST } from './hive-op.dto';
// INIT and INSPECTION are set by the server; AI_SUGGESTION is not accepted yet.
export const CLIENT_CHANGE_SOURCES = ['CARD', 'EDITOR'] as const;
const MAX_NOTE_LENGTH = 500;
export class CreateHiveChangeDto {
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
@Matches(DAY_PATTERN)
occurredOn!: string;
@ApiProperty({ enum: CLIENT_CHANGE_SOURCES })
@IsIn(CLIENT_CHANGE_SOURCES)
source!: (typeof CLIENT_CHANGE_SOURCES)[number];
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
@ValidateNested({ each: true })
@Type(() => HiveOpDto)
ops!: HiveOpDto[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(MAX_NOTE_LENGTH)
note?: string;
}
@@ -0,0 +1,89 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export const HIVE_PART_KINDS = [
'FLOOR',
'BOX',
'DIVIDER_BOARD',
'QUEEN_EXCLUDER',
'FEEDER',
'COVER_SHEET',
'PROPOLIS_SCREEN',
'INNER_LID',
'INSULATION',
'ROOF',
'FLOOR_INSERT',
'EVAPORATOR',
] as const;
export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const;
const MAX_MM = 5000;
const MAX_SCHEMA_FIELDS = 30;
const MAX_FRAME_SLOTS = 20;
export class CreateHivePartTypeDto {
@ApiProperty({ enum: HIVE_PART_KINDS })
@IsIn(HIVE_PART_KINDS)
kind!: (typeof HIVE_PART_KINDS)[number];
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(80)
articleRef?: string;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
lengthMm!: number;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
widthMm!: number;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
heightMm!: number;
@ApiPropertyOptional({ enum: HIVE_BOX_DEPTHS })
@IsOptional()
@IsIn(HIVE_BOX_DEPTHS)
boxDepth?: (typeof HIVE_BOX_DEPTHS)[number];
@ApiPropertyOptional({ description: 'Required for kind BOX' })
@IsOptional()
@IsInt()
@Min(0)
@Max(MAX_FRAME_SLOTS)
frameSlots?: number;
@ApiPropertyOptional({ description: 'State fields, spec 4.2' })
@IsOptional()
@IsArray()
@ArrayMaxSize(MAX_SCHEMA_FIELDS)
@IsObject({ each: true })
stateSchema?: Record<string, unknown>[];
}
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsObject, IsString, Matches, MaxLength } from 'class-validator';
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export class CreateHiveSetupDto {
@ApiProperty()
@IsString()
@MaxLength(100)
systemId!: string;
@ApiProperty({
description: 'Complete hive state (schemaVersion 1); validated server side',
})
@IsObject()
state!: Record<string, unknown>;
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
@Matches(DAY_PATTERN)
occurredOn!: string;
}
@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const MAX_MM = 5000;
export class CreateHiveSystemDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
@ApiProperty({ description: 'Top bar width in mm' })
@IsInt()
@Min(1)
@Max(MAX_MM)
frameWidthMm!: number;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
frameHeightMm!: number;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
footprintLMm!: number;
@ApiProperty()
@IsInt()
@Min(1)
@Max(MAX_MM)
footprintWMm!: number;
}
+144
View File
@@ -0,0 +1,144 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateBy,
ValidateIf,
buildMessage,
} from 'class-validator';
import { FRAME_TYPES, MAX_OPS } from '../hive-types';
export const HIVE_OP_NAMES = [
'addPart',
'addInsert',
'removePart',
'movePart',
'setState',
'setFrame',
'setFrames',
'setLabel',
] as const;
export const MAX_OPS_PER_REQUEST = MAX_OPS;
const MAX_ID_LENGTH = 100;
const MAX_TEXT = 200;
const MAX_FRAMES = 100;
type OpName = (typeof HIVE_OP_NAMES)[number];
/** The property applies only to the listed operations. */
const onlyFor =
(...names: OpName[]) =>
(dto: HiveOpDto) =>
names.includes(dto.op);
function IsStringOrFiniteNumber() {
return ValidateBy(
{
name: 'isStringOrFiniteNumber',
validator: {
validate: (value: unknown) =>
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value)),
defaultMessage: buildMessage(
(each) => `${each}$property must be a string or a finite number`,
),
},
},
{},
);
}
/**
* One hive operation. Only the shape is checked here (discriminator and
* primitive types); the deep check is `applyHiveOps` (spec 6.2).
*/
export class HiveOpDto {
@ApiProperty({ enum: HIVE_OP_NAMES })
@IsIn(HIVE_OP_NAMES)
op!: OpName;
@ApiPropertyOptional({ description: 'addPart, addInsert: the part' })
@ValidateIf(onlyFor('addPart', 'addInsert'))
@IsObject()
part?: Record<string, unknown>;
@ApiPropertyOptional({ description: 'addPart: stack index' })
@ValidateIf(onlyFor('addPart'))
@IsInt()
at?: number;
@ApiPropertyOptional({ description: 'addInsert: id of the host part' })
@ValidateIf(onlyFor('addInsert'))
@IsString()
@MaxLength(MAX_ID_LENGTH)
hostId?: string;
@ApiPropertyOptional({
description: 'removePart, movePart, setState, setLabel: part id',
})
@ValidateIf(onlyFor('removePart', 'movePart', 'setState', 'setLabel'))
@IsString()
@MaxLength(MAX_ID_LENGTH)
id?: string;
@ApiPropertyOptional({ description: 'movePart: target stack index' })
@ValidateIf(onlyFor('movePart'))
@IsInt()
to?: number;
@ApiPropertyOptional({ description: 'setState: state field key' })
@ValidateIf(onlyFor('setState'))
@IsString()
@MaxLength(MAX_ID_LENGTH)
key?: string;
@ApiPropertyOptional({ description: 'setState: number or string' })
@ValidateIf(onlyFor('setState'))
@IsStringOrFiniteNumber()
value?: number | string;
@ApiPropertyOptional({ description: 'setFrame, setFrames: box id' })
@ValidateIf(onlyFor('setFrame', 'setFrames'))
@IsString()
@MaxLength(MAX_ID_LENGTH)
boxId?: string;
@ApiPropertyOptional({ description: 'setFrame: slot number' })
@ValidateIf(onlyFor('setFrame'))
@IsInt()
slot?: number;
@ApiPropertyOptional({ description: 'setFrame: frame type' })
@ValidateIf(onlyFor('setFrame'))
@IsIn(FRAME_TYPES)
type?: string;
@ApiPropertyOptional({ description: 'setFrame: optional frame name' })
@ValidateIf(onlyFor('setFrame'))
@IsOptional()
@IsString()
@MaxLength(MAX_TEXT)
name?: string | null;
@ApiPropertyOptional({ description: 'setFrames: all frames of the box' })
@ValidateIf(onlyFor('setFrames'))
@IsArray()
@ArrayMaxSize(MAX_FRAMES)
@IsObject({ each: true })
frames?: Record<string, unknown>[];
@ApiPropertyOptional({ description: 'setLabel: new label or null' })
@ValidateIf(onlyFor('setLabel'))
@IsOptional()
@IsString()
@MaxLength(MAX_TEXT)
label?: string | null;
}
@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { DAY_PATTERN } from './create-hive-setup.dto';
export const MAX_HISTORY_LIMIT = 100;
export class HiveHistoryQueryDto {
@ApiPropertyOptional({ description: 'Opaque cursor of the previous page' })
@IsOptional()
@IsString()
@MaxLength(200)
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: MAX_HISTORY_LIMIT })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_HISTORY_LIMIT)
limit?: number;
}
export class HiveAtQueryDto {
@ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' })
@Matches(DAY_PATTERN)
day!: string;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { CreateHivePartTypeDto } from './create-hive-part-type.dto';
// `kind` cannot change after creation, so it is not part of the update DTO.
export class UpdateHivePartTypeDto extends PartialType(
OmitType(CreateHivePartTypeDto, ['kind'] as const),
) {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
active?: boolean;
}
@@ -0,0 +1,15 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsObject, IsOptional } from 'class-validator';
import { CreateHiveSystemDto } from './create-hive-system.dto';
export class UpdateHiveSystemDto extends PartialType(CreateHiveSystemDto) {
@ApiPropertyOptional({ description: 'Hive state JSON without ids (5.2)' })
@IsOptional()
@IsObject()
template?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
active?: boolean;
}
@@ -0,0 +1,35 @@
import type { HiveOp } from './hive-types';
/**
* A copy of the op with only the fields its name defines, so stray client
* fields never reach the state logic or the stored history.
*/
export function canonicalOp(op: HiveOp): HiveOp {
switch (op.op) {
case 'addPart':
return { op: op.op, part: op.part, at: op.at };
case 'addInsert':
return { op: op.op, hostId: op.hostId, part: op.part };
case 'removePart':
return { op: op.op, id: op.id };
case 'movePart':
return { op: op.op, id: op.id, to: op.to };
case 'setState':
return { op: op.op, id: op.id, key: op.key, value: op.value };
case 'setFrame':
return {
op: op.op,
boxId: op.boxId,
slot: op.slot,
type: op.type,
...(op.name !== undefined ? { name: op.name } : {}),
};
case 'setFrames':
return { op: op.op, boxId: op.boxId, frames: op.frames };
case 'setLabel':
return { op: op.op, id: op.id, label: op.label };
default:
// Unknown names stay as they are; applyHiveOps reports UNKNOWN_OP.
return { ...(op as object) } as HiveOp;
}
}
@@ -0,0 +1,68 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
import { HiveCatalogService } from './hive-catalog.service';
@ApiTags('hive-catalog')
@ApiBearerAuth()
@Controller()
export class HiveCatalogController {
constructor(private readonly catalog: HiveCatalogService) {}
@RequirePermission('colonies:read')
@Get('hive-systems')
listSystems() {
return this.catalog.listSystems();
}
@RequirePermission('colonies:read')
@Get('hive-systems/:id/template')
template(@Param('id') id: string) {
return this.catalog.template(id);
}
@RequirePermission('hive-systems:manage')
@Post('hive-systems')
createSystem(
@Body() dto: CreateHiveSystemDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.catalog.createSystem(dto, actor.id);
}
@RequirePermission('hive-systems:manage')
@Patch('hive-systems/:id')
updateSystem(
@Param('id') id: string,
@Body() dto: UpdateHiveSystemDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.catalog.updateSystem(id, dto, actor.id);
}
@RequirePermission('hive-systems:manage')
@Post('hive-systems/:id/part-types')
createPartType(
@Param('id') id: string,
@Body() dto: CreateHivePartTypeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.catalog.createPartType(id, dto, actor.id);
}
@RequirePermission('hive-systems:manage')
@Patch('hive-part-types/:id')
updatePartType(
@Param('id') id: string,
@Body() dto: UpdateHivePartTypeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.catalog.updatePartType(id, dto, actor.id);
}
}
@@ -0,0 +1,458 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { HiveCatalogService } from './hive-catalog.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { validateHiveState } from './hive-validate';
import type { HiveState } from './hive-types';
import { loadFixtureCatalog } from './hive-test-utils';
const SYSTEM_ID = 'hive-system-zander-liebig-10';
const USER = 'user-1';
const fixtureRows = [...loadFixtureCatalog().partTypes.values()].map((t) => ({
...t,
systemId: SYSTEM_ID,
}));
const FLOOR = 'hive-part-zl10-floor';
const BOX = 'hive-part-zl10-box-10';
const ROOF = 'hive-part-zl10-roof';
const EVAPORATOR = 'hive-part-zl10-evaporator';
const frames = Array.from({ length: 10 }, (_, i) => ({
slot: i + 1,
type: 'FOUNDATION',
}));
// Stored templates carry no ids (spec 4.4); one insert covers the insert path.
const storedTemplate = {
schemaVersion: 1,
stack: [
{ partTypeId: FLOOR, state: {} },
{
partTypeId: BOX,
state: {},
frames,
inserts: [{ partTypeId: EVAPORATOR, state: {} }],
},
{ partTypeId: ROOF, state: {} },
],
};
const levelField = {
key: 'syrupLiters',
label: 'Fluessigfutter',
kind: 'level',
unit: 'l',
max: 3,
step: 0.5,
};
const choiceField = {
key: 'entrance',
label: 'Flugloch',
kind: 'choice',
options: ['OPEN', 'CLOSED'],
optionLabels: ['offen', 'zu'],
};
describe('HiveCatalogService', () => {
let service: HiveCatalogService;
let tx: {
$queryRaw: jest.Mock;
hiveSystem: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
hivePartType: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let prisma: { $transaction: jest.Mock } & typeof tx;
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'x' }]),
hiveSystem: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue({
id: SYSTEM_ID,
name: 'Zander',
template: storedTemplate,
active: true,
}),
create: jest.fn(),
update: jest.fn(),
},
hivePartType: {
findMany: jest.fn().mockResolvedValue(fixtureRows),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
prisma = {
...tx,
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new HiveCatalogService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('listSystems', () => {
it('returns systems with all part types ordered by name', async () => {
await service.listSystems();
expect(prisma.hiveSystem.findMany).toHaveBeenCalledWith({
include: { partTypes: { orderBy: { name: 'asc' } } },
orderBy: { name: 'asc' },
});
});
});
describe('loadCatalog', () => {
it('maps rows including inactive part types', async () => {
tx.hivePartType.findMany.mockResolvedValue([
{ ...fixtureRows[0], active: false },
]);
const catalog = await service.loadCatalog(SYSTEM_ID);
expect(catalog.systemId).toBe(SYSTEM_ID);
expect(catalog.partTypes.get(fixtureRows[0].id)?.active).toBe(false);
});
it('uses the given transaction client', async () => {
const other = {
hiveSystem: {
findUnique: jest.fn().mockResolvedValue({ id: SYSTEM_ID }),
},
hivePartType: { findMany: jest.fn().mockResolvedValue([]) },
};
await service.loadCatalog(
SYSTEM_ID,
other as unknown as Prisma.TransactionClient,
);
expect(other.hivePartType.findMany).toHaveBeenCalled();
expect(tx.hivePartType.findMany).not.toHaveBeenCalled();
});
it('rejects a part type row with a malformed stateSchema', async () => {
tx.hivePartType.findMany.mockResolvedValue([
{ ...fixtureRows[0], stateSchema: { not: 'a list' } },
]);
await expect(service.loadCatalog(SYSTEM_ID)).rejects.toThrow();
});
it('throws NotFound for an unknown system', async () => {
tx.hiveSystem.findUnique.mockResolvedValue(null);
await expect(service.loadCatalog('nope')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('template', () => {
const ids = (state: HiveState): string[] =>
state.stack.flatMap((p) => [p.id, ...(p.inserts ?? []).map((i) => i.id)]);
it('gives fresh ids on every call and is a valid state', async () => {
const first = await service.template(SYSTEM_ID);
const second = await service.template(SYSTEM_ID);
const firstIds = ids(first);
expect(firstIds).toHaveLength(4);
expect(new Set(firstIds).size).toBe(4);
expect(ids(second).some((id) => firstIds.includes(id))).toBe(false);
expect(validateHiveState(first, loadFixtureCatalog())).toEqual([]);
});
it('throws 409 TEMPLATE_NOT_SET for an empty stored stack', async () => {
tx.hiveSystem.findUnique.mockResolvedValue({
id: SYSTEM_ID,
template: { schemaVersion: 1, stack: [] },
});
await expect(service.template(SYSTEM_ID)).rejects.toMatchObject({
status: 409,
response: expect.objectContaining({
code: 'TEMPLATE_NOT_SET',
}) as unknown,
});
});
it('does not mutate the stored template', async () => {
await service.template(SYSTEM_ID);
expect(storedTemplate.stack[0]).not.toHaveProperty('id');
});
});
describe('updateSystem', () => {
it('rejects a template that fails validation with the messages', async () => {
const bad = { schemaVersion: 1, stack: [{ partTypeId: BOX, state: {} }] };
const error = await service
.updateSystem(SYSTEM_ID, { template: bad }, USER)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(BadRequestException);
const body = (error as BadRequestException).getResponse() as {
messages: string[];
};
expect(body.messages.length).toBeGreaterThan(0);
expect(tx.hiveSystem.update).not.toHaveBeenCalled();
});
it('rejects an explicit empty-stack template with TEMPLATE_INVALID', async () => {
await expect(
service.updateSystem(
SYSTEM_ID,
{ template: { schemaVersion: 1, stack: [] } },
USER,
),
).rejects.toMatchObject({
response: expect.objectContaining({
code: 'TEMPLATE_INVALID',
}) as unknown,
});
});
it('stores a valid template and audits the update', async () => {
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID });
await service.updateSystem(SYSTEM_ID, { template: storedTemplate }, USER);
expect(tx.hiveSystem.update).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HiveSystem',
action: 'UPDATE',
entityId: SYSTEM_ID,
}),
tx,
);
});
it('deactivates without deleting', async () => {
tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID, active: false });
await service.updateSystem(SYSTEM_ID, { active: false }, USER);
expect(tx.hiveSystem.update).toHaveBeenCalledWith({
where: { id: SYSTEM_ID },
data: { active: false },
});
});
});
describe('createSystem', () => {
it('creates with an empty stack and audits CREATE', async () => {
tx.hiveSystem.create.mockResolvedValue({ id: 'new' });
await service.createSystem(
{
name: 'Neu',
frameWidthMm: 1,
frameHeightMm: 1,
footprintLMm: 1,
footprintWMm: 1,
},
USER,
);
expect(tx.hiveSystem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
template: { schemaVersion: 1, stack: [] },
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'HiveSystem', action: 'CREATE' }),
tx,
);
});
});
describe('part types', () => {
const existing = {
id: 'pt-1',
systemId: SYSTEM_ID,
kind: 'FEEDER',
name: 'Futter',
stateSchema: [levelField, choiceField],
active: true,
};
beforeEach(() => {
tx.hivePartType.findUnique.mockResolvedValue(existing);
tx.hivePartType.update.mockResolvedValue(existing);
});
it('rejects removing a state field with SCHEMA_NOT_ADDITIVE', async () => {
const error = await service
.updatePartType('pt-1', { stateSchema: [levelField] }, USER)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(BadRequestException);
expect((error as BadRequestException).getResponse()).toMatchObject({
code: 'SCHEMA_NOT_ADDITIVE',
});
expect(tx.hivePartType.update).not.toHaveBeenCalled();
});
it('rejects changing an existing field definition', async () => {
const changed = { ...levelField, max: 5 };
await expect(
service.updatePartType(
'pt-1',
{ stateSchema: [changed, choiceField] },
USER,
),
).rejects.toMatchObject({
response: expect.objectContaining({
code: 'SCHEMA_NOT_ADDITIVE',
}) as unknown,
});
});
it('accepts appending a field and audits the update', async () => {
const extra = { ...levelField, key: 'extra' };
await service.updatePartType(
'pt-1',
{ stateSchema: [levelField, choiceField, extra] },
USER,
);
expect(tx.hivePartType.update).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HivePartType',
action: 'UPDATE',
entityId: 'pt-1',
}),
tx,
);
});
it('rejects a duplicate key in the appended schema', async () => {
await expect(
service.updatePartType(
'pt-1',
{ stateSchema: [levelField, choiceField, levelField] },
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('locks the part type row before reading it', async () => {
await service.updatePartType('pt-1', { active: false }, USER);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.hivePartType.findUnique.mock.invocationCallOrder[0],
);
});
it.each([
['unknown feedType', { ...levelField, key: 'f', feedType: 'NOPE' }],
[
'duplicate options',
{
...choiceField,
key: 'c',
options: ['A', 'A'],
optionLabels: ['a', 'b'],
},
],
['key over 40', { ...levelField, key: 'k'.repeat(41) }],
['label over 60', { ...levelField, key: 'l', label: 'x'.repeat(61) }],
['unit over 10', { ...levelField, key: 'u', unit: 'x'.repeat(11) }],
[
'option over 40',
{
...choiceField,
key: 'o',
options: ['x'.repeat(41), 'B'],
optionLabels: ['a', 'b'],
},
],
[
'option label over 40',
{
...choiceField,
key: 'p',
options: ['A', 'B'],
optionLabels: ['x'.repeat(41), 'b'],
},
],
])('rejects a new field with %s', async (_name, field) => {
await expect(
service.updatePartType(
'pt-1',
{ stateSchema: [levelField, choiceField, field] },
USER,
),
).rejects.toMatchObject({
response: expect.objectContaining({
code: 'SCHEMA_INVALID',
}) as unknown,
});
});
it('accepts a valid feedType', async () => {
const ok = { ...levelField, key: 'fed', feedType: 'FEED_PASTE' };
await service.updatePartType(
'pt-1',
{ stateSchema: [levelField, choiceField, ok] },
USER,
);
expect(tx.hivePartType.update).toHaveBeenCalled();
});
it('deactivates by setting active false and never deletes', async () => {
await service.updatePartType('pt-1', { active: false }, USER);
expect(tx.hivePartType.update).toHaveBeenCalledWith({
where: { id: 'pt-1' },
data: { active: false },
});
});
it('throws NotFound for an unknown part type', async () => {
tx.hivePartType.findUnique.mockResolvedValue(null);
await expect(
service.updatePartType('x', { active: false }, USER),
).rejects.toBeInstanceOf(NotFoundException);
});
it('creates a part type with audit CREATE', async () => {
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
await service.createPartType(
SYSTEM_ID,
{
kind: 'ROOF',
name: 'Dach',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
stateSchema: [],
},
USER,
);
expect(tx.hivePartType.create).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HivePartType',
action: 'CREATE',
}),
tx,
);
});
it('requires frameSlots for a BOX', async () => {
await expect(
service.createPartType(
SYSTEM_ID,
{
kind: 'BOX',
name: 'Zarge',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
},
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});
@@ -0,0 +1,296 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { HivePartType, HiveSystem, Prisma } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto';
import { CreateHiveSystemDto } from './dto/create-hive-system.dto';
import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto';
import { UpdateHiveSystemDto } from './dto/update-hive-system.dto';
import {
isAdditiveSchema,
parseStateSchema,
stateSchemaProblems,
} from './hive-state-schema';
import { catalogFromRows } from './hive-types';
import type { HiveCatalog, HiveState } from './hive-types';
import { validateHiveState } from './hive-validate';
export type HiveSystemDto = HiveSystem & { partTypes: HivePartType[] };
const EMPTY_TEMPLATE = { schemaVersion: 1, stack: [] as never[] };
const UNIQUE_VIOLATION = 'P2002';
type Obj = Record<string, unknown>;
const isObj = (value: unknown): value is Obj =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Copy of a stored part (no id) with a fresh id on it and on its inserts. */
function stampPart(part: unknown): unknown {
if (!isObj(part)) return part;
const stamped: Obj = { ...part, id: randomUUID() };
if (Array.isArray(part.inserts)) {
stamped.inserts = part.inserts.map(stampPart);
}
return stamped;
}
function stampTemplate(template: unknown): unknown {
if (!isObj(template) || !Array.isArray(template.stack)) return template;
return { ...template, stack: template.stack.map(stampPart) };
}
function conflictOnDuplicate(error: unknown, what: string): never {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === UNIQUE_VIOLATION
) {
throw new ConflictException(`${what} name already exists`);
}
throw error;
}
@Injectable()
export class HiveCatalogService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async listSystems(): Promise<HiveSystemDto[]> {
return this.prisma.hiveSystem.findMany({
include: { partTypes: { orderBy: { name: 'asc' } } },
orderBy: { name: 'asc' },
});
}
/** Part types of a system incl. inactive ones (`active: false`). */
async loadCatalog(
systemId: string,
tx?: Prisma.TransactionClient,
): Promise<HiveCatalog> {
const db = tx ?? this.prisma;
const system = await db.hiveSystem.findUnique({
where: { id: systemId },
select: { id: true },
});
if (!system) throw new NotFoundException('Hive system not found');
const rows = await db.hivePartType.findMany({ where: { systemId } });
return catalogFromRows(
rows.map((row) => ({
id: row.id,
kind: row.kind,
name: row.name,
lengthMm: row.lengthMm,
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
stateSchema: parseStateSchema(row.stateSchema),
active: row.active,
})),
systemId,
);
}
/** The system template as a hive state with fresh ids on every part. */
async template(systemId: string): Promise<HiveState> {
const system = await this.prisma.hiveSystem.findUnique({
where: { id: systemId },
});
if (!system) throw new NotFoundException('Hive system not found');
const stored = system.template;
if (
isObj(stored) &&
Array.isArray(stored.stack) &&
stored.stack.length === 0
) {
throw new ConflictException({
code: 'TEMPLATE_NOT_SET',
message: 'The hive system has no template yet',
});
}
return stampTemplate(stored) as HiveState;
}
async createSystem(
dto: CreateHiveSystemDto,
actingUserId: string,
): Promise<HiveSystem> {
return this.prisma
.$transaction(async (tx) => {
// A template needs part types of the system, so it starts empty and
// is set with PATCH once the catalog exists.
const system = await tx.hiveSystem.create({
data: { ...dto, template: EMPTY_TEMPLATE },
});
await this.auditLog.record(
{
entityType: 'HiveSystem',
entityId: system.id,
action: 'CREATE',
userId: actingUserId,
afterData: system,
},
tx,
);
return system;
})
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
}
async updateSystem(
id: string,
dto: UpdateHiveSystemDto,
actingUserId: string,
): Promise<HiveSystem> {
return this.prisma
.$transaction(async (tx) => {
const before = await tx.hiveSystem.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Hive system not found');
if (dto.template !== undefined) {
await this.assertValidTemplate(id, dto.template, tx);
}
const { template, ...rest } = dto;
const system = await tx.hiveSystem.update({
where: { id },
data: {
...rest,
...(template !== undefined
? { template: template as Prisma.InputJsonValue }
: {}),
},
});
await this.auditLog.record(
{
entityType: 'HiveSystem',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: system,
},
tx,
);
return system;
})
.catch((error: unknown) => conflictOnDuplicate(error, 'System'));
}
async createPartType(
systemId: string,
dto: CreateHivePartTypeDto,
actingUserId: string,
): Promise<HivePartType> {
if (dto.kind === 'BOX' && dto.frameSlots === undefined) {
throw new BadRequestException('frameSlots is required for a BOX');
}
const stateSchema = dto.stateSchema ?? [];
this.assertSchemaShape(stateSchema);
return this.prisma
.$transaction(async (tx) => {
const system = await tx.hiveSystem.findUnique({
where: { id: systemId },
select: { id: true },
});
if (!system) throw new NotFoundException('Hive system not found');
const partType = await tx.hivePartType.create({
data: {
...dto,
systemId,
stateSchema: stateSchema as Prisma.InputJsonValue,
},
});
await this.auditLog.record(
{
entityType: 'HivePartType',
entityId: partType.id,
action: 'CREATE',
userId: actingUserId,
afterData: partType,
},
tx,
);
return partType;
})
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
}
async updatePartType(
id: string,
dto: UpdateHivePartTypeDto,
actingUserId: string,
): Promise<HivePartType> {
return this.prisma
.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'HivePartType', id);
const before = await tx.hivePartType.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Hive part type not found');
const { stateSchema, ...rest } = dto;
if (stateSchema !== undefined) {
this.assertSchemaShape(stateSchema);
this.assertAdditive(before.stateSchema, stateSchema);
}
const partType = await tx.hivePartType.update({
where: { id },
data: {
...rest,
...(stateSchema !== undefined
? { stateSchema: stateSchema as Prisma.InputJsonValue }
: {}),
},
});
await this.auditLog.record(
{
entityType: 'HivePartType',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: partType,
},
tx,
);
return partType;
})
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
}
private assertSchemaShape(stateSchema: unknown): void {
const problems = stateSchemaProblems(stateSchema);
if (problems.length > 0) {
throw new BadRequestException({
code: 'SCHEMA_INVALID',
messages: problems,
});
}
}
private assertAdditive(current: unknown, next: unknown): void {
if (!isAdditiveSchema(parseStateSchema(current), parseStateSchema(next))) {
throw new BadRequestException({
code: 'SCHEMA_NOT_ADDITIVE',
message:
'The state schema may only append fields; existing fields stay unchanged',
});
}
}
private async assertValidTemplate(
systemId: string,
template: unknown,
tx: Prisma.TransactionClient,
): Promise<void> {
const catalog = await this.loadCatalog(systemId, tx);
const messages = validateHiveState(stampTemplate(template), catalog);
if (messages.length > 0) {
throw new BadRequestException({ code: 'TEMPLATE_INVALID', messages });
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import type { HiveState } from './hive-types';
/** 409: an operation failed against the current state (spec 6.2). */
export class HiveConflictException extends ConflictException {
constructor(
opIndex: number,
reason: string,
current: { version: number; state: HiveState },
) {
super({
statusCode: 409,
code: 'HIVE_CONFLICT',
message: 'Die Änderung passt nicht mehr zum aktuellen Aufbau.',
opIndex,
reason,
current,
});
}
}
export function hiveNotFound(): NotFoundException {
return new NotFoundException({
statusCode: 404,
code: 'HIVE_NOT_FOUND',
message: 'Für dieses Volk gibt es noch keinen Aufbau.',
});
}
export function hiveExists(): ConflictException {
return new ConflictException({
statusCode: 409,
code: 'HIVE_EXISTS',
message: 'Für dieses Volk gibt es schon einen Aufbau.',
});
}
export function invalidDay(field: string): BadRequestException {
return new BadRequestException(`${field} must be a valid YYYY-MM-DD day`);
}
+118
View File
@@ -0,0 +1,118 @@
import { deriveHiveFacts } from './hive-facts';
import type { FrameType, HivePart, HiveState } from './hive-types';
import { loadFixtureCatalog } from './hive-test-utils';
const T = (slug: string) => `hive-part-zl10-${slug}`;
const catalog = loadFixtureCatalog();
const makeBox = (types: FrameType[]): HivePart => ({
id: 'b1',
partTypeId: T('box-10'),
state: {},
frames: types.map((type, i) => ({ slot: i + 1, type })),
});
const stackOf = (...parts: HivePart[]): HiveState => ({
schemaVersion: 1,
stack: [{ id: 'f1', partTypeId: T('floor'), state: {} }, ...parts],
});
describe('deriveHiveFacts', () => {
it('derives zero facts for the system template', () => {
const state = stackOf(
makeBox(Array<FrameType>(10).fill('FOUNDATION')),
{ id: 'c1', partTypeId: T('cover-abdeckfix'), state: {} },
{ id: 'l1', partTypeId: T('inner-lid'), state: {} },
{ id: 'i1', partTypeId: T('insulation'), state: {} },
{ id: 'r1', partTypeId: T('roof'), state: {} },
);
const facts = deriveHiveFacts(state, catalog);
expect(facts.boxCount).toBe(1);
expect(facts.broodFrames).toBe(0);
expect(facts.honeyFrames).toBe(0);
expect(facts.builtFrames).toBe(0);
expect(facts.hasFloorInsert).toBe(false);
expect(facts.evaporatorAcidMl).toBeNull();
});
it('counts brood, honey and built frames', () => {
const types: FrameType[] = [
...Array<FrameType>(6).fill('BROOD'),
'HONEY',
'HONEY',
'DIVIDER',
'NONE',
];
const facts = deriveHiveFacts(stackOf(makeBox(types)), catalog);
expect(facts.broodFrames).toBe(6);
expect(facts.honeyFrames).toBe(2);
expect(facts.builtFrames).toBe(8);
});
it('reports a feeder level with its feed type', () => {
const state = stackOf({
id: 'fd1',
partTypeId: T('feeder'),
state: { syrupLiters: 2.5 },
});
const facts = deriveHiveFacts(state, catalog);
const syrup = facts.levels.filter((l) => l.key === 'syrupLiters');
expect(syrup).toEqual([
{
partId: 'fd1',
key: 'syrupLiters',
label: 'Flüssigfutter',
unit: 'l',
value: 2.5,
max: 3,
feedType: 'SUGAR_WATER',
},
]);
// Missing level fields count as 0.
expect(facts.levels.find((l) => l.key === 'fondantKg')?.value).toBe(0);
});
it('detects a floor insert and reads evaporator acid', () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
{
id: 'f1',
partTypeId: T('floor'),
state: {},
inserts: [{ id: 'fi1', partTypeId: T('floor-insert'), state: {} }],
},
{
id: 'e1',
partTypeId: T('box-empty'),
state: {},
frames: [],
inserts: [
{ id: 'ev1', partTypeId: T('evaporator'), state: { acidMl: 250 } },
],
},
],
};
const facts = deriveHiveFacts(state, catalog);
expect(facts.hasFloorInsert).toBe(true);
expect(facts.evaporatorAcidMl).toBe(250);
expect(facts.boxCount).toBe(1);
});
it('reports 0 ml for an evaporator without acid state', () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
{ id: 'f1', partTypeId: T('floor'), state: {} },
{
id: 'e1',
partTypeId: T('box-empty'),
state: {},
frames: [],
inserts: [{ id: 'ev1', partTypeId: T('evaporator'), state: {} }],
},
],
};
expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import type {
HiveCatalog,
HiveFacts,
HiveLevelFact,
HivePart,
HiveState,
} from './hive-types';
const UNBUILT_FRAME_TYPES = new Set(['NONE', 'FOUNDATION', 'DIVIDER']);
function allParts(state: HiveState): HivePart[] {
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
}
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return [];
return type.stateSchema.flatMap((field) => {
if (field.kind !== 'level') return [];
const raw = part.state[field.key];
return [
{
partId: part.id,
key: field.key,
label: field.label,
unit: field.unit,
value: typeof raw === 'number' ? raw : 0,
max: field.max,
...(field.feedType ? { feedType: field.feedType } : {}),
},
];
});
}
/** Pure derivation of display facts (spec 5.3). Never written back to a colony. */
export function deriveHiveFacts(
state: HiveState,
catalog: HiveCatalog,
): HiveFacts {
const parts = allParts(state);
const kindOf = (part: HivePart) =>
catalog.partTypes.get(part.partTypeId)?.kind;
const frames = state.stack
.filter((part) => kindOf(part) === 'BOX')
.flatMap((part) => part.frames ?? []);
const evaporators = parts.filter((part) => kindOf(part) === 'EVAPORATOR');
const acid = evaporators.reduce((sum, part) => {
const value = part.state.acidMl;
return sum + (typeof value === 'number' ? value : 0);
}, 0);
return {
boxCount: state.stack.filter((part) => kindOf(part) === 'BOX').length,
broodFrames: frames.filter((f) => f.type === 'BROOD').length,
honeyFrames: frames.filter((f) => f.type === 'HONEY').length,
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
levels: parts.flatMap((part) => levelsOf(part, catalog)),
hasFloorInsert: parts.some((part) => kindOf(part) === 'FLOOR_INSERT'),
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
};
}
+114
View File
@@ -0,0 +1,114 @@
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { applyHiveOps } from './hive-ops';
import { validateHiveState } from './hive-validate';
import type { HiveOp, HiveState } from './hive-types';
import { FIXTURE_DIR, deepFreeze, loadFixtureCatalog } from './hive-test-utils';
interface FixtureCase {
name: string;
inactivePartTypeIds?: string[];
state: HiveState;
ops: HiveOp[];
expect:
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reasonCode: string };
}
const cases = readdirSync(FIXTURE_DIR)
.filter((file) => file.endsWith('.json') && file !== 'catalog.json')
.sort()
.map((file) => ({
file,
data: JSON.parse(
readFileSync(join(FIXTURE_DIR, file), 'utf8'),
) as FixtureCase,
}));
function findCase(name: string): FixtureCase {
const found = cases.find((c) => c.data.name === name);
if (!found) throw new Error(`fixture not found: ${name}`);
return found.data;
}
describe('applyHiveOps fixtures', () => {
it('has at least 20 cases', () => {
expect(cases.length).toBeGreaterThanOrEqual(20);
});
it.each(cases.map((c) => [c.file, c.data] as const))('%s', (_file, data) => {
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
const result = applyHiveOps(
deepFreeze(structuredClone(data.state)),
deepFreeze(structuredClone(data.ops)),
catalog,
);
if (data.expect.ok) {
expect(result).toEqual({ ok: true, state: data.expect.state });
// The expected end state must itself be valid.
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
} else {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.opIndex).toBe(data.expect.opIndex);
expect(result.reason.split(':')[0]).toBe(data.expect.reasonCode);
}
}
});
});
describe('applyHiveOps purity', () => {
it('does not mutate a deep-frozen input state or ops', () => {
const catalog = loadFixtureCatalog();
const sample = findCase('multiple ops apply in order');
const state = deepFreeze(structuredClone(sample.state));
const ops = deepFreeze(structuredClone(sample.ops));
const before = JSON.stringify(state);
const result = applyHiveOps(state, ops, catalog);
expect(result.ok).toBe(true);
expect(JSON.stringify(state)).toBe(before);
});
it('does not share references between input and result', () => {
const catalog = loadFixtureCatalog();
const state = structuredClone(
findCase('setLabel sets and clears a label').state,
);
const result = applyHiveOps(state, [], catalog);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.state).not.toBe(state);
expect(result.state.stack[0]).not.toBe(state.stack[0]);
}
});
});
describe('validateHiveState', () => {
const catalog = loadFixtureCatalog();
it('rejects non-objects and wrong schemaVersion', () => {
expect(validateHiveState(null, catalog).length).toBeGreaterThan(0);
expect(
validateHiveState({ schemaVersion: 2, stack: [] }, catalog).length,
).toBeGreaterThan(0);
});
it('collects several violations', () => {
const state = {
schemaVersion: 1,
stack: [
{ id: 'a', partTypeId: 'hive-part-zl10-roof', state: {} },
{ id: 'a', partTypeId: 'hive-part-zl10-floor', state: { x: 1 } },
],
};
const codes = validateHiveState(state, catalog).map((r) => r.split(':')[0]);
expect(codes).toEqual(
expect.arrayContaining([
'DUPLICATE_ID',
'RULE_FLOOR_BOTTOM',
'RULE_ROOF_TOP',
'STATE_UNKNOWN_KEY',
]),
);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { MAX_OPS } from './hive-types';
import type {
HiveApplyResult,
HiveCatalog,
HiveFrame,
HiveOp,
HivePart,
HiveState,
} from './hive-types';
import { validateHiveState } from './hive-validate';
interface Location {
part: HivePart;
list: HivePart[];
index: number;
isInsert: boolean;
}
/** Result of one op on the working copy: a reason string, or null on success. */
type OpOutcome = string | null;
function locate(state: HiveState, id: unknown): Location | null {
for (let index = 0; index < state.stack.length; index++) {
const part = state.stack[index];
if (part.id === id) {
return { part, list: state.stack, index, isInsert: false };
}
const inserts = part.inserts ?? [];
for (let i = 0; i < inserts.length; i++) {
if (inserts[i].id === id) {
return { part: inserts[i], list: inserts, index: i, isInsert: true };
}
}
}
return null;
}
function missing(id: unknown): string {
return `MISSING_ID: no part with id ${String(id)}`;
}
function clamp(value: unknown, max: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) return max;
return Math.min(Math.max(Math.trunc(value), 0), max);
}
/** Catalog membership and active check for a part to be added (and its inserts). */
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
if (typeof part !== 'object' || part === null) {
return 'MISSING_ID: part is not an object';
}
const type = catalog.partTypes.get(part.partTypeId);
if (!type) {
return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
}
if (!type.active) {
return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
}
for (const insert of part.inserts ?? []) {
const nested = checkAddable(insert, catalog);
if (nested) return nested;
}
return null;
}
/** Deep copy of an incoming part without empty `inserts` arrays. */
function normalisedCopy(part: HivePart): HivePart {
const copy = structuredClone(part);
if (Array.isArray(copy.inserts)) {
if (copy.inserts.length === 0) delete copy.inserts;
else copy.inserts = copy.inserts.map(normalisedCopy);
}
return copy;
}
function applyAddPart(
state: HiveState,
op: Extract<HiveOp, { op: 'addPart' }>,
catalog: HiveCatalog,
): OpOutcome {
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
const at = clamp(op.at, state.stack.length);
state.stack.splice(at, 0, normalisedCopy(op.part));
return null;
}
function applyAddInsert(
state: HiveState,
op: Extract<HiveOp, { op: 'addInsert' }>,
catalog: HiveCatalog,
): OpOutcome {
const host = locate(state, op.hostId);
if (!host || host.isInsert) return missing(op.hostId);
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
return null;
}
function applyRemovePart(
state: HiveState,
op: Extract<HiveOp, { op: 'removePart' }>,
): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.list.splice(found.index, 1);
if (found.isInsert) {
const host = state.stack.find((p) => p.inserts === found.list);
if (host && found.list.length === 0) delete host.inserts;
}
return null;
}
function applyMovePart(
state: HiveState,
op: Extract<HiveOp, { op: 'movePart' }>,
): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (found.isInsert) {
return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
}
const [moved] = state.stack.splice(found.index, 1);
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
return null;
}
function applySetState(
state: HiveState,
op: Extract<HiveOp, { op: 'setState' }>,
): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.part.state = { ...found.part.state, [op.key]: op.value };
return null;
}
function locateBox(state: HiveState, boxId: unknown): Location | string {
const found = locate(state, boxId);
if (!found) return missing(boxId);
if (found.isInsert || !Array.isArray(found.part.frames)) {
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
}
return found;
}
function applySetFrame(
state: HiveState,
op: Extract<HiveOp, { op: 'setFrame' }>,
): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === 'string') return box;
const frames = box.part.frames as HiveFrame[];
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
}
// name: undefined keeps the current name, null removes it.
const current = frames[op.slot - 1];
const name = op.name === undefined ? current.name : (op.name ?? undefined);
frames[op.slot - 1] =
name === undefined
? { slot: op.slot, type: op.type }
: { slot: op.slot, type: op.type, name };
return null;
}
function applySetFrames(
state: HiveState,
op: Extract<HiveOp, { op: 'setFrames' }>,
): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === 'string') return box;
box.part.frames = structuredClone(op.frames);
return null;
}
function applySetLabel(
state: HiveState,
op: Extract<HiveOp, { op: 'setLabel' }>,
): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (op.label === null) {
delete found.part.label;
} else {
found.part.label = op.label;
}
return null;
}
function applyOne(
state: HiveState,
op: HiveOp,
catalog: HiveCatalog,
): OpOutcome {
switch (op?.op) {
case 'addPart':
return applyAddPart(state, op, catalog);
case 'addInsert':
return applyAddInsert(state, op, catalog);
case 'removePart':
return applyRemovePart(state, op);
case 'movePart':
return applyMovePart(state, op);
case 'setState':
return applySetState(state, op);
case 'setFrame':
return applySetFrame(state, op);
case 'setFrames':
return applySetFrames(state, op);
case 'setLabel':
return applySetLabel(state, op);
default:
return 'UNKNOWN_OP: unsupported operation';
}
}
/**
* Applies ops in order and validates the whole state after each one.
* Never mutates its inputs: it works on a private deep copy.
*/
export function applyHiveOps(
state: HiveState,
ops: HiveOp[],
catalog: HiveCatalog,
): HiveApplyResult {
if (ops.length > MAX_OPS) {
return {
ok: false,
opIndex: MAX_OPS,
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
};
}
const working = structuredClone(state);
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
const failure =
applyOne(working, ops[opIndex], catalog) ??
validateHiveState(working, catalog)[0] ??
null;
if (failure) return { ok: false, opIndex, reason: failure };
}
return { ok: true, state: working };
}
@@ -0,0 +1,121 @@
import { FeedType } from '../../generated/prisma';
import type { StateField } from './hive-types';
const MAX_KEY = 40;
const MAX_LABEL = 60;
const MAX_UNIT = 10;
const MAX_OPTION = 40;
const FEED_TYPES: readonly string[] = Object.values(FeedType);
type Obj = Record<string, unknown>;
const isObj = (value: unknown): value is Obj =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const isPositive = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0;
const isStringList = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((x) => typeof x === 'string');
function levelProblem(field: Obj, where: string): string | null {
if (typeof field.unit !== 'string' || field.unit.length > MAX_UNIT) {
return `${where}.unit must be a string of at most ${MAX_UNIT} characters`;
}
if (!isPositive(field.max)) return `${where}.max must be positive`;
if (!isPositive(field.step)) return `${where}.step must be positive`;
if (
field.feedType !== undefined &&
!(typeof field.feedType === 'string' && FEED_TYPES.includes(field.feedType))
) {
return `${where}.feedType must be one of ${FEED_TYPES.join('/')}`;
}
return null;
}
function choiceProblem(field: Obj, where: string): string | null {
const { options, optionLabels } = field;
if (!isStringList(options) || options.length === 0) {
return `${where}.options must be a non-empty list of strings`;
}
if (new Set(options).size !== options.length) {
return `${where}.options must be unique`;
}
if (!isStringList(optionLabels) || optionLabels.length !== options.length) {
return `${where}.optionLabels must match options`;
}
if ([...options, ...optionLabels].some((text) => text.length > MAX_OPTION)) {
return `${where} options and labels must have at most ${MAX_OPTION} characters`;
}
return null;
}
function fieldProblem(field: unknown, index: number): string | null {
const where = `stateSchema[${index}]`;
if (!isObj(field)) return `${where} must be an object`;
if (
typeof field.key !== 'string' ||
field.key === '' ||
field.key.length > MAX_KEY
) {
return `${where}.key must be 1-${MAX_KEY} characters`;
}
if (
typeof field.label !== 'string' ||
field.label === '' ||
field.label.length > MAX_LABEL
) {
return `${where}.label must be 1-${MAX_LABEL} characters`;
}
if (field.kind === 'level') return levelProblem(field, where);
if (field.kind === 'choice') return choiceProblem(field, where);
return `${where}.kind must be level or choice`;
}
/** Returns the problems of a raw stateSchema value (empty list means valid). */
export function stateSchemaProblems(raw: unknown): string[] {
if (!Array.isArray(raw)) return ['stateSchema must be a list'];
const problems: string[] = [];
const keys = new Set<string>();
raw.forEach((field: unknown, index) => {
const problem = fieldProblem(field, index);
if (problem) {
problems.push(problem);
return;
}
const key = (field as Obj).key as string;
if (keys.has(key)) problems.push(`duplicate key ${key}`);
keys.add(key);
});
return problems;
}
/** Casts a stored JSON value after the shape check; throws when malformed. */
export function parseStateSchema(raw: unknown): StateField[] {
const problems = stateSchemaProblems(raw);
if (problems.length > 0) {
throw new Error(`Malformed stateSchema: ${problems.join('; ')}`);
}
return raw as StateField[];
}
/** Stable JSON text: object keys sorted, so key order does not matter. */
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (isObj(value)) {
return `{${Object.keys(value)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}
/** True when `next` keeps every field of `current` unchanged and only appends. */
export function isAdditiveSchema(
current: StateField[],
next: StateField[],
): boolean {
if (next.length < current.length) return false;
return current.every((field, i) => canonical(field) === canonical(next[i]));
}
@@ -0,0 +1,33 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { catalogFromRows } from './hive-types';
import type { CatalogPartType, HiveCatalog } from './hive-types';
export const FIXTURE_DIR = join(__dirname, '../../test/fixtures/hive-ops');
interface CatalogFile {
systemId: string;
partTypes: CatalogPartType[];
}
/** Loads the shared catalog fixture; `inactiveIds` marks part types inactive. */
export function loadFixtureCatalog(inactiveIds: string[] = []): HiveCatalog {
const file = JSON.parse(
readFileSync(join(FIXTURE_DIR, 'catalog.json'), 'utf8'),
) as CatalogFile;
const rows = file.partTypes.map((row) => ({
...row,
active: !inactiveIds.includes(row.id),
}));
return catalogFromRows(rows, file.systemId);
}
export function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value as Record<string, unknown>).forEach((child) =>
deepFreeze(child),
);
}
return value;
}
+156
View File
@@ -0,0 +1,156 @@
// Pure domain types for the hive setup (spec 5.2, 6.1). No framework or
// Prisma imports: the frontend mirrors this module.
export type HivePartKind =
| 'FLOOR'
| 'BOX'
| 'DIVIDER_BOARD'
| 'QUEEN_EXCLUDER'
| 'FEEDER'
| 'COVER_SHEET'
| 'PROPOLIS_SCREEN'
| 'INNER_LID'
| 'INSULATION'
| 'ROOF'
| 'FLOOR_INSERT'
| 'EVAPORATOR';
// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on.
export type HiveBoxDepth = 'FULL' | 'SHALLOW';
export type FrameType =
| 'BROOD'
| 'DRONE'
| 'HONEY'
| 'POLLEN'
| 'FEED'
| 'FOUNDATION'
| 'EMPTY_COMB'
| 'DRONE_FRAME'
| 'DIVIDER'
| 'NONE';
export const FRAME_TYPES: readonly FrameType[] = [
'BROOD',
'DRONE',
'HONEY',
'POLLEN',
'FEED',
'FOUNDATION',
'EMPTY_COMB',
'DRONE_FRAME',
'DIVIDER',
'NONE',
];
export const MAX_STACK_PARTS = 30;
export const MAX_OPS = 100;
export const MAX_TEXT_LENGTH = 40;
export const LEVEL_TOLERANCE = 1e-9;
export interface HiveFrame {
slot: number;
type: FrameType;
name?: string;
}
export interface HivePart {
id: string;
partTypeId: string;
label?: string;
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];
}
export type HiveOp =
| { op: 'addPart'; part: HivePart; at: number }
| { op: 'addInsert'; hostId: string; part: HivePart }
| { op: 'removePart'; id: string }
| { op: 'movePart'; id: string; to: number }
| { op: 'setState'; id: string; key: string; value: number | string }
| {
op: 'setFrame';
boxId: string;
slot: number;
type: FrameType;
name?: string | null;
}
| { op: 'setFrames'; boxId: string; frames: HiveFrame[] }
| { op: 'setLabel'; id: string; label: string | null };
export interface LevelField {
key: string;
label: string;
kind: 'level';
unit: string;
max: number;
step: number;
feedType?: string;
}
export interface ChoiceField {
key: string;
label: string;
kind: 'choice';
options: string[];
optionLabels: string[];
}
export type StateField = LevelField | ChoiceField;
export interface CatalogPartType {
id: string;
kind: HivePartKind;
name: string;
lengthMm: number;
widthMm: number;
heightMm: number;
frameSlots: number | null;
stateSchema: StateField[];
active: boolean;
}
export interface HiveCatalog {
systemId: string;
partTypes: Map<string, CatalogPartType>;
}
export function catalogFromRows(
rows: CatalogPartType[],
systemId: string,
): HiveCatalog {
return {
systemId,
partTypes: new Map(rows.map((row) => [row.id, row])),
};
}
export interface HiveLevelFact {
partId: string;
key: string;
label: string;
unit: string;
value: number;
max: number;
feedType?: string;
}
export interface HiveFacts {
boxCount: number;
broodFrames: number;
honeyFrames: number;
builtFrames: number;
levels: HiveLevelFact[];
hasFloorInsert: boolean;
evaporatorAcidMl: number | null;
}
export type HiveApplyResult =
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reason: string };
+269
View File
@@ -0,0 +1,269 @@
import {
FRAME_TYPES,
LEVEL_TOLERANCE,
MAX_STACK_PARTS,
MAX_TEXT_LENGTH,
} from './hive-types';
import type { CatalogPartType, HiveCatalog, StateField } from './hive-types';
// Check order decides which code is reported first; the frontend port must
// keep it: root shape (schemaVersion must be 1 -> STATE_OUT_OF_RANGE; extra
// root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per stack part: id, unknown
// fields (UNKNOWN_FIELD), type, label, state, frames (per frame: slot, unknown
// fields, type, name), inserts (each insert in the same order), then FLOOR, then ROOF.
// Text lengths are UTF-16 code units (`.length`); the frontend must do the same.
const ROOT_KEYS = ['schemaVersion', 'stack'];
const PART_KEYS = ['id', 'partTypeId', 'label', 'state', 'inserts', 'frames'];
const FRAME_KEYS = ['slot', 'type', 'name'];
function unknownFields(
obj: Record<string, unknown>,
allowed: string[],
where: string,
out: string[],
): void {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
}
}
type Obj = Record<string, unknown>;
const isObj = (value: unknown): value is Obj =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Checks one state value against its schema field; returns a reason or null. */
export function checkStateValue(
field: StateField,
value: unknown,
where: string,
): string | null {
if (field.kind === 'choice') {
if (typeof value !== 'string' || !field.options.includes(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join('/')}`;
}
return null;
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
}
if (value < 0 || value > field.max) {
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
}
const steps = value / field.step;
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
}
return null;
}
function validateState(
part: Obj,
type: CatalogPartType,
where: string,
out: string[],
): void {
if (!isObj(part.state)) {
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
return;
}
for (const [key, value] of Object.entries(part.state)) {
const field = type.stateSchema.find((f) => f.key === key);
if (!field) {
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
continue;
}
const problem = checkStateValue(field, value, `${where}.${key}`);
if (problem) out.push(problem);
}
}
function validateFrames(
part: Obj,
type: CatalogPartType,
where: string,
out: string[],
): void {
const slots = type.kind === 'BOX' ? (type.frameSlots ?? 0) : 0;
const frames = part.frames;
if (type.kind !== 'BOX') {
if (
frames !== undefined &&
!(Array.isArray(frames) && frames.length === 0)
) {
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
}
return;
}
const list = frames === undefined && slots === 0 ? [] : frames;
if (!Array.isArray(list) || list.length !== slots) {
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
return;
}
list.forEach((frame: unknown, index) => {
if (!isObj(frame) || frame.slot !== index + 1) {
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
return;
}
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
out.push(
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`,
);
}
if (frame.name !== undefined) {
if (typeof frame.name !== 'string') {
out.push(
`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`,
);
} else if (frame.name.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
}
}
});
}
interface WalkContext {
catalog: HiveCatalog;
seenIds: Set<string>;
out: string[];
}
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
function validatePartCommon(
part: unknown,
where: string,
ctx: WalkContext,
): { part: Obj; type: CatalogPartType } | null {
const { out } = ctx;
if (!isObj(part)) {
out.push(`MISSING_ID: ${where} is not an object`);
return null;
}
if (typeof part.id !== 'string' || part.id === '') {
out.push(`MISSING_ID: ${where} has no id`);
} else if (ctx.seenIds.has(part.id)) {
out.push(`DUPLICATE_ID: ${part.id}`);
} else {
ctx.seenIds.add(part.id);
}
unknownFields(part, PART_KEYS, where, out);
const type =
typeof part.partTypeId === 'string'
? ctx.catalog.partTypes.get(part.partTypeId)
: undefined;
if (!type) {
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
return null;
}
if (part.label !== undefined) {
if (typeof part.label !== 'string') {
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
} else if (part.label.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} label is too long`);
}
}
validateState(part, type, where, out);
validateFrames(part, type, where, out);
return { part, type };
}
function validateInserts(
host: Obj,
hostType: CatalogPartType,
where: string,
ctx: WalkContext,
): void {
const { out } = ctx;
const inserts = host.inserts;
if (inserts === undefined) return;
if (!Array.isArray(inserts)) {
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed =
hostType.kind === 'FLOOR'
? 'FLOOR_INSERT'
: hostType.kind === 'BOX'
? 'EVAPORATOR'
: null;
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
}
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
}
if (checked.part.inserts !== undefined) {
const nested = checked.part.inserts;
if (!Array.isArray(nested) || nested.length > 0) {
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
}
});
}
function validateStackRules(
kinds: (CatalogPartType | null)[],
out: string[],
): void {
const floors = kinds.filter((t) => t?.kind === 'FLOOR').length;
if (floors !== 1 || kinds[0]?.kind !== 'FLOOR') {
out.push(
'RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom',
);
}
const roofIndexes = kinds
.map((t, i) => (t?.kind === 'ROOF' ? i : -1))
.filter((i) => i >= 0);
if (
roofIndexes.length > 1 ||
(roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)
) {
out.push('RULE_ROOF_TOP: the roof must be the single top part');
}
}
/** Validates a whole hive state. Empty result means valid. */
export function validateHiveState(
state: unknown,
catalog: HiveCatalog,
): string[] {
const out: string[] = [];
if (
!isObj(state) ||
state.schemaVersion !== 1 ||
!Array.isArray(state.stack)
) {
return ['STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list'];
}
unknownFields(state, ROOT_KEYS, 'state', out);
if (state.stack.length > MAX_STACK_PARTS) {
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
}
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
const types: (CatalogPartType | null)[] = [];
state.stack.forEach((entry: unknown, index) => {
const where = `stack[${index}]`;
const checked = validatePartCommon(entry, where, ctx);
types.push(checked?.type ?? null);
if (!checked) return;
if (
checked.type.kind === 'FLOOR_INSERT' ||
checked.type.kind === 'EVAPORATOR'
) {
out.push(
`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`,
);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
return out;
}
@@ -0,0 +1,76 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { CreateHiveChangeDto } from './dto/create-hive-change.dto';
import { CreateHiveSetupDto } from './dto/create-hive-setup.dto';
import { HiveAtQueryDto, HiveHistoryQueryDto } from './dto/hive-queries.dto';
import { HiveService } from './hive.service';
import type { HiveOp } from './hive-types';
@ApiTags('hive')
@ApiBearerAuth()
@Controller('colonies/:id/hive')
export class HiveController {
constructor(
private readonly hive: HiveService,
private readonly prisma: PrismaService,
) {}
@RequirePermission('colonies:read')
@Get()
get(@Param('id') id: string) {
return this.hive.get(id);
}
@RequirePermission('colonies:update')
@Idempotent()
@Post()
create(
@Param('id') id: string,
@Body() dto: CreateHiveSetupDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.hive.create(id, dto, actor.id);
}
@RequirePermission('colonies:update')
@Idempotent()
@Post('changes')
applyChange(
@Param('id') id: string,
@Body() dto: CreateHiveChangeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
// Plain objects only: the DTO instances carry class metadata.
const ops = JSON.parse(JSON.stringify(dto.ops)) as HiveOp[];
return this.prisma.$transaction((tx) =>
this.hive.applyChange(
tx,
id,
{
occurredOn: dto.occurredOn,
source: dto.source,
ops,
note: dto.note,
},
actor.id,
),
);
}
@RequirePermission('colonies:read')
@Get('changes')
history(@Param('id') id: string, @Query() query: HiveHistoryQueryDto) {
return this.hive.history(id, query.cursor, query.limit);
}
@RequirePermission('colonies:read')
@Get('at')
at(@Param('id') id: string, @Query() query: HiveAtQueryDto) {
return this.hive.at(id, query.day);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HiveCatalogController } from './hive-catalog.controller';
import { HiveCatalogService } from './hive-catalog.service';
import { HiveController } from './hive.controller';
import { HiveService } from './hive.service';
@Module({
controllers: [HiveCatalogController, HiveController],
providers: [HiveCatalogService, HiveService],
exports: [HiveCatalogService, HiveService],
})
export class HiveModule {}
@@ -0,0 +1,609 @@
import { HttpException } from '@nestjs/common';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { addDays, berlinToday } from '../common/dates';
import { HiveCatalogService } from './hive-catalog.service';
import { HiveService } from './hive.service';
import { loadFixtureCatalog } from './hive-test-utils';
import type { HiveOp, HiveState } from './hive-types';
jest.mock('../prisma/row-lock');
const SYSTEM_ID = 'hive-system-zander-liebig-10';
const COLONY = 'colony-1';
const USER = 'user-1';
const FLOOR = 'hive-part-zl10-floor';
const BOX = 'hive-part-zl10-box-10';
const ROOF = 'hive-part-zl10-roof';
const frames = Array.from({ length: 10 }, (_, i) => ({
slot: i + 1,
type: 'FOUNDATION',
}));
const baseState = {
schemaVersion: 1,
stack: [
{ id: 'p-floor', partTypeId: FLOOR, state: {} },
{ id: 'p-box', partTypeId: BOX, state: {}, frames },
{ id: 'p-roof', partTypeId: ROOF, state: {} },
],
} as unknown as HiveState;
const TODAY = berlinToday();
const INIT_DAY = addDays(TODAY, -10);
const setLabel = (label: string): HiveOp => ({
op: 'setLabel',
id: 'p-box',
label,
});
function dayDate(day: string): Date {
return new Date(`${day}T00:00:00.000Z`);
}
function argOf<T>(mock: jest.Mock, call: number, arg: number): T {
return (mock.mock.calls as unknown[][])[call][arg] as T;
}
interface WrittenChange {
data: {
stateAfter: { stack: { label?: string }[] };
occurredOn: Date;
inspectionId: string | null;
};
}
const lockedTables = (mock: jest.Mock): unknown[] =>
(mock.mock.calls as unknown[][]).map((call) => call[1]);
async function statusAndBody(promise: Promise<unknown>) {
try {
await promise;
} catch (error) {
if (error instanceof HttpException) {
return { status: error.getStatus(), body: error.getResponse() };
}
throw error;
}
throw new Error('expected the call to reject');
}
describe('HiveService', () => {
let service: HiveService;
let tx: {
colony: { findUnique: jest.Mock };
hiveSystem: { findUnique: jest.Mock };
hiveSetup: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
hiveChange: {
findUnique: jest.Mock;
findFirst: jest.Mock;
findMany: jest.Mock;
create: jest.Mock;
};
};
let prisma: { $transaction: jest.Mock } & typeof tx;
let events: { record: jest.Mock };
let catalogs: { loadCatalog: jest.Mock };
const lock = lockRowForUpdate as jest.Mock;
const setup = (version = 1) => ({
colonyId: COLONY,
systemId: SYSTEM_ID,
version,
state: baseState,
updatedAt: new Date('2026-10-01T10:00:00.000Z'),
});
beforeEach(() => {
tx = {
colony: {
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
},
hiveSystem: {
findUnique: jest
.fn()
.mockResolvedValue({ id: SYSTEM_ID, active: true }),
},
hiveSetup: {
findUnique: jest.fn().mockResolvedValue(setup()),
create: jest.fn().mockImplementation(({ data }: { data: object }) => ({
...data,
updatedAt: new Date('2026-10-02T10:00:00.000Z'),
})),
update: jest.fn().mockImplementation(({ data }: { data: object }) => ({
colonyId: COLONY,
systemId: SYSTEM_ID,
updatedAt: new Date('2026-10-03T10:00:00.000Z'),
...data,
})),
},
hiveChange: {
findUnique: jest.fn().mockResolvedValue({
occurredOn: dayDate(INIT_DAY),
}),
findFirst: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockResolvedValue({}),
},
};
prisma = {
...tx,
$transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
};
events = { record: jest.fn().mockResolvedValue(undefined) };
catalogs = {
loadCatalog: jest.fn().mockResolvedValue(loadFixtureCatalog()),
};
lock.mockReset().mockResolvedValue(true);
service = new HiveService(
prisma as unknown as PrismaService,
events as unknown as DomainEventsService,
catalogs as unknown as HiveCatalogService,
);
});
describe('get', () => {
it('returns the setup with derived facts', async () => {
const view = await service.get(COLONY);
expect(view).toMatchObject({
colonyId: COLONY,
systemId: SYSTEM_ID,
version: 1,
updatedAt: '2026-10-01T10:00:00.000Z',
});
expect(view.state).toEqual(baseState);
expect(view.facts.boxCount).toBe(1);
});
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
tx.hiveSetup.findUnique.mockResolvedValue(null);
const { status, body } = await statusAndBody(service.get(COLONY));
expect(status).toBe(404);
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
});
});
describe('create', () => {
const input = (overrides = {}) => ({
systemId: SYSTEM_ID,
state: baseState,
occurredOn: TODAY,
...overrides,
});
beforeEach(() => {
tx.hiveSetup.findUnique.mockResolvedValue(null);
});
it('writes version 1, the INIT change and the event in one transaction', async () => {
const view = await service.create(COLONY, input(), USER);
expect(view.version).toBe(1);
expect(tx.hiveSetup.create).toHaveBeenCalledWith({
data: {
colonyId: COLONY,
systemId: SYSTEM_ID,
version: 1,
state: baseState,
},
});
expect(tx.hiveChange.create).toHaveBeenCalledWith({
data: expect.objectContaining({
colonyId: COLONY,
version: 1,
source: 'INIT',
userId: USER,
occurredOn: dayDate(TODAY),
ops: [],
stateAfter: baseState,
}) as unknown,
});
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'HiveChanged',
aggregateType: 'Colony',
aggregateId: COLONY,
actorUserId: USER,
payload: {
colonyId: COLONY,
version: 1,
source: 'INIT',
inspectionId: null,
},
});
});
it('locks the colony before the setup', async () => {
await service.create(COLONY, input(), USER);
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
});
it('answers 409 HIVE_EXISTS for a second setup, decided under the lock', async () => {
tx.hiveSetup.findUnique.mockResolvedValue(setup());
const { status, body } = await statusAndBody(
service.create(COLONY, input(), USER),
);
expect(status).toBe(409);
expect(body).toMatchObject({ code: 'HIVE_EXISTS' });
expect(tx.hiveSetup.create).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('answers 409 for a colony that no longer lives', async () => {
tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' });
const { status } = await statusAndBody(
service.create(COLONY, input(), USER),
);
expect(status).toBe(409);
});
it('answers 404 for an unknown colony', async () => {
lock.mockResolvedValueOnce(false);
const { status } = await statusAndBody(
service.create(COLONY, input(), USER),
);
expect(status).toBe(404);
});
it('answers 400 with messages for a state the catalog refuses', async () => {
const state = { schemaVersion: 1, stack: [] };
state.stack.push({
id: 'x',
partTypeId: 'unknown-part',
state: {},
} as never);
const { status, body } = await statusAndBody(
service.create(COLONY, input({ state }), USER),
);
expect(status).toBe(400);
expect(body).toMatchObject({ messages: expect.any(Array) as unknown });
});
it('answers 400 for an inactive system and 404 for an unknown one', async () => {
tx.hiveSystem.findUnique.mockResolvedValueOnce({
id: SYSTEM_ID,
active: false,
});
expect(
(await statusAndBody(service.create(COLONY, input(), USER))).status,
).toBe(400);
tx.hiveSystem.findUnique.mockResolvedValueOnce(null);
expect(
(await statusAndBody(service.create(COLONY, input(), USER))).status,
).toBe(404);
});
it('answers 400 for a day in the future (Berlin)', async () => {
const { status } = await statusAndBody(
service.create(COLONY, input({ occurredOn: addDays(TODAY, 1) }), USER),
);
expect(status).toBe(400);
});
it('answers 400 for an impossible day', async () => {
const { status } = await statusAndBody(
service.create(COLONY, input({ occurredOn: '2026-02-30' }), USER),
);
expect(status).toBe(400);
});
});
describe('applyChange', () => {
const change = (overrides = {}) => ({
occurredOn: TODAY,
source: 'CARD' as const,
ops: [setLabel('Brutraum')],
...overrides,
});
it('increments the version, stores the change and records the event', async () => {
const view = await service.applyChange(
tx as never,
COLONY,
change({ note: 'Test' }),
USER,
);
expect(view.version).toBe(2);
const written = argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data;
expect(written).toMatchObject({
colonyId: COLONY,
version: 2,
source: 'CARD',
userId: USER,
note: 'Test',
occurredOn: dayDate(TODAY),
inspectionId: null,
});
expect(written.stateAfter.stack[1].label).toBe('Brutraum');
expect(tx.hiveSetup.update).toHaveBeenCalledWith({
where: { colonyId: COLONY },
data: { version: 2, state: written.stateAfter },
});
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'HiveChanged',
aggregateType: 'Colony',
aggregateId: COLONY,
actorUserId: USER,
payload: {
colonyId: COLONY,
version: 2,
source: 'CARD',
inspectionId: null,
},
});
});
it('passes the inspection id on to the change and the event', async () => {
await service.applyChange(
tx as never,
COLONY,
change({ source: 'INSPECTION', inspectionId: 'insp-1' }),
USER,
);
expect(
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.inspectionId,
).toBe('insp-1');
expect(
argOf<{ payload: object }>(events.record, 0, 1).payload,
).toMatchObject({
source: 'INSPECTION',
inspectionId: 'insp-1',
});
});
it('stores and applies only the fields the op name defines', async () => {
const ops = [
{ ...setLabel('Sauber'), junk: 1, id: 'p-box' },
] as unknown as HiveOp[];
await service.applyChange(tx as never, COLONY, change({ ops }), USER);
expect(
argOf<{ data: { ops: unknown } }>(tx.hiveChange.create, 0, 0).data.ops,
).toEqual([{ op: 'setLabel', id: 'p-box', label: 'Sauber' }]);
});
it('answers 400 for INIT and AI_SUGGESTION as source', async () => {
for (const source of ['INIT', 'AI_SUGGESTION']) {
const { status } = await statusAndBody(
service.applyChange(tx as never, COLONY, change({ source }), USER),
);
expect(status).toBe(400);
}
expect(lock).not.toHaveBeenCalled();
});
it('requires inspectionId exactly for source INSPECTION', async () => {
for (const input of [
change({ source: 'INSPECTION' }),
change({ source: 'CARD', inspectionId: 'insp-1' }),
]) {
const { status } = await statusAndBody(
service.applyChange(tx as never, COLONY, input, USER),
);
expect(status).toBe(400);
}
});
it('locks the colony, then the setup', async () => {
await service.applyChange(tx as never, COLONY, change(), USER);
expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']);
});
it('answers 409 for a colony that no longer lives', async () => {
tx.colony.findUnique.mockResolvedValue({ status: 'SOLD' });
const { status } = await statusAndBody(
service.applyChange(tx as never, COLONY, change(), USER),
);
expect(status).toBe(409);
expect(tx.hiveChange.create).not.toHaveBeenCalled();
});
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
lock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const { status, body } = await statusAndBody(
service.applyChange(tx as never, COLONY, change(), USER),
);
expect(status).toBe(404);
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
});
it('answers 409 HIVE_CONFLICT with the current state for a missing id', async () => {
tx.hiveSetup.findUnique.mockResolvedValue(setup(4));
const ops: HiveOp[] = [setLabel('ok'), { op: 'removePart', id: 'gone' }];
const { status, body } = await statusAndBody(
service.applyChange(tx as never, COLONY, change({ ops }), USER),
);
expect(status).toBe(409);
expect(body).toMatchObject({
code: 'HIVE_CONFLICT',
opIndex: 1,
reason: expect.stringMatching(/^MISSING_ID/) as unknown,
current: { version: 4, state: baseState },
});
expect(tx.hiveChange.create).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('answers 400 for tomorrow (Berlin)', async () => {
const { status } = await statusAndBody(
service.applyChange(
tx as never,
COLONY,
change({ occurredOn: addDays(TODAY, 1) }),
USER,
),
);
expect(status).toBe(400);
});
it('answers 400 for a day before the INIT change', async () => {
const { status } = await statusAndBody(
service.applyChange(
tx as never,
COLONY,
change({ occurredOn: addDays(INIT_DAY, -1) }),
USER,
),
);
expect(status).toBe(400);
});
it('accepts the INIT day itself and a backdated day', async () => {
await service.applyChange(
tx as never,
COLONY,
change({ occurredOn: INIT_DAY }),
USER,
);
expect(
argOf<WrittenChange>(tx.hiveChange.create, 0, 0).data.occurredOn,
).toEqual(dayDate(INIT_DAY));
});
it('leaves no event behind when the change cannot be written', async () => {
tx.hiveChange.create.mockRejectedValue(new Error('db down'));
await expect(
service.applyChange(tx as never, COLONY, change(), USER),
).rejects.toThrow('db down');
expect(events.record).not.toHaveBeenCalled();
});
it('writes the event with the caller transaction client only', async () => {
await service.applyChange(tx as never, COLONY, change(), USER);
expect(argOf<unknown>(events.record, 0, 0)).toBe(tx);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
describe('history', () => {
const row = (version: number, day: string) => ({
id: `c${version}`,
version,
occurredOn: dayDate(day),
source: 'CARD',
inspectionId: null,
userId: USER,
user: { name: 'Nick' },
ops: [],
note: null,
createdAt: new Date('2026-10-05T08:00:00.000Z'),
});
it('lists newest first without stateAfter and maps the user name', async () => {
tx.hiveChange.findMany.mockResolvedValue([row(3, '2026-10-05')]);
const result = await service.history(COLONY);
expect(tx.hiveChange.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { colonyId: COLONY },
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
take: 21,
}),
);
expect(
argOf<{ select: Record<string, unknown> }>(tx.hiveChange.findMany, 0, 0)
.select.stateAfter,
).toBe(undefined);
expect(result.items[0]).toEqual({
id: 'c3',
version: 3,
occurredOn: '2026-10-05',
source: 'CARD',
inspectionId: null,
userId: USER,
userName: 'Nick',
ops: [],
note: null,
createdAt: '2026-10-05T08:00:00.000Z',
});
expect(result.nextCursor).toBeNull();
});
it('returns a cursor when more rows exist and continues after it', async () => {
tx.hiveChange.findMany.mockResolvedValue([
row(4, '2026-10-05'),
row(2, '2026-10-04'),
row(3, '2026-10-03'),
]);
const page = await service.history(COLONY, undefined, 2);
expect(page.items.map((item) => item.version)).toEqual([4, 2]);
expect(page.nextCursor).not.toBeNull();
expect(
JSON.parse(
Buffer.from(page.nextCursor as string, 'base64url').toString(),
),
).toEqual({ occurredOn: '2026-10-04', version: 2 });
tx.hiveChange.findMany.mockResolvedValue([]);
await service.history(COLONY, page.nextCursor as string, 2);
expect(
argOf<{ where: unknown }>(tx.hiveChange.findMany, 1, 0).where,
).toEqual({
colonyId: COLONY,
OR: [
{ occurredOn: { lt: dayDate('2026-10-04') } },
{ occurredOn: dayDate('2026-10-04'), version: { lt: 2 } },
],
});
});
it('answers 400 for a malformed cursor', async () => {
const { status } = await statusAndBody(
service.history(COLONY, 'not-a-cursor'),
);
expect(status).toBe(400);
});
it('answers 404 HIVE_NOT_FOUND without a setup', async () => {
tx.hiveSetup.findUnique.mockResolvedValue(null);
const { status } = await statusAndBody(service.history(COLONY));
expect(status).toBe(404);
});
});
describe('at', () => {
it('returns the highest version with occurredOn <= day', async () => {
tx.hiveChange.findFirst.mockResolvedValue({
version: 3,
occurredOn: dayDate('2026-10-04'),
stateAfter: baseState,
});
const result = await service.at(COLONY, '2026-10-05');
expect(tx.hiveChange.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
colonyId: COLONY,
occurredOn: { lte: dayDate('2026-10-05') },
},
orderBy: { version: 'desc' },
}),
);
expect(result).toMatchObject({
version: 3,
occurredOn: '2026-10-04',
state: baseState,
});
expect(result.facts.boxCount).toBe(1);
});
it('answers 404 before the INIT day', async () => {
tx.hiveChange.findFirst.mockResolvedValue(null);
const { status, body } = await statusAndBody(
service.at(COLONY, '2020-01-01'),
);
expect(status).toBe(404);
expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
});
it('answers 400 for a malformed day', async () => {
const { status } = await statusAndBody(service.at(COLONY, '05.10.2026'));
expect(status).toBe(400);
});
});
});
+457
View File
@@ -0,0 +1,457 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { HiveChangeSource, Prisma } from '../../generated/prisma';
import { berlinToday, dayToDate } from '../common/dates';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { isLivingColonyStatus } from '../colonies/colony-status';
import {
HiveConflictException,
hiveExists,
hiveNotFound,
invalidDay,
} from './hive-errors';
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 { validateHiveState } from './hive-validate';
export interface HiveSetupView {
colonyId: string;
systemId: string;
version: number;
state: HiveState;
facts: HiveFacts;
updatedAt: string;
}
export interface HiveChangeListItem {
id: string;
version: number;
occurredOn: string;
source: HiveChangeSource;
inspectionId: string | null;
userId: string;
userName: string;
ops: unknown;
note: string | null;
createdAt: string;
}
export interface ApplyChangeInput {
occurredOn: string;
source: HiveChangeSource;
ops: HiveOp[];
note?: string;
inspectionId?: string;
}
export interface CreateSetupInput {
systemId: string;
state: unknown;
occurredOn: string;
}
const DEFAULT_HISTORY_LIMIT = 20;
const MAX_HISTORY_LIMIT = 100;
interface HistoryCursor {
occurredOn: string;
version: number;
}
const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
function parseDay(day: unknown, field: string): Date {
if (typeof day !== 'string') throw invalidDay(field);
try {
return dayToDate(day);
} catch {
throw invalidDay(field);
}
}
/** A day (YYYY-MM-DD) that is valid and not after today in Berlin. */
function parseOccurredOn(day: unknown): Date {
const date = parseDay(day, 'occurredOn');
if (isoDay(date) > berlinToday()) {
throw new BadRequestException('occurredOn must not be in the future');
}
return date;
}
function encodeCursor(cursor: HistoryCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}
function decodeCursor(raw: string): HistoryCursor {
try {
const value: unknown = JSON.parse(
Buffer.from(raw, 'base64url').toString('utf8'),
);
const { occurredOn, version } = value as Partial<HistoryCursor>;
if (
typeof occurredOn === 'string' &&
typeof version === 'number' &&
Number.isInteger(version)
) {
dayToDate(occurredOn);
return { occurredOn, version };
}
} catch {
// falls through to the 400 below
}
throw new BadRequestException('cursor is not valid');
}
/** INIT and AI_SUGGESTION are not accepted here; an inspection id goes with INSPECTION only. */
function assertSourceFits(input: ApplyChangeInput): void {
if (input.source === 'INIT' || input.source === 'AI_SUGGESTION') {
throw new BadRequestException(`source ${input.source} is not allowed`);
}
if ((input.source === 'INSPECTION') !== (input.inspectionId !== undefined)) {
throw new BadRequestException(
'inspectionId is required for, and only allowed with, source INSPECTION',
);
}
}
@Injectable()
export class HiveService {
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
private readonly catalogs: HiveCatalogService,
) {}
async get(colonyId: string): Promise<HiveSetupView> {
const setup = await this.prisma.hiveSetup.findUnique({
where: { colonyId },
});
if (!setup) throw hiveNotFound();
return this.view(setup);
}
async create(
colonyId: string,
input: CreateSetupInput,
userId: string,
): Promise<HiveSetupView> {
const occurredOn = parseOccurredOn(input.occurredOn);
return this.prisma.$transaction(async (tx) => {
await this.lockLivingColony(tx, colonyId);
await lockRowForUpdate(tx, 'HiveSetup', colonyId);
const existing = await tx.hiveSetup.findUnique({ where: { colonyId } });
if (existing) throw hiveExists();
const catalog = await this.loadActiveSystemCatalog(tx, input.systemId);
const messages = validateHiveState(input.state, catalog);
if (messages.length > 0) {
throw new BadRequestException({ code: 'STATE_INVALID', messages });
}
const state = input.state as HiveState;
const setup = await tx.hiveSetup.create({
data: {
colonyId,
systemId: input.systemId,
version: 1,
state: state as unknown as Prisma.InputJsonValue,
},
});
await tx.hiveChange.create({
data: {
colonyId,
version: 1,
occurredOn,
source: 'INIT',
userId,
ops: [],
stateAfter: state as unknown as Prisma.InputJsonValue,
note: null,
},
});
await this.recordChanged(tx, colonyId, 1, 'INIT', null, userId);
return {
colonyId,
systemId: setup.systemId,
version: setup.version,
state,
facts: deriveHiveFacts(state, catalog),
updatedAt: setup.updatedAt.toISOString(),
};
});
}
/**
* Applies ops to the current state under the Colony and HiveSetup locks and
* writes the change plus the event in the caller's transaction (also used
* by the inspection flow).
*/
async applyChange(
tx: Prisma.TransactionClient,
colonyId: string,
input: ApplyChangeInput,
userId: string,
): Promise<HiveSetupView> {
assertSourceFits(input);
const occurredOn = parseOccurredOn(input.occurredOn);
const ops = input.ops.map(canonicalOp);
await this.lockLivingColony(tx, colonyId);
if (!(await lockRowForUpdate(tx, 'HiveSetup', colonyId))) {
throw hiveNotFound();
}
const setup = await tx.hiveSetup.findUnique({ where: { colonyId } });
if (!setup) throw hiveNotFound();
await this.assertNotBeforeInit(tx, colonyId, occurredOn);
const catalog = await this.catalogs.loadCatalog(setup.systemId, tx);
const current = setup.state as unknown as HiveState;
const result = applyHiveOps(current, ops, catalog);
if (!result.ok) {
throw new HiveConflictException(result.opIndex, result.reason, {
version: setup.version,
state: current,
});
}
const version = setup.version + 1;
const stateJson = result.state as unknown as Prisma.InputJsonValue;
await tx.hiveChange.create({
data: {
colonyId,
version,
occurredOn,
source: input.source,
inspectionId: input.inspectionId ?? null,
userId,
ops: ops as unknown as Prisma.InputJsonValue,
stateAfter: stateJson,
note: input.note ?? null,
},
});
const updated = await tx.hiveSetup.update({
where: { colonyId },
data: { version, state: stateJson },
});
await this.recordChanged(
tx,
colonyId,
version,
input.source,
input.inspectionId ?? null,
userId,
);
return {
colonyId,
systemId: updated.systemId,
version,
state: result.state,
facts: deriveHiveFacts(result.state, catalog),
updatedAt: updated.updatedAt.toISOString(),
};
}
async history(
colonyId: string,
cursor?: string,
limit = DEFAULT_HISTORY_LIMIT,
): Promise<{ items: HiveChangeListItem[]; nextCursor: string | null }> {
const take = Math.min(Math.max(Math.trunc(limit), 1), MAX_HISTORY_LIMIT);
const after = cursor === undefined ? null : decodeCursor(cursor);
await this.assertSetupExists(colonyId);
const rows = await this.prisma.hiveChange.findMany({
where: {
colonyId,
...(after
? {
OR: [
{ occurredOn: { lt: dayToDate(after.occurredOn) } },
{
occurredOn: dayToDate(after.occurredOn),
version: { lt: after.version },
},
],
}
: {}),
},
orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }],
take: take + 1,
select: {
id: true,
version: true,
occurredOn: true,
source: true,
inspectionId: true,
userId: true,
user: { select: { name: true } },
ops: true,
note: true,
createdAt: true,
},
});
const page = rows.slice(0, take);
const last = page[page.length - 1];
const nextCursor =
rows.length > take && last
? encodeCursor({
occurredOn: isoDay(last.occurredOn),
version: last.version,
})
: null;
return {
items: page.map((row) => ({
id: row.id,
version: row.version,
occurredOn: isoDay(row.occurredOn),
source: row.source,
inspectionId: row.inspectionId,
userId: row.userId,
userName: row.user.name,
ops: row.ops,
note: row.note,
createdAt: row.createdAt.toISOString(),
})),
nextCursor,
};
}
/** The state at the end of a Berlin calendar day (spec 6.2). */
async at(
colonyId: string,
day: string,
): Promise<{
version: number;
state: HiveState;
facts: HiveFacts;
occurredOn: string;
}> {
const dayDate = parseDay(day, 'day');
const setup = await this.prisma.hiveSetup.findUnique({
where: { colonyId },
select: { systemId: true },
});
if (!setup) throw hiveNotFound();
const change = await this.prisma.hiveChange.findFirst({
where: { colonyId, occurredOn: { lte: dayDate } },
orderBy: { version: 'desc' },
select: { version: true, occurredOn: true, stateAfter: true },
});
if (!change) throw hiveNotFound();
const catalog = await this.catalogs.loadCatalog(setup.systemId);
const state = change.stateAfter as unknown as HiveState;
return {
version: change.version,
state,
facts: deriveHiveFacts(state, catalog),
occurredOn: isoDay(change.occurredOn),
};
}
private async view(setup: {
colonyId: string;
systemId: string;
version: number;
state: Prisma.JsonValue;
updatedAt: Date;
}): Promise<HiveSetupView> {
const catalog = await this.catalogs.loadCatalog(setup.systemId);
const state = setup.state as unknown as HiveState;
return {
colonyId: setup.colonyId,
systemId: setup.systemId,
version: setup.version,
state,
facts: deriveHiveFacts(state, catalog),
updatedAt: setup.updatedAt.toISOString(),
};
}
/** Lock order: Colony first; a colony that no longer lives is read-only. */
private async lockLivingColony(
tx: Prisma.TransactionClient,
colonyId: string,
): Promise<void> {
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
throw new NotFoundException('Colony not found');
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
if (!colony) throw new NotFoundException('Colony not found');
if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException(
`Das Volk besteht nicht mehr (Status: ${colony.status})`,
);
}
}
private async loadActiveSystemCatalog(
tx: Prisma.TransactionClient,
systemId: string,
) {
const system = await tx.hiveSystem.findUnique({
where: { id: systemId },
select: { id: true, active: true },
});
if (!system) throw new NotFoundException('Hive system not found');
if (!system.active) {
throw new BadRequestException({
code: 'SYSTEM_INACTIVE',
message: 'Das Beutensystem ist ausgeblendet.',
});
}
return this.catalogs.loadCatalog(systemId, tx);
}
private async assertNotBeforeInit(
tx: Prisma.TransactionClient,
colonyId: string,
occurredOn: Date,
): Promise<void> {
const init = await tx.hiveChange.findUnique({
where: { colonyId_version: { colonyId, version: 1 } },
select: { occurredOn: true },
});
if (init && occurredOn < init.occurredOn) {
throw new BadRequestException(
'occurredOn must not be before the day the hive setup was created',
);
}
}
private async assertSetupExists(colonyId: string): Promise<void> {
const setup = await this.prisma.hiveSetup.findUnique({
where: { colonyId },
select: { colonyId: true },
});
if (!setup) throw hiveNotFound();
}
private recordChanged(
tx: Prisma.TransactionClient,
colonyId: string,
version: number,
source: HiveChangeSource,
inspectionId: string | null,
userId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'HiveChanged',
aggregateType: 'Colony',
aggregateId: colonyId,
actorUserId: userId,
payload: { colonyId, version, source, inspectionId },
});
}
}
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsBoolean,
@@ -13,15 +14,28 @@ import {
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
import {
COLONY_TAKEOVER_FIELDS,
type ColonyTakeoverField,
} from '../../colonies/colony-takeover';
import { Transform } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import { HiveOpDto, MAX_OPS_PER_REQUEST } from '../../hive/dto/hive-op.dto';
import { ColonyCondition } from '../../../generated/prisma';
import { feedConditionFromInput } from '../../common/feed-vocabulary';
/** Hive operations carried by an inspection (create only). */
export class InspectionHiveChangeDto {
@ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(MAX_OPS_PER_REQUEST)
@ValidateNested({ each: true })
@Type(() => HiveOpDto)
ops!: HiveOpDto[];
}
export class CreateInspectionDto {
@ApiProperty()
@IsString()
@@ -172,4 +186,14 @@ export class CreateInspectionDto {
@ArrayMaxSize(3)
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
applyToColony?: ColonyTakeoverField[];
@ApiPropertyOptional({
type: InspectionHiveChangeDto,
description:
'Hive operations saved together with the inspection in one transaction. Needs colonies:update.',
})
@IsOptional()
@ValidateNested()
@Type(() => InspectionHiveChangeDto)
hiveChange?: InspectionHiveChangeDto;
}
@@ -8,6 +8,6 @@ import { CreateInspectionDto } from './create-inspection.dto';
* not part of a correction: a correction takes nothing over to the colony.
*/
export class UpdateInspectionDto extends PartialType(
OmitType(CreateInspectionDto, ['applyToColony'] as const),
OmitType(CreateInspectionDto, ['applyToColony', 'hiveChange'] as const),
{ skipNullProperties: false },
) {}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TasksModule } from '../tasks/tasks.module';
import { ColoniesModule } from '../colonies/colonies.module';
import { HiveModule } from '../hive/hive.module';
import { InspectionsController } from './inspections.controller';
import { ColonyInspectionsController } from './colony-inspections.controller';
import { InspectionsService } from './inspections.service';
@@ -11,7 +12,7 @@ import { InspectionsService } from './inspections.service';
* PermissionResolverService comes from the global resolver module.
*/
@Module({
imports: [TasksModule, ColoniesModule],
imports: [TasksModule, ColoniesModule, HiveModule],
controllers: [InspectionsController, ColonyInspectionsController],
providers: [InspectionsService],
exports: [InspectionsService],
@@ -6,6 +6,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service';
import { ColoniesService } from '../colonies/colonies.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { HiveService } from '../hive/hive.service';
import { Prisma } from '../../generated/prisma';
describe('InspectionsService', () => {
@@ -35,6 +36,7 @@ describe('InspectionsService', () => {
let tasks: { createInTx: jest.Mock };
let colonies: { updateIn: jest.Mock };
let resolver: { getPermissions: jest.Mock };
let hive: { applyChange: jest.Mock };
const inspection = {
id: 'inspection-1',
@@ -74,6 +76,7 @@ describe('InspectionsService', () => {
resolver = {
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
};
hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) };
service = new InspectionsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
@@ -81,6 +84,7 @@ describe('InspectionsService', () => {
tasks as unknown as TasksService,
colonies as unknown as ColoniesService,
resolver as unknown as PermissionResolverService,
hive as unknown as HiveService,
);
});
@@ -94,6 +98,7 @@ describe('InspectionsService', () => {
TasksService,
ColoniesService,
PermissionResolverService,
HiveService,
]);
});
@@ -314,6 +319,81 @@ describe('InspectionsService', () => {
).rejects.toThrow('outbox down');
});
describe('hiveChange', () => {
const ops = [{ op: 'setLabel', id: 'box', label: 'x' }];
const dto = {
colonyId: 'colony-1',
date: '2026-03-10T10:00:00.000Z',
hiveChange: { ops },
} as never;
beforeEach(() => {
tx.inspection.create.mockResolvedValue(inspection);
});
it('locks the colony, saves the inspection, then applies the ops with the inspection id', async () => {
const result = await service.create(dto, 'user-1');
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(
tx.inspection.create.mock.invocationCallOrder[0],
);
const [[created]] = tx.inspection.create.mock.calls as [
[{ data: object }],
];
expect(created.data).not.toHaveProperty('hiveChange');
expect(hive.applyChange).toHaveBeenCalledWith(
tx,
'colony-1',
{
occurredOn: '2026-03-10',
source: 'INSPECTION',
ops,
inspectionId: 'inspection-1',
},
'user-1',
);
expect(result).toMatchObject({
id: 'inspection-1',
hive: { version: 2 },
});
});
it('answers 403 HIVE_FORBIDDEN without colonies:update and writes nothing', async () => {
resolver.getPermissions.mockResolvedValue(new Set());
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
status: 403,
response: { code: 'HIVE_FORBIDDEN' },
});
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('resolves permissions once when hiveChange and applyToColony are both used', async () => {
await service.create(
{ ...(dto as object), applyToColony: ['feedStatus'] } as never,
'user-1',
);
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
});
it('propagates an applyChange failure so the transaction aborts', async () => {
hive.applyChange.mockRejectedValue(new NotFoundException());
await expect(service.create(dto, 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('omits hive from the response without hiveChange', async () => {
const result = await service.create(
{ colonyId: 'colony-1', date: '2026-01-01' },
'user-1',
);
expect(result).not.toHaveProperty('hive');
expect(hive.applyChange).not.toHaveBeenCalled();
});
});
it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => {
tx.inspection.findUnique.mockResolvedValue(inspection);
// Re-filed against another colony: the event names the colony it is
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
@@ -18,9 +22,14 @@ import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { optionalDate } from '../common/patch-values';
import { berlinDayOf } from '../common/dates';
import type { HiveOp } from '../hive/hive-types';
import { HiveService, HiveSetupView } from '../hive/hive.service';
export type InspectionWithTakeover = Inspection & {
colonyTakeover: ColonyTakeover;
/** Present only when the request carried a hiveChange. */
hive?: HiveSetupView;
};
/**
@@ -41,6 +50,7 @@ export class InspectionsService {
private readonly tasks: TasksService,
private readonly colonies: ColoniesService,
private readonly permissionResolver: PermissionResolverService,
private readonly hiveService: HiveService,
) {}
async findAll(): Promise<Inspection[]> {
@@ -62,16 +72,25 @@ export class InspectionsService {
dto: CreateInspectionDto,
actingUserId: string,
): Promise<InspectionWithTakeover> {
const { applyToColony = [], ...data } = dto;
// Resolved before the transaction (it may query the database): the
// permission is only needed when a takeover was requested.
const { applyToColony = [], hiveChange, ...data } = dto;
// Resolved before the transaction (it may query the database), once for
// both features: a takeover only applies with colonies:update, a hive
// change is refused without it.
const needsPermission =
applyToColony.length > 0 || hiveChange !== undefined;
const canUpdateColony =
applyToColony.length > 0 &&
needsPermission &&
(await this.permissionResolver.getPermissions(actingUserId)).has(
'colonies:update',
);
if (hiveChange && !canUpdateColony) {
throw new ForbiddenException({
code: 'HIVE_FORBIDDEN',
message: 'Beute-Änderungen brauchen das Recht colonies:update.',
});
}
return this.prisma.$transaction(async (tx) => {
if (applyToColony.length > 0) {
if (applyToColony.length > 0 || hiveChange) {
// Colony first (before the inspection row takes a key-share lock on
// it): serializes concurrent inspections of one colony, so "is there
// a newer inspection" below is decided on a stable state.
@@ -104,7 +123,22 @@ export class InspectionsService {
canUpdateColony,
actingUserId,
);
return { ...inspection, colonyTakeover };
if (!hiveChange) {
return { ...inspection, colonyTakeover };
}
const hive = await this.hiveService.applyChange(
tx,
dto.colonyId,
{
occurredOn: berlinDayOf(new Date(dto.date)),
source: 'INSPECTION',
// Plain objects only: the DTO instances carry class metadata.
ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[],
inspectionId: inspection.id,
},
actingUserId,
);
return { ...inspection, colonyTakeover, hive };
});
}
+7 -2
View File
@@ -226,10 +226,12 @@ describe('lock keys per table', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
it('keeps the map in lock order: Apiary before Colony, HiveSetup right after Colony, HivePartType after HiveSetup, the T6 tables last', () => {
expect(LOCK_ORDER).toEqual([
'Apiary',
'Colony',
'HiveSetup',
'HivePartType',
'Suggestion',
'Task',
'Queen',
@@ -262,7 +264,10 @@ describe('lock keys per table', () => {
it('every table of the map is lockable by id and each appears once', async () => {
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
const keyed = new Map([['NotificationPreference', 'userId']]);
const keyed = new Map([
['NotificationPreference', 'userId'],
['HiveSetup', 'colonyId'],
]);
for (const table of LOCK_ORDER) {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
+5
View File
@@ -19,6 +19,11 @@ function lockable(table: string, key = 'id') {
const LOCKABLE_TABLES = {
Apiary: lockable('Apiary'),
Colony: lockable('Colony'),
// Beute in 3D: one setup row per colony, keyed by the colony. Locked right
// after the colony row it belongs to.
HiveSetup: lockable('HiveSetup', 'colonyId'),
// Catalog part types: locked while their stateSchema is changed.
HivePartType: lockable('HivePartType'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
Queen: lockable('Queen'),
@@ -87,12 +87,16 @@ describe('WebhookEventHandlers', () => {
});
});
it('none of the four new events is forwarded to webhooks', async () => {
it('none of the five new events is forwarded to webhooks', async () => {
const herdEvents = [
['ColonyCreated', { apiaryId: 'a1' }],
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
[
'HiveChanged',
{ colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null },
],
] as const;
for (const [type, payload] of herdEvents) {
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);