feat: make the parent relation of every record correctable after entry

An inspection, treatment, feeding, varroa measurement or swarm control check
filed against the wrong colony could not be repaired: the colony was frozen at
creation time, and with no delete endpoint either, the only remedy was leaving
a wrong record in the colony's history. The same held for a harvest booked
against the wrong apiary or with a colony forgotten, for an extraction linked
to the wrong harvests, and for a honey batch whose number or source extraction
was mistyped.

Backend:
- The five colony-scoped update DTOs now cover the full create DTO, colonyId
  included.
- Harvest update accepts apiaryId and colonyIds, extraction update accepts
  harvestIds. Both validate the ids first and replace the whole join-row set
  inside the existing transaction; the join rows carry no data of their own,
  so replacing beats diffing.
- Honey batch update accepts batchNumber, extractionId and status. Status was
  previously only ever set to BOTTLED by the bottling module, so SOLD and
  CONSUMED were unreachable through the API.
- Colony and storage container codes, and a task's relatedType/relatedId, are
  editable.
- apiaryId on a colony and storageContainerId on a honey batch stay out of
  PATCH on purpose: both have dedicated endpoints that also write the history
  entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain
  depends on.
- AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a
  404. Services write straight against the foreign keys with no application
  level existence check, so before this a stale or mistyped relation id came
  back as a bare 500 and filled the log with fake server errors.

Frontend:
- New shared fields: ColonySelectField (used by all five colony-scoped edit
  dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField.
- Re-filing a record invalidates the lists of both the colony it left and the
  one it joined, otherwise one of the two stays stale.
- The harvest dialog gained an apiary select and a colony checklist that keeps
  already-linked colonies visible even when they moved or were dissolved; the
  honey batch dialog gained batch number, source extraction and status.
- Extraction and harvest forms refuse to submit an empty relation set instead
  of letting the backend reject it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 08:01:05 +02:00
1 parent 2b15795a60
commit bee2199647
44 files changed
+958 -156

No files matched your search

@@ -3,8 +3,15 @@ import { IsEnum, IsOptional } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto';
/**
* `apiaryId` is deliberately absent: moving a colony to another apiary runs
* through POST /colonies/:id/move, which also writes the ColonyApiaryHistory
* entry the traceability chain depends on. A plain PATCH would move the
* colony without that record.
*/
class UpdatableColonyFieldsDto extends PartialType(
PickType(CreateColonyDto, [
'code',
'hiveType',
'originText',
'colonyType',
@@ -123,4 +123,57 @@ describe('AllExceptionsFilter', () => {
expect(warnSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('translates a P2003 foreign-key error to a 400 naming the broken relation', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Foreign key constraint failed on the field: `colonyId`',
{
code: 'P2003',
clientVersion: '6.0.0',
meta: { field_name: 'colonyId' },
},
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 400,
message:
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen',
});
});
it('translates a P2025 missing-record error to a 404', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'An operation failed because it depends on one or more records that were required but not found.',
{ code: 'P2025', clientVersion: '6.0.0' },
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(404);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 404,
message: 'Datensatz nicht gefunden',
});
});
it('leaves an unmapped Prisma error as a generic 500, leaking nothing', () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Timed out fetching a new connection from the connection pool',
{ code: 'P2024', clientVersion: '6.0.0' },
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 500,
message: 'Internal server error',
});
});
});
@@ -34,6 +34,45 @@ function duplicateFieldMessage(target: unknown): string {
: 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen';
}
/**
* A relation pointed at a record that does not exist (Prisma P2003). Every
* module lets a record be re-pointed at a different parent - an inspection
* filed against the wrong colony, a harvest against the wrong apiary - and
* relies on the foreign key rather than an application-level existence check,
* so a stale or mistyped id has to come back as a clean 400.
*/
const FOREIGN_KEY_MESSAGE =
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen';
/**
* Translates the Prisma errors that are really user input problems into the
* status and message they deserve. Anything not listed here stays a 500 with
* a generic body, so no internal detail leaks.
*/
function prismaErrorResponse(
exception: unknown,
): { status: number; message: string } | null {
if (!(exception instanceof Prisma.PrismaClientKnownRequestError)) {
return null;
}
switch (exception.code) {
case 'P2002':
return {
status: HttpStatus.CONFLICT,
message: duplicateFieldMessage(exception.meta?.target),
};
case 'P2003':
return { status: HttpStatus.BAD_REQUEST, message: FOREIGN_KEY_MESSAGE };
case 'P2025':
return {
status: HttpStatus.NOT_FOUND,
message: 'Datensatz nicht gefunden',
};
default:
return null;
}
}
/**
* Replaces Nest's default exception handling so EVERY error - not just
* unhandled 5xx ones - shows up in stdout/kubectl logs. Nest's built-in
@@ -46,12 +85,13 @@ function duplicateFieldMessage(target: unknown): string {
* Client-facing behaviour is unchanged: HttpExceptions still return
* exactly the body/status Nest would have produced. Non-HttpException
* errors still collapse to a generic 500 body - the real message and
* stack go to the log, never to the client. The one exception is Prisma's
* P2002 (unique constraint violation): services create/update records
* straight off a DB `@unique` column (colony/apiary/queen codes, SKUs,
* batch numbers) with no application-level duplicate check, relying on
* the constraint itself - so without this branch, a beekeeper reusing a
* code by mistake saw a bare 500 "Internal server error" instead of a
* stack go to the log, never to the client. The exceptions are the Prisma
* errors listed in prismaErrorResponse: services write records straight
* against DB constraints (`@unique` columns like colony/apiary/queen codes,
* SKUs and batch numbers; foreign keys for every parent relation) with no
* application-level check, relying on the constraint itself - so without
* those branches, a beekeeper reusing a code or picking a record that was
* meanwhile removed saw a bare 500 "Internal server error" instead of a
* clean, specific message, and the log filled with fake server errors for
* what is really a user input problem.
*/
@@ -65,22 +105,16 @@ export class AllExceptionsFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException;
const isUniqueConstraintError =
exception instanceof Prisma.PrismaClientKnownRequestError &&
exception.code === 'P2002';
const prismaResponse = prismaErrorResponse(exception);
const status = isHttpException
? exception.getStatus()
: isUniqueConstraintError
? HttpStatus.CONFLICT
: HttpStatus.INTERNAL_SERVER_ERROR;
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
const body = isHttpException
? exception.getResponse()
: isUniqueConstraintError
? {
statusCode: status,
message: duplicateFieldMessage(exception.meta?.target),
}
: { statusCode: status, message: 'Internal server error' };
: {
statusCode: status,
message: prismaResponse?.message ?? 'Internal server error',
};
const message =
exception instanceof Error ? exception.message : 'Unknown error';
const context = `${request.method} ${request.originalUrl} -> ${status}`;
@@ -1,13 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateExtractionDto } from './create-extraction.dto';
export class UpdateExtractionDto extends PartialType(
PickType(CreateExtractionDto, [
'date',
'amount',
'room',
'waterContent',
'temperature',
'notes',
] as const),
) {}
/**
* Every field stays correctable, the set of harvests included: a Schleuderung
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the
* only way to repair the traceability chain without deleting the record.
*/
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {}
@@ -12,16 +12,19 @@ describe('ExtractionsService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
extraction: { create: jest.Mock };
extractionHarvest: { createMany: jest.Mock };
extraction: { create: jest.Mock; update: jest.Mock };
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
};
const extraction = { id: 'extraction-1', date: new Date() };
beforeEach(() => {
tx = {
extraction: { create: jest.fn().mockResolvedValue(extraction) },
extractionHarvest: { createMany: jest.fn() },
extraction: {
create: jest.fn().mockResolvedValue(extraction),
update: jest.fn(),
},
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
};
prisma = {
extraction: { findUnique: jest.fn(), update: jest.fn() },
@@ -82,7 +85,6 @@ describe('ExtractionsService', () => {
prisma.extraction.findUnique
.mockResolvedValueOnce(extraction)
.mockResolvedValueOnce(updated);
prisma.extraction.update.mockResolvedValue(updated);
const result = await service.update(
'extraction-1',
@@ -100,5 +102,41 @@ describe('ExtractionsService', () => {
afterData: updated,
}),
);
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
await service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
);
expect(tx.extractionHarvest.deleteMany).toHaveBeenCalledWith({
where: { extractionId: 'extraction-1' },
});
const [[createArg]] = tx.extractionHarvest.createMany.mock.calls as [
[{ data: { extractionId: string; harvestId: string }[] }],
];
expect(createArg.data).toEqual([
{ extractionId: 'extraction-1', harvestId: 'harvest-1' },
{ extractionId: 'extraction-1', harvestId: 'harvest-2' },
]);
});
it('update rejects an unknown harvest id before touching the database', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
await expect(
service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'missing-harvest'] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -89,10 +89,32 @@ export class ExtractionsService {
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.extraction.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
const { harvestIds, ...scalarFields } = dto;
if (harvestIds) {
await this.assertHarvestsExist(harvestIds);
}
await this.prisma.$transaction(async (tx) => {
await tx.extraction.update({
where: { id },
data: {
...scalarFields,
date: dto.date ? new Date(dto.date) : undefined,
},
});
// The join rows carry no data of their own, so replacing the whole set
// is both simpler and safer than diffing it.
if (harvestIds) {
await tx.extractionHarvest.deleteMany({ where: { extractionId: id } });
await tx.extractionHarvest.createMany({
data: harvestIds.map((harvestId) => ({
extractionId: id,
harvestId,
})),
});
}
});
const extraction = await this.findById(id);
await this.auditLog.record({
entityType: 'Extraction',
@@ -1,14 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateFeedingDto } from './create-feeding.dto';
export class UpdateFeedingDto extends PartialType(
PickType(CreateFeedingDto, [
'date',
'feedType',
'product',
'amount',
'unit',
'batchNumber',
'notes',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {}
@@ -1,13 +1,10 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateHarvestDto } from './create-harvest.dto';
export class UpdateHarvestDto extends PartialType(
PickType(CreateHarvestDto, [
'date',
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
] as const),
) {}
/**
* Every field stays correctable, the apiary and the set of colonies included:
* an Ernte booked against the wrong stand or with a colony forgotten is a
* data-entry error, and re-pointing it is the only way to repair the
* traceability chain without deleting the record.
*/
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {}
@@ -14,16 +14,19 @@ describe('HarvestsService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
harvest: { create: jest.Mock };
harvestColony: { createMany: jest.Mock };
harvest: { create: jest.Mock; update: jest.Mock };
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
};
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
beforeEach(() => {
tx = {
harvest: { create: jest.fn().mockResolvedValue(harvest) },
harvestColony: { createMany: jest.fn() },
harvest: {
create: jest.fn().mockResolvedValue(harvest),
update: jest.fn(),
},
harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() },
};
prisma = {
harvest: { findUnique: jest.fn(), update: jest.fn() },
@@ -96,7 +99,6 @@ describe('HarvestsService', () => {
prisma.harvest.findUnique
.mockResolvedValueOnce(harvest)
.mockResolvedValueOnce(updated);
prisma.harvest.update.mockResolvedValue(updated);
const result = await service.update(
'harvest-1',
@@ -105,6 +107,7 @@ describe('HarvestsService', () => {
);
expect(result).toEqual(updated);
expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Harvest',
@@ -115,4 +118,39 @@ describe('HarvestsService', () => {
}),
);
});
it('update replaces the whole colony set when colonyIds is given', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
await service.update(
'harvest-1',
{ colonyIds: ['colony-1', 'colony-2'] },
'user-1',
);
expect(tx.harvestColony.deleteMany).toHaveBeenCalledWith({
where: { harvestId: 'harvest-1' },
});
const [[createArg]] = tx.harvestColony.createMany.mock.calls as [
[{ data: { harvestId: string; colonyId: string }[] }],
];
expect(createArg.data).toEqual([
{ harvestId: 'harvest-1', colonyId: 'colony-1' },
{ harvestId: 'harvest-1', colonyId: 'colony-2' },
]);
});
it('update rejects an unknown colony id before touching the database', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
await expect(
service.update(
'harvest-1',
{ colonyIds: ['colony-1', 'missing-colony'] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -93,10 +93,29 @@ export class HarvestsService {
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.harvest.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
const { colonyIds, ...scalarFields } = dto;
if (colonyIds) {
await this.assertColoniesExist(colonyIds);
}
await this.prisma.$transaction(async (tx) => {
await tx.harvest.update({
where: { id },
data: {
...scalarFields,
date: dto.date ? new Date(dto.date) : undefined,
},
});
// The join rows carry no data of their own, so replacing the whole set
// is both simpler and safer than diffing it.
if (colonyIds) {
await tx.harvestColony.deleteMany({ where: { harvestId: id } });
await tx.harvestColony.createMany({
data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })),
});
}
});
const harvest = await this.findById(id);
await this.auditLog.record({
entityType: 'Harvest',
@@ -1,10 +1,32 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { HoneyBatchStatus } from '../../../generated/prisma';
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
export class UpdateHoneyBatchDto extends PartialType(
/**
* `storageContainerId` is deliberately absent: moving a batch to another
* container runs through POST /honey-batches/:id/relocate, which also writes
* the HoneyBatchRelocation entry. A plain PATCH would move the honey without
* that record.
*/
class UpdatableHoneyBatchFieldsDto extends PartialType(
PickType(CreateHoneyBatchDto, [
'batchNumber',
'extractionId',
'honeyType',
'amount',
'waterContent',
] as const),
) {}
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
/**
* Set automatically to BOTTLED when the batch is bottled, but a beekeeper
* still has to be able to record that a batch was sold or consumed, or to
* undo a status that was set by mistake.
*/
@ApiPropertyOptional({ enum: HoneyBatchStatus })
@IsOptional()
@IsEnum(HoneyBatchStatus)
status?: HoneyBatchStatus;
}
@@ -1,27 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateInspectionDto } from './create-inspection.dto';
export class UpdateInspectionDto extends PartialType(
PickType(CreateInspectionDto, [
'date',
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {}
@@ -3,6 +3,7 @@ import { CreateStorageContainerDto } from './create-storage-container.dto';
export class UpdateStorageContainerDto extends PartialType(
PickType(CreateStorageContainerDto, [
'code',
'containerType',
'capacity',
'location',
@@ -1,14 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateSwarmControlCheckDto extends PartialType(
PickType(CreateSwarmControlCheckDto, [
'date',
'queenCellsFound',
'queenCellsCapped',
'swarmMood',
'actionTaken',
'notes',
'nextCheckDate',
] as const),
CreateSwarmControlCheckDto,
) {}
@@ -1,6 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskPriority, TaskStatus } from '../../../generated/prisma';
import {
TaskPriority,
TaskRelatedType,
TaskStatus,
} from '../../../generated/prisma';
export class UpdateTaskDto {
@ApiPropertyOptional()
@@ -32,4 +36,14 @@ export class UpdateTaskDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@ApiPropertyOptional({ enum: TaskRelatedType })
@IsOptional()
@IsEnum(TaskRelatedType)
relatedType?: TaskRelatedType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
relatedId?: string;
}
@@ -1,18 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateTreatmentDto } from './create-treatment.dto';
export class UpdateTreatmentDto extends PartialType(
PickType(CreateTreatmentDto, [
'date',
'startDate',
'endDate',
'product',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'notes',
'withdrawalPeriodDays',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {}
@@ -1,14 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateVarroaMeasurementDto extends PartialType(
PickType(CreateVarroaMeasurementDto, [
'date',
'method',
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
] as const),
CreateVarroaMeasurementDto,
) {}