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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user