feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,139 @@
-- Platform backbone: domain-event outbox (DomainEvent + per-handler
-- DomainEventDelivery), webhook delivery queue, cron job run history and
-- throttler counters, plus two small columns (Apiary.publicLocationLabel,
-- SiteSettings.originCountry).
-- ThrottlerHit is UNLOGGED on purpose: no WAL, emptied after a crash - only
-- rate-limit counters get lost. DomainEvent.dedupeKey is a nullable UNIQUE
-- (not a partial index) so the schema and the database cannot drift apart.
-- CreateEnum
CREATE TYPE "DomainEventStatus" AS ENUM ('PENDING', 'PROCESSING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "DomainEventDeliveryStatus" AS ENUM ('PENDING', 'DONE', 'FAILED');
-- CreateEnum
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
-- AlterTable
ALTER TABLE "Apiary" ADD COLUMN "publicLocationLabel" TEXT;
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "originCountry" TEXT NOT NULL DEFAULT 'Deutschland';
-- CreateTable
CREATE TABLE "DomainEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"actorUserId" TEXT,
"correlationId" TEXT NOT NULL,
"dedupeKey" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "DomainEventStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"processedAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "DomainEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainEventDelivery" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"handler" TEXT NOT NULL,
"status" "DomainEventDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainEventDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JobRun" (
"id" TEXT NOT NULL,
"job" TEXT NOT NULL,
"status" "JobRunStatus" NOT NULL DEFAULT 'RUNNING',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
"result" JSONB,
"error" TEXT,
CONSTRAINT "JobRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE UNLOGGED TABLE "ThrottlerHit" (
"key" TEXT NOT NULL,
"throttlerName" TEXT NOT NULL,
"hits" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"blockedUntil" TIMESTAMP(3),
CONSTRAINT "ThrottlerHit_pkey" PRIMARY KEY ("key","throttlerName")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainEvent_dedupeKey_key" ON "DomainEvent"("dedupeKey");
-- CreateIndex
CREATE INDEX "DomainEvent_status_availableAt_idx" ON "DomainEvent"("status", "availableAt");
-- CreateIndex
CREATE INDEX "DomainEvent_correlationId_idx" ON "DomainEvent"("correlationId");
-- CreateIndex
CREATE INDEX "DomainEvent_aggregateType_aggregateId_idx" ON "DomainEvent"("aggregateType", "aggregateId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainEventDelivery_eventId_handler_key" ON "DomainEventDelivery"("eventId", "handler");
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_availableAt_idx" ON "WebhookDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "WebhookDelivery_subscriptionId_createdAt_idx" ON "WebhookDelivery"("subscriptionId", "createdAt");
-- CreateIndex
CREATE INDEX "JobRun_job_startedAt_idx" ON "JobRun"("job", "startedAt");
-- CreateIndex
CREATE INDEX "JobRun_status_idx" ON "JobRun"("status");
-- CreateIndex
CREATE INDEX "ThrottlerHit_expiresAt_idx" ON "ThrottlerHit"("expiresAt");
-- AddForeignKey
ALTER TABLE "DomainEventDelivery" ADD CONSTRAINT "DomainEventDelivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "DomainEvent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "Inspection" ADD COLUMN "broodFrames" INTEGER;
+152 -29
View File
@@ -78,8 +78,8 @@ model User {
// TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false)
mfaSecret String?
mfaEnabled Boolean @default(false)
mfaSecret String?
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
// wird danach nie wieder akzeptiert (Replay-Schutz).
mfaLastTimeStep Int?
@@ -88,7 +88,7 @@ model User {
// ab 5 ist das Konto bis authLockedUntil gesperrt.
failedAuthAttempts Int @default(0)
authLockedUntil DateTime?
avatarPath String?
avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
// die vor diesem Zeitpunkt ausgestellt wurden (siehe
@@ -170,16 +170,16 @@ model ConsumedToken {
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
sessionStartedAt DateTime @default(now())
sessionStartedAt DateTime @default(now())
@@index([userId])
}
@@ -223,23 +223,25 @@ enum ApiaryStatus {
}
model Apiary {
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
id String @id @default(cuid())
name String
description String?
location String?
latitude Float?
longitude Float?
address String?
// Coarse, public-safe place name for storefront/shop output (never the exact address).
publicLocationLabel String?
status ApiaryStatus @default(ACTIVE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
@@ -454,6 +456,7 @@ model Inspection {
weather String?
temperature Float?
colonyStrength Int?
broodFrames Int?
broodAmount String?
broodPattern Int?
openBroodPresent Boolean?
@@ -761,8 +764,8 @@ model Harvest {
notes String?
createdAt DateTime @default(now())
colonies HarvestColony[]
extractions ExtractionHarvest[]
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
@@ -1300,6 +1303,8 @@ model WebhookSubscription {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]
@@index([active])
}
@@ -1403,6 +1408,8 @@ model SiteSettings {
// part of any public page.
holderName String?
holderAddress String?
// Country of origin printed on labels and shop output.
originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt
}
@@ -1521,3 +1528,119 @@ model CalendarSyncOutboxEntry {
@@unique([sourceType, sourceId])
@@index([status])
}
// ---------------------------------------------------------------------------
// Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe,
// Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen.
// ---------------------------------------------------------------------------
enum DomainEventStatus {
PENDING
PROCESSING
DONE
FAILED
}
model DomainEvent {
id String @id @default(cuid())
type String
aggregateType String
aggregateId String
payload Json
actorUserId String?
correlationId String
// Nullable @unique instead of a partial index: avoids the migration drift
// described in the backbone spec (section 3.5).
dedupeKey String? @unique
occurredAt DateTime @default(now())
status DomainEventStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
processedAt DateTime?
lastError String?
deliveries DomainEventDelivery[]
@@index([status, availableAt])
@@index([correlationId])
@@index([aggregateType, aggregateId])
}
enum DomainEventDeliveryStatus {
PENDING
DONE
FAILED
}
model DomainEventDelivery {
id String @id @default(cuid())
eventId String
event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
handler String
status DomainEventDeliveryStatus @default(PENDING)
attempts Int @default(0)
lastError String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, handler])
}
enum WebhookDeliveryStatus {
PENDING
PROCESSING
DELIVERED
FAILED
}
model WebhookDelivery {
id String @id @default(cuid())
subscriptionId String
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
event String
body String
status WebhookDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([status, availableAt])
@@index([subscriptionId, createdAt])
}
enum JobRunStatus {
RUNNING
SUCCEEDED
FAILED
}
model JobRun {
id String @id @default(cuid())
job String
status JobRunStatus @default(RUNNING)
startedAt DateTime @default(now())
finishedAt DateTime?
result Json?
error String?
@@index([job, startedAt])
@@index([status])
}
// UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost.
model ThrottlerHit {
key String
throttlerName String
hits Int
expiresAt DateTime
blockedUntil DateTime?
@@id([key, throttlerName])
@@index([expiresAt])
}