From d42f8331f9be042bfbeb1cbd26a61b0b8b88b80a Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Sun, 6 Sep 2026 02:50:34 +0200
Subject: [PATCH] feat: warn against harvesting honey during a treatment's
Wartezeit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Treatments (Ameisensäure, Oxalsäure, etc.) can require a legally
mandated withdrawal period before honey from that colony may be sold.
Nothing in the app tracked this before, so a beekeeper had no
safeguard against harvesting into an active withdrawal window.
Treatments now optionally record withdrawalPeriodDays (from the
product's Packungsbeilage). The new harvest form fetches each
candidate colony's active withdrawal window and shows an inline
warning naming the product and the date it clears, computed as
max(endDate ?? date) + withdrawalPeriodDays across a colony's
treatments, restricted to windows still in the future.
Co-Authored-By: Claude Sonnet 5
---
.../migration.sql | 2 +
ApiCore-Backend/prisma/schema.prisma | 73 ++++++++++---------
.../treatments/dto/create-treatment.dto.ts | 17 ++++-
.../treatments/dto/update-treatment.dto.ts | 1 +
.../src/treatments/treatments.controller.ts | 17 ++++-
.../src/treatments/treatments.service.spec.ts | 54 ++++++++++++++
.../src/treatments/treatments.service.ts | 44 +++++++++++
.../(app)/colonies/[id]/colony-treatments.tsx | 3 +
.../colonies/[id]/treatment-edit-dialog.tsx | 15 ++++
.../src/app/(app)/harvests/new/page.tsx | 55 +++++++++-----
.../src/app/(app)/treatments/new/page.tsx | 18 +++++
ApiCore-Frontend/src/lib/treatments/api.ts | 18 ++++-
ApiCore-Frontend/src/lib/treatments/types.ts | 9 +++
13 files changed, 270 insertions(+), 56 deletions(-)
create mode 100644 ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql
diff --git a/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql b/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql
new file mode 100644
index 0000000..8b3b0bf
--- /dev/null
+++ b/ApiCore-Backend/prisma/migrations/20260906120000_add_treatment_withdrawal_period/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma
index 5ad443f..4d56a5c 100644
--- a/ApiCore-Backend/prisma/schema.prisma
+++ b/ApiCore-Backend/prisma/schema.prisma
@@ -569,22 +569,23 @@ model SwarmControlCheck {
// ---------------------------------------------------------------------------
model Treatment {
- id String @id @default(cuid())
- colonyId String
- colony Colony @relation(fields: [colonyId], references: [id])
- userId String
- user User @relation(fields: [userId], references: [id])
- date DateTime
- startDate DateTime?
- endDate DateTime?
- product String
- activeIngredient String?
- amount String?
- method String?
- reason String?
- batchNumber String?
- notes String?
- createdAt DateTime @default(now())
+ id String @id @default(cuid())
+ colonyId String
+ colony Colony @relation(fields: [colonyId], references: [id])
+ userId String
+ user User @relation(fields: [userId], references: [id])
+ date DateTime
+ startDate DateTime?
+ endDate DateTime?
+ product String
+ activeIngredient String?
+ amount String?
+ method String?
+ reason String?
+ batchNumber String?
+ notes String?
+ withdrawalPeriodDays Int?
+ createdAt DateTime @default(now())
@@index([colonyId, date])
}
@@ -708,9 +709,9 @@ model HoneyBatch {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- measurements HoneyMeasurement[]
- bottlingBatches BottlingBatch[]
- relocations HoneyBatchRelocation[]
+ measurements HoneyMeasurement[]
+ bottlingBatches BottlingBatch[]
+ relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
@@index([status])
@@ -757,10 +758,10 @@ model StorageContainer {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- honeyBatches HoneyBatch[]
- relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
- relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
- batchAllocations HoneyBatchContainerAllocation[]
+ honeyBatches HoneyBatch[]
+ relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
+ relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
+ batchAllocations HoneyBatchContainerAllocation[]
@@index([status])
}
@@ -879,14 +880,14 @@ model Product {
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
model LabelTemplate {
- id String @id @default(cuid())
- name String
+ id String @id @default(cuid())
+ name String
// Array of LabelElement objects (text/placeholder/qrCode/image/line), see
// label-templates.service.ts. Drives both the in-browser canvas designer
// and the pdfkit print renderer from the same source of truth.
- elements Json @default("[]")
- widthMm Float @default(90)
- heightMm Float @default(35)
+ elements Json @default("[]")
+ widthMm Float @default(90)
+ heightMm Float @default(35)
// A4 sheet layout. columns/rows are nullable: null means "auto-fit as many
// as fit" (the original behaviour); a real label sheet product (e.g. a
// specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so
@@ -894,14 +895,14 @@ model LabelTemplate {
// pre-die-cut sheet instead of just whatever the naive fit computes.
columns Int?
rows Int?
- marginTopMm Float @default(5)
- marginLeftMm Float @default(5)
- gapXMm Float @default(2)
- gapYMm Float @default(2)
- isDefault Boolean @default(false)
- active Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ marginTopMm Float @default(5)
+ marginLeftMm Float @default(5)
+ gapXMm Float @default(2)
+ gapYMm Float @default(2)
+ isDefault Boolean @default(false)
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
@@index([active])
}
diff --git a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
index d2ff58f..4ede718 100644
--- a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
+++ b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
@@ -1,5 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsDateString, IsOptional, IsString } from 'class-validator';
+import {
+ IsDateString,
+ IsInt,
+ IsOptional,
+ IsString,
+ Min,
+} from 'class-validator';
export class CreateTreatmentDto {
@ApiProperty()
@@ -53,4 +59,13 @@ export class CreateTreatmentDto {
@IsOptional()
@IsString()
notes?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Wartezeit in Tagen ab Behandlungsende, bevor Honig aus diesem Volk geerntet werden darf.',
+ })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ withdrawalPeriodDays?: number;
}
diff --git a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
index 76ffa27..36817e5 100644
--- a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
+++ b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts
@@ -13,5 +13,6 @@ export class UpdateTreatmentDto extends PartialType(
'reason',
'batchNumber',
'notes',
+ 'withdrawalPeriodDays',
] as const),
) {}
diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts
index c4c4dc7..6ee3e5e 100644
--- a/ApiCore-Backend/src/treatments/treatments.controller.ts
+++ b/ApiCore-Backend/src/treatments/treatments.controller.ts
@@ -1,4 +1,12 @@
-import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ Patch,
+ Post,
+ Query,
+} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -19,6 +27,13 @@ export class TreatmentsController {
return this.treatmentsService.findAll();
}
+ @RequirePermission('treatments:read')
+ @Get('withdrawal-status')
+ getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) {
+ const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : [];
+ return this.treatmentsService.getWithdrawalStatus(ids);
+ }
+
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
index d9ef64d..6468f7b 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
@@ -97,4 +97,58 @@ describe('TreatmentsService', () => {
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
+
+ describe('getWithdrawalStatus', () => {
+ it('returns an empty array without querying when no colony ids are given', async () => {
+ const result = await service.getWithdrawalStatus([]);
+
+ expect(result).toEqual([]);
+ expect(prisma.treatment.findMany).not.toHaveBeenCalled();
+ });
+
+ it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
+ const now = Date.now();
+ const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000);
+ const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000);
+ prisma.treatment.findMany.mockResolvedValue([
+ {
+ colonyId: 'colony-1',
+ date: inTenDays,
+ endDate: null,
+ withdrawalPeriodDays: 0,
+ product: 'Ameisensäure',
+ },
+ {
+ colonyId: 'colony-1',
+ date: inFiveDays,
+ endDate: null,
+ withdrawalPeriodDays: 21,
+ product: 'Api Bioxal',
+ },
+ ]);
+
+ const result = await service.getWithdrawalStatus(['colony-1']);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].colonyId).toBe('colony-1');
+ expect(result[0].product).toBe('Api Bioxal');
+ });
+
+ it('omits a colony whose withdrawal window has already passed', async () => {
+ const longAgo = new Date('2020-01-01');
+ prisma.treatment.findMany.mockResolvedValue([
+ {
+ colonyId: 'colony-1',
+ date: longAgo,
+ endDate: null,
+ withdrawalPeriodDays: 14,
+ product: 'Ameisensäure',
+ },
+ ]);
+
+ const result = await service.getWithdrawalStatus(['colony-1']);
+
+ expect(result).toEqual([]);
+ });
+ });
});
diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts
index 1e49a35..c8b605f 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.ts
@@ -6,6 +6,12 @@ import { Treatment } from '../../generated/prisma';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
+export interface WithdrawalStatus {
+ colonyId: string;
+ activeUntil: string;
+ product: string;
+}
+
@Injectable()
export class TreatmentsService {
constructor(
@@ -33,6 +39,44 @@ export class TreatmentsService {
});
}
+ // For each colony, returns the treatment whose withdrawal window (its
+ // end date, or start date if the treatment is still ongoing, plus its
+ // withdrawalPeriodDays) reaches furthest into the future - only when
+ // that window is still active. Colonies with no active window are
+ // omitted rather than returned as null, since most colonies have none.
+ async getWithdrawalStatus(colonyIds: string[]): Promise {
+ if (colonyIds.length === 0) {
+ return [];
+ }
+ const treatments = await this.prisma.treatment.findMany({
+ where: {
+ colonyId: { in: colonyIds },
+ withdrawalPeriodDays: { not: null },
+ },
+ });
+ const now = new Date();
+ const activeByColony = new Map();
+ for (const treatment of treatments) {
+ const referenceDate = treatment.endDate ?? treatment.date;
+ const activeUntil = new Date(referenceDate);
+ activeUntil.setUTCDate(
+ activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
+ );
+ if (activeUntil <= now) {
+ continue;
+ }
+ const current = activeByColony.get(treatment.colonyId);
+ if (!current || activeUntil > new Date(current.activeUntil)) {
+ activeByColony.set(treatment.colonyId, {
+ colonyId: treatment.colonyId,
+ activeUntil: activeUntil.toISOString(),
+ product: treatment.product,
+ });
+ }
+ }
+ return [...activeByColony.values()];
+ }
+
async create(
dto: CreateTreatmentDto,
actingUserId: string,
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx
index f0299d6..39c604d 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-treatments.tsx
@@ -49,6 +49,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{treatment.method ? ` · ${treatment.method}` : ""}
{treatment.reason ? Grund: {treatment.reason}
: null}
+ {treatment.withdrawalPeriodDays !== null ? (
+ Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)
+ ) : null}
))}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
index 82f9001..5e1fa61 100644
--- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
+++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx
@@ -26,6 +26,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
+ const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
+ treatment.withdrawalPeriodDays?.toString() ?? "",
+ );
const mutation = useMutation({
mutationFn: () =>
@@ -36,6 +39,7 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
method: method || undefined,
reason: reason || undefined,
notes: notes || undefined,
+ withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -107,6 +111,17 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setReason(event.target.value)}
/>
+
+
+ setWithdrawalPeriodDays(event.target.value)}
+ />
+
diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx
index 8f3102d..52a2798 100644
--- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx
@@ -45,6 +45,7 @@ export default function NewTreatmentPage() {
const [method, setMethod] = useState("");
const [reason, setReason] = useState("");
const [notes, setNotes] = useState("");
+ const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
const [error, setError] = useState(null);
const mutation = useMutation({
@@ -83,6 +84,7 @@ export default function NewTreatmentPage() {
method: method || undefined,
reason: reason || undefined,
notes: notes || undefined,
+ withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
});
}
@@ -160,6 +162,22 @@ export default function NewTreatmentPage() {
onChange={(event) => setReason(event.target.value)}
/>
+
+
+
setWithdrawalPeriodDays(event.target.value)}
+ />
+
+ Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig
+ geerntet werden.
+
+