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

+77 -44
View File
@@ -7,7 +7,9 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { Queen, QueenStatus } from '../../generated/prisma';
import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
@@ -64,7 +66,7 @@ export class QueensService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly calendarSync: CalendarSyncService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
@@ -158,28 +160,55 @@ export class QueensService {
dto: AssignColonyDto,
actingUserId: string,
): Promise<Queen> {
const queen = await this.findById(id);
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await this.prisma.colony.findUnique({
where: { id: dto.colonyId },
await this.findById(id);
// Only to know which colony to lock; the transaction re-reads it.
const preRead = await this.prisma.colony.findUnique({
where: { currentQueenId: id },
select: { id: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException(
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
);
}
if (colony.currentQueenId === queen.id) {
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
}
return this.prisma.$transaction(async (tx) => {
// Colonies first (one statement, ascending ids), then the queen: the
// order every writer of both uses.
await lockRowsForUpdate(tx, 'Colony', [
dto.colonyId,
...(preRead ? [preRead.id] : []),
]);
await lockRowForUpdate(tx, 'Queen', id);
const queen = await tx.queen.findUnique({ where: { id } });
if (!queen) {
throw new NotFoundException('Queen not found');
}
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await tx.colony.findUnique({
where: { id: dto.colonyId },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException(
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
);
}
if (colony.currentQueenId === queen.id) {
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
}
// The colony the queen leaves must let go of her - currentQueenId is
// unique, and a stale pointer would both block this assignment and
// keep showing her in a colony she is no longer in.
const previousColony = await tx.colony.findUnique({
where: { currentQueenId: queen.id },
});
if ((previousColony?.id ?? null) !== (preRead?.id ?? null)) {
throw new ConflictException(
'Die Königin wurde gerade umgesetzt - bitte erneut versuchen',
);
}
const history = await this.prisma.$transaction(async (tx) => {
const now = new Date();
if (colony.currentQueenId) {
await tx.queenColonyHistory.updateMany({
@@ -191,12 +220,6 @@ export class QueensService {
data: { endDate: now },
});
}
// The colony the queen leaves must let go of her - currentQueenId is
// unique, and a stale pointer would both block this assignment and
// keep showing her in a colony she is no longer in.
const previousColony = await tx.colony.findUnique({
where: { currentQueenId: queen.id },
});
if (previousColony) {
await tx.colony.update({
where: { id: previousColony.id },
@@ -221,29 +244,39 @@ export class QueensService {
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
},
});
return tx.queenColonyHistory.create({
const history = await tx.queenColonyHistory.create({
data: {
queenId: queen.id,
colonyId: colony.id,
startDate: new Date(),
startDate: now,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Queen',
entityId: queen.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id },
},
tx,
);
await this.events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queen.id,
actorUserId: actingUserId,
payload: {
colonyId: colony.id,
historyId: history.id,
assignedOn: utcDay(now),
previousColonyId: previousColony?.id ?? null,
},
});
return tx.queen.findUniqueOrThrow({ where: { id } });
});
await this.auditLog.record({
entityType: 'Queen',
entityId: queen.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id },
});
const updated = await this.findById(id);
await this.webhooks.dispatch('queen.replaced', updated);
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
return updated;
}
async getHistory(id: string) {