Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { DbClient } from '../prisma/db-client';
|
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
|
import { TasksService } from '../tasks/tasks.service';
|
|
import { Inspection, Task } from '../../generated/prisma';
|
|
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
|
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
|
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
|
import { optionalDate } from '../common/patch-values';
|
|
|
|
/**
|
|
* Inspections are written together with their audit entry and domain event
|
|
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
|
|
* rules (automation.inspection-rules, on InspectionRecorded only), calendar
|
|
* entry (calendar-sync.inspection) and the inspection.created webhook
|
|
* (webhooks.forward) follow from the event, never from a direct call - so
|
|
* none of them can fail the request or run twice on a retried one.
|
|
*/
|
|
@Injectable()
|
|
export class InspectionsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly events: DomainEventsService,
|
|
private readonly tasks: TasksService,
|
|
) {}
|
|
|
|
async findAll(): Promise<Inspection[]> {
|
|
return this.prisma.inspection.findMany({ orderBy: { date: 'desc' } });
|
|
}
|
|
|
|
async findById(id: string): Promise<Inspection> {
|
|
return this.findIn(this.prisma, id);
|
|
}
|
|
|
|
async findByColony(colonyId: string): Promise<Inspection[]> {
|
|
return this.prisma.inspection.findMany({
|
|
where: { colonyId },
|
|
orderBy: { date: 'desc' },
|
|
});
|
|
}
|
|
|
|
async create(
|
|
dto: CreateInspectionDto,
|
|
actingUserId: string,
|
|
): Promise<Inspection> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const inspection = await tx.inspection.create({
|
|
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
|
});
|
|
await this.auditLog.record(
|
|
{
|
|
entityType: 'Inspection',
|
|
entityId: inspection.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: inspection,
|
|
},
|
|
tx,
|
|
);
|
|
await this.events.record(tx, {
|
|
type: 'InspectionRecorded',
|
|
aggregateType: 'Inspection',
|
|
aggregateId: inspection.id,
|
|
payload: { colonyId: inspection.colonyId },
|
|
actorUserId: actingUserId,
|
|
});
|
|
return inspection;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A correction evaluates no automation rule, as before the event
|
|
* backbone. InspectionCorrected moves the calendar entry when the date or
|
|
* colony changed instead of leaving the old one behind.
|
|
*/
|
|
async update(
|
|
id: string,
|
|
dto: UpdateInspectionDto,
|
|
actingUserId: string,
|
|
): Promise<Inspection> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
// "before" is read under the row lock: a concurrent correction can
|
|
// neither slip in between nor be mislabelled in the audit entry.
|
|
await lockRowForUpdate(tx, 'Inspection', id);
|
|
const before = await this.findIn(tx, id);
|
|
const inspection = await tx.inspection.update({
|
|
where: { id },
|
|
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
|
});
|
|
await this.auditLog.record(
|
|
{
|
|
entityType: 'Inspection',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: inspection,
|
|
},
|
|
tx,
|
|
);
|
|
await this.events.record(tx, {
|
|
type: 'InspectionCorrected',
|
|
aggregateType: 'Inspection',
|
|
aggregateId: id,
|
|
payload: { colonyId: inspection.colonyId },
|
|
actorUserId: actingUserId,
|
|
});
|
|
return inspection;
|
|
});
|
|
}
|
|
|
|
// Through TasksService like a task from the task page: audit entry,
|
|
// TaskCreated and with it calendar entry and webhook do not depend on
|
|
// where the task was started.
|
|
async createTaskFromInspection(
|
|
id: string,
|
|
dto: CreateTaskFromInspectionDto,
|
|
actingUserId: string,
|
|
): Promise<Task> {
|
|
const inspection = await this.findById(id);
|
|
return this.prisma.$transaction((tx) =>
|
|
this.tasks.createInTx(
|
|
tx,
|
|
{
|
|
title: dto.title,
|
|
description: dto.description,
|
|
priority: dto.priority,
|
|
dueDate: optionalDate(dto.dueDate),
|
|
assignedUserId: dto.assignedUserId,
|
|
relatedType: 'COLONY',
|
|
relatedId: inspection.colonyId,
|
|
sourceInspectionId: inspection.id,
|
|
},
|
|
actingUserId,
|
|
),
|
|
);
|
|
}
|
|
|
|
private async findIn(db: DbClient, id: string): Promise<Inspection> {
|
|
const inspection = await db.inspection.findUnique({ where: { id } });
|
|
if (!inspection) {
|
|
throw new NotFoundException('Inspection not found');
|
|
}
|
|
return inspection;
|
|
}
|
|
}
|