feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinToday } from '../common/dates';
describe('ColoniesService', () => {
let service: ColoniesService;
@@ -139,6 +140,96 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2');
});
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' };
tx.colony.create.mockResolvedValue(child);
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{
code: 'V-001-A',
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
},
'user-1',
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { date: Date; queenSource: string } }],
];
expect(lineageArg.data.date).toEqual(new Date('2026-05-05T00:00:00.000Z'));
expect(lineageArg.data.queenSource).toBe('QUEEN_CELL');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: {
parentColonyId: 'colony-1',
lineageId: 'lineage-1',
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
},
}),
);
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
});
it('split without a date uses today and a null queenSource', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const [[, event]] = events.record.mock.calls as [
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
];
expect(event.payload.queenSource).toBeNull();
expect(event.payload.splitOn).toBe(berlinToday());
});
it('split with a future date answers 400', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY', date: '2999-01-01' },
'user-1',
),
).rejects.toMatchObject({
status: 400,
message: 'Das Datum liegt in der Zukunft.',
});
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('split of a colony that ended meanwhile (seen after the lock) answers 409', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({ ...parentColony, status: 'DISSOLVED' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
@@ -502,6 +593,7 @@ describe('ColoniesService', () => {
it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
@@ -17,6 +17,7 @@ import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
export interface ColonyFilter {
status?: ColonyStatus;
@@ -194,9 +195,16 @@ export class ColoniesService {
dto: SplitColonyDto,
actingUserId: string,
): Promise<Colony> {
const parent = await this.findById(id);
this.assertActive(parent);
await this.findById(id);
const today = berlinToday();
const splitOn = dto.date ?? today;
if (splitOn > today) {
throw new BadRequestException('Das Datum liegt in der Zukunft.');
}
const queenSource = dto.queenSource ?? null;
const child = await this.prisma.$transaction(async (tx) => {
const parent = await lockColony(tx, id);
this.assertActive(parent);
const created = await tx.colony.create({
data: {
code: dto.code,
@@ -205,12 +213,13 @@ export class ColoniesService {
notes: dto.notes,
},
});
await tx.colonyLineage.create({
const lineage = await tx.colonyLineage.create({
data: {
type: 'SPLIT',
fromColonyId: parent.id,
toColonyId: created.id,
date: new Date(),
date: dayToDate(splitOn),
queenSource,
notes: dto.notes,
},
});
@@ -224,6 +233,18 @@ export class ColoniesService {
},
tx,
);
await this.events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: created.id,
actorUserId: actingUserId,
payload: {
parentColonyId: parent.id,
lineageId: lineage.id,
queenSource,
splitOn,
},
});
return created;
});
return child;
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { ColonyType, SplitQueenSource } from '../../../generated/prisma';
export class SplitColonyDto {
@ApiProperty()
@@ -15,4 +15,16 @@ export class SplitColonyDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description: 'Day of the split (YYYY-MM-DD), not after today.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date?: string;
@ApiPropertyOptional({ enum: SplitQueenSource })
@IsOptional()
@IsEnum(SplitQueenSource)
queenSource?: SplitQueenSource;
}