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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user