Files
ApiCore/ApiCore-Backend/src/inspections/inspections.service.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
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]>
2026-10-14 11:36:38 +02:00

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;
}
}