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]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -1,23 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.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 webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly tasks: TasksService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -25,13 +33,7 @@ export class InspectionsService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Inspection> {
|
||||
const inspection = await this.prisma.inspection.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!inspection) {
|
||||
throw new NotFoundException('Inspection not found');
|
||||
}
|
||||
return inspection;
|
||||
return this.findIn(this.prisma, id);
|
||||
}
|
||||
|
||||
async findByColony(colonyId: string): Promise<Inspection[]> {
|
||||
@@ -45,78 +47,104 @@ export class InspectionsService {
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): Promise<Inspection> {
|
||||
const inspection = await this.prisma.inspection.create({
|
||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||
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;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Inspection',
|
||||
entityId: inspection.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: inspection,
|
||||
});
|
||||
await this.webhooks.dispatch('inspection.created', inspection);
|
||||
await this.automation.evaluateInspection(inspection);
|
||||
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||
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> {
|
||||
const before = await this.findById(id);
|
||||
const inspection = await this.prisma.inspection.update({
|
||||
where: { id },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
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;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Inspection',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: inspection,
|
||||
});
|
||||
// A changed date or colony must move the calendar event, not leave the
|
||||
// old one behind.
|
||||
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||
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);
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
priority: dto.priority,
|
||||
dueDate: optionalDate(dto.dueDate),
|
||||
assignedUserId: dto.assignedUserId,
|
||||
createdByUserId: actingUserId,
|
||||
relatedType: 'COLONY',
|
||||
relatedId: inspection.colonyId,
|
||||
sourceInspectionId: inspection.id,
|
||||
},
|
||||
});
|
||||
// Same trail as a task created on the task page: audit entry, webhook
|
||||
// and calendar entry must not depend on where the task was started.
|
||||
await this.auditLog.record({
|
||||
entityType: 'Task',
|
||||
entityId: task.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: task,
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
if (task.dueDate) {
|
||||
await this.calendarSync.enqueue('TASK', task.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 task;
|
||||
return inspection;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user