feat: allow correcting honey measurements and queen evaluations
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s

Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating 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:15:54 +02:00
1 parent b0e05daa42
commit 42ceb33844
14 files changed
+503 -15

No files matched your search

@@ -0,0 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
/**
* A water-content reading typed in wrong has to be correctable: it decides
* whether a batch may be sold as honey at all, so a wrong figure left standing
* is worse than one that was never recorded.
*/
export class UpdateHoneyMeasurementDto extends PartialType(
CreateHoneyMeasurementDto,
) {}
@@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { HoneyBatchesService } from './honey-batches.service';
@@ -117,6 +118,20 @@ export class HoneyBatchesController {
}
@RequirePermission('honey-batches:measure')
@RequirePermission('honey-batches:measure')
@Patch('measurements/:measurementId')
updateMeasurement(
@Param('measurementId') measurementId: string,
@Body() dto: UpdateHoneyMeasurementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.updateMeasurement(
measurementId,
dto,
actor.id,
);
}
@Post(':id/measurements')
addMeasurement(
@Param('id') id: string,
@@ -14,7 +14,11 @@ describe('HoneyBatchesService', () => {
create: jest.Mock;
update: jest.Mock;
};
honeyMeasurement: { create: jest.Mock };
honeyMeasurement: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
honeyBatchContainerAllocation: {
@@ -60,7 +64,11 @@ describe('HoneyBatchesService', () => {
create: jest.fn(),
update: jest.fn(),
},
honeyMeasurement: { create: jest.fn() },
honeyMeasurement: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
honeyBatchContainerAllocation: {
@@ -398,4 +406,41 @@ describe('HoneyBatchesService', () => {
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
});
});
it('updateMeasurement corrects a reading and logs before/after', async () => {
const before = {
id: 'measurement-1',
honeyBatchId: 'batch-1',
waterContent: 22,
};
prisma.honeyMeasurement.findUnique.mockResolvedValue(before);
prisma.honeyMeasurement.update.mockResolvedValue({
...before,
waterContent: 17.5,
});
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
await service.updateMeasurement(
'measurement-1',
{ waterContent: 17.5 },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyMeasurement',
entityId: 'measurement-1',
action: 'UPDATE',
beforeData: before,
}),
);
});
it('updateMeasurement throws NotFoundException for an unknown reading', async () => {
prisma.honeyMeasurement.findUnique.mockResolvedValue(null);
await expect(
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -11,6 +11,7 @@ import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
@@ -122,6 +123,35 @@ export class HoneyBatchesService {
return updated;
}
async updateMeasurement(
measurementId: string,
dto: UpdateHoneyMeasurementDto,
actingUserId: string,
) {
const before = await this.prisma.honeyMeasurement.findUnique({
where: { id: measurementId },
});
if (!before) {
throw new NotFoundException('Honey measurement not found');
}
const measurement = await this.prisma.honeyMeasurement.update({
where: { id: measurementId },
data: {
...dto,
measuredAt: dto.measuredAt ? new Date(dto.measuredAt) : undefined,
},
});
await this.auditLog.record({
entityType: 'HoneyMeasurement',
entityId: measurementId,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: measurement,
});
return this.findById(before.honeyBatchId);
}
async addMeasurement(
id: string,
dto: CreateHoneyMeasurementDto,
@@ -0,0 +1,10 @@
import { PartialType } from '@nestjs/swagger';
import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
/**
* Breeding decisions are made from these scores, so a rating entered against
* the wrong queen or with a slipped digit has to be correctable.
*/
export class UpdateQueenEvaluationDto extends PartialType(
CreateQueenEvaluationDto,
) {}
@@ -16,6 +16,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto';
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
import { QueensService } from './queens.service';
@ApiTags('queens')
@@ -81,6 +82,16 @@ export class QueensController {
}
@RequirePermission('queens:evaluate')
@RequirePermission('queens:evaluate')
@Patch('evaluations/:evaluationId')
updateEvaluation(
@Param('evaluationId') evaluationId: string,
@Body() dto: UpdateQueenEvaluationDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.queensService.updateEvaluation(evaluationId, dto, actor.id);
}
@Post(':id/evaluations')
addEvaluation(
@Param('id') id: string,
@@ -13,6 +13,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto';
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
export interface QueenFilter {
status?: QueenStatus;
@@ -227,6 +228,32 @@ export class QueensService {
});
}
async updateEvaluation(
evaluationId: string,
dto: UpdateQueenEvaluationDto,
actingUserId: string,
) {
const before = await this.prisma.queenEvaluation.findUnique({
where: { id: evaluationId },
});
if (!before) {
throw new NotFoundException('Queen evaluation not found');
}
const evaluation = await this.prisma.queenEvaluation.update({
where: { id: evaluationId },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'QueenEvaluation',
entityId: evaluationId,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: evaluation,
});
return evaluation;
}
async addEvaluation(
id: string,
dto: CreateQueenEvaluationDto,
@@ -0,0 +1,175 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyMeasurement } from "@/lib/honey-batches/api";
import { HoneyMeasurement } from "@/lib/honey-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Corrects a water-content reading. The figure decides whether a batch may be
* sold as honey at all, so a slipped digit left standing is worse than a
* reading that was never recorded.
*/
function EditMeasurementForm({
measurement,
honeyBatchId,
onSaved,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [waterContent, setWaterContent] = useState(
measurement.waterContent.toString(),
);
const [measuredAt, setMeasuredAt] = useState(
measurement.measuredAt.slice(0, 10),
);
const [device, setDevice] = useState(measurement.device ?? "");
const [temperature, setTemperature] = useState(
measurement.temperature?.toString() ?? "",
);
const [notes, setNotes] = useState(measurement.notes ?? "");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateHoneyMeasurement(measurement.id, {
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatchId],
});
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Messung konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-water">Wassergehalt (%)</Label>
<Input
id="measurement-edit-water"
type="number"
step="0.1"
min={0}
max={100}
required
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-date">Gemessen am</Label>
<Input
id="measurement-edit-date"
type="date"
required
value={measuredAt}
onChange={(event) => setMeasuredAt(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-device">Gerät</Label>
<Input
id="measurement-edit-device"
value={device}
onChange={(event) => setDevice(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-temp">Temperatur (°C)</Label>
<Input
id="measurement-edit-temp"
type="number"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-notes">Notizen</Label>
<Textarea
id="measurement-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
export function EditMeasurementDialog({
measurement,
honeyBatchId,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Messung bearbeiten</DialogTitle>
</DialogHeader>
{open ? (
<EditMeasurementForm
measurement={measurement}
honeyBatchId={honeyBatchId}
onSaved={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
import { EditMeasurementDialog } from "./edit-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
@@ -106,13 +107,21 @@ export default function HoneyBatchDetailPage() {
) : null}
{honeyBatch.measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? (
<p className="text-muted-foreground">{measurement.device}</p>
) : null}
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
{measurement.waterContent}% Wassergehalt
</p>
{measurement.device ? (
<p className="text-muted-foreground">{measurement.device}</p>
) : null}
</div>
<EditMeasurementDialog
measurement={measurement}
honeyBatchId={honeyBatch.id}
/>
</div>
</div>
))}
</CardContent>
@@ -1,10 +1,14 @@
"use client";
import { FormEvent, useState } from "react";
import { Star } from "lucide-react";
import { Pencil, Star } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { addQueenEvaluation, fetchQueenEvaluations } from "@/lib/queens/api";
import { CreateQueenEvaluationInput } from "@/lib/queens/types";
import {
addQueenEvaluation,
fetchQueenEvaluations,
updateQueenEvaluation,
} from "@/lib/queens/api";
import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -32,6 +36,122 @@ function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Corrects a rating after the fact. Breeding decisions are made from these
* scores, so a slipped digit has to be fixable rather than left standing.
*/
function EditEvaluationDialog({
evaluation,
queenId,
}: {
evaluation: QueenEvaluation;
queenId: string;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(evaluation.date.slice(0, 10));
const [scores, setScores] = useState<Record<string, string>>(
Object.fromEntries(
CRITERIA.map((criterion) => [
criterion.key,
String(evaluation[criterion.key]),
]),
),
);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateQueenEvaluation(evaluation.id, {
date: new Date(date).toISOString(),
colonyStrength: Number(scores.colonyStrength),
gentleness: Number(scores.gentleness),
combBuild: Number(scores.combBuild),
swarmingTendency: Number(scores.swarmingTendency),
broodPattern: Number(scores.broodPattern),
honeyYield: Number(scores.honeyYield),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["queens", queenId, "evaluations"],
});
setOpen(false);
setError(null);
},
onError: (mutationError: Error) =>
setError(
mutationError.message || "Bewertung konnte nicht gespeichert werden.",
),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Bewertung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-date-${evaluation.id}`}>Datum</Label>
<Input
id={`evaluation-edit-date-${evaluation.id}`}
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
{CRITERIA.map((criterion) => (
<div key={criterion.key} className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-${criterion.key}-${evaluation.id}`}>
{criterion.label} (1–5)
</Label>
<Input
id={`evaluation-edit-${criterion.key}-${evaluation.id}`}
type="number"
min={1}
max={5}
required
value={scores[criterion.key]}
onChange={(event) =>
setScores((prev) => ({
...prev,
[criterion.key]: event.target.value,
}))
}
/>
</div>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -141,9 +261,12 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
) : null}
{evaluations?.map((evaluation) => (
<div key={evaluation.id} className="rounded-md border p-3">
<p className="font-medium">
{new Date(evaluation.date).toLocaleDateString("de-DE")}
</p>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
{new Date(evaluation.date).toLocaleDateString("de-DE")}
</p>
<EditEvaluationDialog evaluation={evaluation} queenId={queenId} />
</div>
<p className="text-muted-foreground">
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import {
CreateHoneyBatchInput,
CreateHoneyMeasurementInput,
UpdateHoneyMeasurementInput,
HoneyBatch,
HoneyBatchContainerAllocation,
HoneyBatchRelocation,
@@ -47,6 +48,16 @@ export function updateHoneyBatch(
});
}
export function updateHoneyMeasurement(
measurementId: string,
input: UpdateHoneyMeasurementInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/measurements/${measurementId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addHoneyMeasurement(
id: string,
input: CreateHoneyMeasurementInput,
@@ -107,3 +107,11 @@ export interface CreateHoneyMeasurementInput {
temperature?: number;
notes?: string;
}
export interface UpdateHoneyMeasurementInput {
waterContent?: number;
measuredAt?: string;
device?: string;
temperature?: number;
notes?: string;
}
+11
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "@/lib/auth/api-client";
import {
AssignColonyInput,
CreateQueenEvaluationInput,
UpdateQueenEvaluationInput,
CreateQueenInput,
Queen,
QueenColonyHistoryEntry,
@@ -59,6 +60,16 @@ export function fetchQueenEvaluations(id: string): Promise<QueenEvaluation[]> {
return apiFetch<QueenEvaluation[]>(`/queens/${id}/evaluations`);
}
export function updateQueenEvaluation(
evaluationId: string,
input: UpdateQueenEvaluationInput,
): Promise<QueenEvaluation> {
return apiFetch<QueenEvaluation>(`/queens/evaluations/${evaluationId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addQueenEvaluation(
id: string,
input: CreateQueenEvaluationInput,
+2
View File
@@ -124,3 +124,5 @@ export interface CreateQueenEvaluationInput {
honeyYield: number;
notes?: string;
}
export type UpdateQueenEvaluationInput = Partial<CreateQueenEvaluationInput>;