feat: Ein-/Auswinterung und Winterverluste je Volk

- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:32:03 +02:00
1 parent 0d23963f07
commit fac66882fa
26 files changed
+2020 -2

No files matched your search

+2
View File
@@ -40,6 +40,7 @@ import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WinterChecksModule } from './winter-checks/winter-checks.module';
import { WeatherModule } from './weather/weather.module';
import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
@@ -95,6 +96,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
WinterChecksModule,
WeatherModule,
ProfileModule,
SiteSettingsModule,
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
],
800,
),
// Seeded with role grants by the winter_checks migration (everyday records
// like inspections, not an admin-only area).
...group(
'Überwinterung',
'winter-checks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
850,
),
...group(
'Behandlungen',
'treatments',
@@ -24,6 +24,7 @@ const BUSINESS_ENTITY_KEYS = [
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
@@ -93,6 +93,7 @@ export class BackupService {
data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany();
data.treatment = await this.prisma.treatment.findMany();
data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany();
@@ -430,6 +431,21 @@ export class BackupService {
}
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
for (const row of rowsOf('winterCheck')) {
await tx.winterCheck.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.winterCheck = rowsOf('winterCheck').length;
for (const row of rowsOf('treatment')) {
await tx.treatment.upsert({
where: { id: row.id as string },
@@ -0,0 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { WinterChecksService } from './winter-checks.service';
@ApiTags('winter-checks')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyWinterChecksController {
constructor(private readonly winterChecksService: WinterChecksService) {}
@RequirePermission('winter-checks:read')
@Get(':id/winter-checks')
findByColony(@Param('id') id: string) {
return this.winterChecksService.findByColony(id);
}
}
@@ -0,0 +1,88 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
ColonyLossCause,
WinterCheckSeason,
WinterOutcome,
} from '../../../generated/prisma';
export class CreateWinterCheckDto {
@ApiProperty()
@IsString()
colonyId!: string;
@ApiProperty({ enum: WinterCheckSeason })
@IsEnum(WinterCheckSeason)
season!: WinterCheckSeason;
@ApiProperty()
@IsDateString()
date!: string;
@ApiPropertyOptional({
description:
'Jahr, in dem der Winter beginnt (2026 = Winter 2026/27). Ohne Angabe aus dem Datum abgeleitet.',
})
@IsOptional()
@IsInt()
@Min(2000)
@Max(2100)
winterYear?: number;
@ApiPropertyOptional({ description: 'Besetzte Wabengassen' })
@IsOptional()
@IsInt()
@Min(0)
@Max(60)
occupiedSeams?: number;
@ApiPropertyOptional({ description: 'Geschätzter Futtervorrat in kg' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
feedStockKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
queenPresent?: boolean;
@ApiPropertyOptional({ enum: WinterOutcome, description: 'Nur Auswinterung' })
@IsOptional()
@IsEnum(WinterOutcome)
outcome?: WinterOutcome;
@ApiPropertyOptional({
enum: ColonyLossCause,
description: 'Nur bei Ergebnis LOST (Kategorien des COLOSS-Monitorings)',
})
@IsOptional()
@IsEnum(ColonyLossCause)
lossCause?: ColonyLossCause;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@ApiPropertyOptional({
description:
'Bei Ergebnis LOST das Volk zugleich als abgestorben (DECEASED) kennzeichnen.',
})
@IsOptional()
@IsBoolean()
markColonyDeceased?: boolean;
}
@@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateWinterCheckDto } from './create-winter-check.dto';
/**
* Every field stays correctable, colony and season included: a check filed
* against the wrong colony or season is a data-entry error, and re-filing it
* is the only way to repair the winter record without deleting it.
*/
export class UpdateWinterCheckDto extends PartialType(CreateWinterCheckDto) {}
@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class WinterCheckQueryDto {
@ApiPropertyOptional({ description: '2026 = Winter 2026/27' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(2000)
@Max(2100)
winterYear?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
apiaryId?: string;
}
@@ -0,0 +1,153 @@
import {
summarizeWinter,
validateWinterCheck,
WinterSummarySource,
winterYearFor,
} from './winter-check-rules';
const d = (value: string) => new Date(`${value}T12:00:00.000Z`);
describe('winterYearFor', () => {
it('assigns autumn dates to the winter starting that year', () => {
expect(winterYearFor(d('2026-09-15'))).toBe(2026);
expect(winterYearFor(d('2026-07-01'))).toBe(2026);
});
it('assigns spring dates to the winter that started the year before', () => {
expect(winterYearFor(d('2027-03-20'))).toBe(2026);
expect(winterYearFor(d('2027-06-30'))).toBe(2026);
});
});
describe('validateWinterCheck', () => {
it('accepts a plain autumn check', () => {
expect(
validateWinterCheck({ season: 'AUTUMN', outcome: null, lossCause: null }),
).toBeNull();
});
it('rejects an outcome on an autumn check', () => {
expect(
validateWinterCheck({
season: 'AUTUMN',
outcome: 'SURVIVED',
lossCause: null,
}),
).toMatch(/Einwinterung/);
});
it('requires an outcome on a spring check', () => {
expect(
validateWinterCheck({ season: 'SPRING', outcome: null, lossCause: null }),
).toMatch(/Ergebnis/);
});
it('requires a loss cause exactly when the colony was lost', () => {
expect(
validateWinterCheck({
season: 'SPRING',
outcome: 'LOST',
lossCause: null,
}),
).toMatch(/Verlustursache/);
expect(
validateWinterCheck({
season: 'SPRING',
outcome: 'SURVIVED',
lossCause: 'DEAD_OR_EMPTY',
}),
).toMatch(/Verlustursache/);
expect(
validateWinterCheck({
season: 'SPRING',
outcome: 'LOST',
lossCause: 'QUEEN_PROBLEM',
}),
).toBeNull();
});
});
function check(overrides: Partial<WinterSummarySource>): WinterSummarySource {
return {
colonyId: 'c1',
apiaryId: 'a',
apiaryName: 'Stand A',
season: 'AUTUMN',
outcome: null,
lossCause: null,
...overrides,
};
}
describe('summarizeWinter', () => {
it('counts outcomes against the colonies that went into winter', () => {
const summary = summarizeWinter([
check({ colonyId: 'c1' }),
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
check({ colonyId: 'c2' }),
check({
colonyId: 'c2',
season: 'SPRING',
outcome: 'LOST',
lossCause: 'DEAD_OR_EMPTY',
}),
check({ colonyId: 'c3' }),
check({ colonyId: 'c3', season: 'SPRING', outcome: 'WEAK' }),
check({ colonyId: 'c4' }),
]);
expect(summary.total).toMatchObject({
wintered: 4,
survived: 1,
weak: 1,
lost: 1,
lostByCause: { DEAD_OR_EMPTY: 1, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 },
pendingSpring: 1,
springWithoutAutumn: 0,
lossRatePercent: 25,
isProvisional: true,
});
});
it('never counts a colony without spring check as survived', () => {
const summary = summarizeWinter([check({ colonyId: 'c1' })]);
expect(summary.total.survived).toBe(0);
expect(summary.total.pendingSpring).toBe(1);
expect(summary.total.isProvisional).toBe(true);
});
it('keeps spring checks without autumn check out of the loss rate', () => {
const summary = summarizeWinter([
check({ colonyId: 'c1' }),
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
check({
colonyId: 'c9',
season: 'SPRING',
outcome: 'LOST',
lossCause: 'NATURAL_DISASTER',
}),
]);
expect(summary.total).toMatchObject({
wintered: 1,
lost: 0,
springWithoutAutumn: 1,
lossRatePercent: 0,
isProvisional: false,
});
});
it('reports no loss rate when nothing was wintered', () => {
expect(summarizeWinter([]).total.lossRatePercent).toBeNull();
});
it('groups by apiary, sorted by name', () => {
const summary = summarizeWinter([
check({ colonyId: 'c1', apiaryId: 'b', apiaryName: 'Wald' }),
check({ colonyId: 'c2', apiaryId: 'a', apiaryName: 'Obstwiese' }),
]);
expect(summary.apiaries.map((apiary) => apiary.apiaryName)).toEqual([
'Obstwiese',
'Wald',
]);
});
});
@@ -0,0 +1,128 @@
import type {
ColonyLossCause,
WinterCheckSeason,
WinterOutcome,
} from '../../generated/prisma';
/**
* Checks from July on belong to the winter starting that year, earlier ones
* to the winter that started the year before (Winter 2026/27 = 2026). The cut
* sits between the latest Auswinterung (spring) and the earliest
* Einwinterung preparations (late summer).
*/
export function winterYearFor(date: Date): number {
const year = date.getUTCFullYear();
return date.getUTCMonth() >= 6 ? year : year - 1;
}
export interface WinterCheckShape {
season: WinterCheckSeason;
outcome: WinterOutcome | null;
lossCause: ColonyLossCause | null;
}
/** German error message, or null when the combination is consistent. */
export function validateWinterCheck(check: WinterCheckShape): string | null {
if (check.season === 'AUTUMN') {
return check.outcome || check.lossCause
? 'Eine Einwinterung hat kein Ergebnis und keine Verlustursache - das gehört zur Auswinterung.'
: null;
}
if (!check.outcome) {
return 'Bei der Auswinterung bitte das Ergebnis angeben (überlebt, schwach oder verloren).';
}
if (check.outcome === 'LOST' && !check.lossCause) {
return 'Bei einem verlorenen Volk bitte die Verlustursache angeben.';
}
if (check.outcome !== 'LOST' && check.lossCause) {
return 'Eine Verlustursache gibt es nur bei einem verlorenen Volk.';
}
return null;
}
export interface WinterSummarySource extends WinterCheckShape {
colonyId: string;
apiaryId: string;
apiaryName: string;
}
export interface WinterCounts {
/** Colonies with an Einwinterung record - the denominator of the loss rate. */
wintered: number;
survived: number;
weak: number;
lost: number;
lostByCause: Record<ColonyLossCause, number>;
/** Wintered colonies without Auswinterung yet: unknown, never "survived". */
pendingSpring: number;
/** Auswinterung without Einwinterung: shown, but outside the loss rate. */
springWithoutAutumn: number;
/** lost / wintered as in the COLOSS monitoring; null if nothing wintered. */
lossRatePercent: number | null;
/** True while pendingSpring > 0 - the rate can still rise. */
isProvisional: boolean;
}
export interface WinterSummary {
total: WinterCounts;
apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[];
}
function countWinter(checks: WinterSummarySource[]): WinterCounts {
const autumn = new Set(
checks.filter((c) => c.season === 'AUTUMN').map((c) => c.colonyId),
);
const counts: WinterCounts = {
wintered: autumn.size,
survived: 0,
weak: 0,
lost: 0,
lostByCause: { DEAD_OR_EMPTY: 0, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 },
pendingSpring: 0,
springWithoutAutumn: 0,
lossRatePercent: null,
isProvisional: false,
};
const springChecked = new Set<string>();
for (const check of checks) {
if (check.season !== 'SPRING') continue;
springChecked.add(check.colonyId);
if (!autumn.has(check.colonyId)) {
counts.springWithoutAutumn += 1;
continue;
}
if (check.outcome === 'SURVIVED') counts.survived += 1;
if (check.outcome === 'WEAK') counts.weak += 1;
if (check.outcome === 'LOST') {
counts.lost += 1;
if (check.lossCause) counts.lostByCause[check.lossCause] += 1;
}
}
counts.pendingSpring = [...autumn].filter(
(colonyId) => !springChecked.has(colonyId),
).length;
counts.isProvisional = counts.pendingSpring > 0;
counts.lossRatePercent =
counts.wintered > 0
? Math.round((counts.lost / counts.wintered) * 1000) / 10
: null;
return counts;
}
export function summarizeWinter(checks: WinterSummarySource[]): WinterSummary {
const byApiary = new Map<string, WinterSummarySource[]>();
for (const check of checks) {
byApiary.set(check.apiaryId, [
...(byApiary.get(check.apiaryId) ?? []),
check,
]);
}
const apiaries = [...byApiary.entries()]
.map(([apiaryId, apiaryChecks]) => ({
apiaryId,
apiaryName: apiaryChecks[0].apiaryName,
...countWinter(apiaryChecks),
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
return { total: countWinter(checks), apiaries };
}
@@ -0,0 +1,67 @@
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';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
import { WinterCheckQueryDto } from './dto/winter-check-query.dto';
import { winterYearFor } from './winter-check-rules';
import { WinterChecksService } from './winter-checks.service';
@ApiTags('winter-checks')
@ApiBearerAuth()
@Controller('winter-checks')
export class WinterChecksController {
constructor(private readonly winterChecksService: WinterChecksService) {}
@RequirePermission('winter-checks:read')
@Get()
findAll(@Query() query: WinterCheckQueryDto) {
return this.winterChecksService.findAll(query);
}
/** Loss figures of one winter; without winterYear the current winter. */
@RequirePermission('winter-checks:read')
@Get('summary')
summary(@Query() query: WinterCheckQueryDto) {
return this.winterChecksService.summary(
query.winterYear ?? winterYearFor(new Date()),
);
}
@RequirePermission('winter-checks:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.winterChecksService.findById(id);
}
@RequirePermission('winter-checks:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateWinterCheckDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.winterChecksService.create(dto, actor.id);
}
@RequirePermission('winter-checks:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateWinterCheckDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.winterChecksService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ColonyWinterChecksController } from './colony-winter-checks.controller';
import { WinterChecksController } from './winter-checks.controller';
import { WinterChecksService } from './winter-checks.service';
@Module({
controllers: [WinterChecksController, ColonyWinterChecksController],
providers: [WinterChecksService],
exports: [WinterChecksService],
})
export class WinterChecksModule {}
@@ -0,0 +1,277 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Colony, Prisma, WinterCheck } from '../../generated/prisma';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
import { WinterCheckQueryDto } from './dto/winter-check-query.dto';
import {
summarizeWinter,
validateWinterCheck,
WinterCheckShape,
WinterSummary,
winterYearFor,
} from './winter-check-rules';
const WITH_COLONY = {
colony: {
select: {
id: true,
code: true,
status: true,
apiaryId: true,
apiary: { select: { id: true, name: true } },
},
},
} satisfies Prisma.WinterCheckInclude;
export type WinterCheckWithColony = Prisma.WinterCheckGetPayload<{
include: typeof WITH_COLONY;
}>;
const DUPLICATE_MESSAGE =
'Für dieses Volk gibt es in diesem Winter schon einen Eintrag dieser Art - bitte den vorhandenen bearbeiten.';
@Injectable()
export class WinterChecksService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
findAll(query: WinterCheckQueryDto): Promise<WinterCheckWithColony[]> {
return this.prisma.winterCheck.findMany({
where: {
winterYear: query.winterYear,
colony: query.apiaryId ? { apiaryId: query.apiaryId } : undefined,
},
include: WITH_COLONY,
orderBy: [{ winterYear: 'desc' }, { date: 'asc' }],
});
}
findByColony(colonyId: string): Promise<WinterCheck[]> {
return this.prisma.winterCheck.findMany({
where: { colonyId },
orderBy: [{ winterYear: 'desc' }, { season: 'asc' }],
});
}
async findById(id: string): Promise<WinterCheckWithColony> {
const check = await this.prisma.winterCheck.findUnique({
where: { id },
include: WITH_COLONY,
});
if (!check) {
throw new NotFoundException('Überwinterungs-Eintrag nicht gefunden');
}
return check;
}
/**
* Loss figures per winter. Colonies are grouped by the apiary they stand at
* now - a dead colony keeps its last apiary, so the loss stays where it
* happened unless the colony was moved after the winter.
*/
async summary(winterYear: number): Promise<WinterSummary> {
const checks = await this.prisma.winterCheck.findMany({
where: { winterYear },
include: WITH_COLONY,
});
return summarizeWinter(
checks.map((check) => ({
colonyId: check.colonyId,
apiaryId: check.colony.apiaryId,
apiaryName: check.colony.apiary.name,
season: check.season,
outcome: check.outcome,
lossCause: check.lossCause,
})),
);
}
async create(
dto: CreateWinterCheckDto,
actingUserId: string,
): Promise<WinterCheck> {
const { markColonyDeceased, ...fields } = dto;
const colony = await this.findColony(fields.colonyId);
// Wintering a colony that no longer exists is a mistake; documenting the
// Auswinterung of one that already died is not - that is the loss record.
if (fields.season === 'AUTUMN' && !isLivingColonyStatus(colony.status)) {
throw new BadRequestException(
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann nicht eingewintert werden`,
);
}
const date = new Date(fields.date);
const data = {
...fields,
date,
winterYear: fields.winterYear ?? winterYearFor(date),
outcome: fields.outcome ?? null,
lossCause: fields.lossCause ?? null,
};
this.assertConsistent(data);
const check = await this.withDuplicateMessage(() =>
this.prisma.$transaction(async (tx) => {
const created = await tx.winterCheck.create({
data: { ...data, userId: actingUserId },
});
await this.markDeceasedIfRequested(
tx,
colony,
created,
markColonyDeceased,
actingUserId,
);
return created;
}),
);
await this.auditLog.record({
entityType: 'WinterCheck',
entityId: check.id,
action: 'CREATE',
userId: actingUserId,
afterData: check,
});
return check;
}
async update(
id: string,
dto: UpdateWinterCheckDto,
actingUserId: string,
): Promise<WinterCheck> {
const { markColonyDeceased, ...fields } = dto;
const { colony: _currentColony, ...before } = await this.findById(id);
void _currentColony;
const colony = await this.findColony(fields.colonyId ?? before.colonyId);
const date = fields.date ? new Date(fields.date) : before.date;
const merged = mergeOutcome(before, fields);
this.assertConsistent(merged);
const check = await this.withDuplicateMessage(() =>
this.prisma.$transaction(async (tx) => {
const updated = await tx.winterCheck.update({
where: { id },
data: {
...fields,
...merged,
date,
winterYear:
fields.winterYear ??
(fields.date ? winterYearFor(date) : undefined),
},
});
await this.markDeceasedIfRequested(
tx,
colony,
updated,
markColonyDeceased,
actingUserId,
);
return updated;
}),
);
await this.auditLog.record({
entityType: 'WinterCheck',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: check,
});
return check;
}
private assertConsistent(check: WinterCheckShape): void {
const problem = validateWinterCheck(check);
if (problem) {
throw new BadRequestException(problem);
}
}
private async findColony(colonyId: string): Promise<Colony> {
const colony = await this.prisma.colony.findUnique({
where: { id: colonyId },
});
if (!colony) {
throw new NotFoundException('Volk nicht gefunden');
}
return colony;
}
/**
* Only on explicit request, only for a LOST outcome and only while the
* colony is still recorded as living - a loss must never be booked
* implicitly, and an already dissolved or merged colony keeps its status.
*/
private async markDeceasedIfRequested(
tx: Prisma.TransactionClient,
colony: Colony,
check: WinterCheck,
requested: boolean | undefined,
actingUserId: string,
): Promise<void> {
if (!requested || check.outcome !== 'LOST') return;
if (!isLivingColonyStatus(colony.status)) return;
const after = await tx.colony.update({
where: { id: colony.id },
data: { status: 'DECEASED', deactivatedAt: check.date },
});
await tx.auditLog.create({
data: {
entityType: 'Colony',
entityId: colony.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: colony,
afterData: after,
},
});
}
private async withDuplicateMessage<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(DUPLICATE_MESSAGE);
}
throw error;
}
}
}
/**
* Applies an edit to season/outcome/lossCause. Switching to an Einwinterung,
* or away from LOST, clears the fields that no longer apply instead of making
* the caller blank them by hand - an explicitly sent value still wins and is
* then validated.
*/
export function mergeOutcome(
before: WinterCheckShape,
fields: Partial<WinterCheckShape>,
): WinterCheckShape {
const season = fields.season ?? before.season;
let outcome = fields.outcome !== undefined ? fields.outcome : before.outcome;
if (season === 'AUTUMN' && fields.outcome === undefined) {
outcome = null;
}
let lossCause =
fields.lossCause !== undefined ? fields.lossCause : before.lossCause;
if (outcome !== 'LOST' && fields.lossCause === undefined) {
lossCause = null;
}
return { season, outcome, lossCause };
}