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:
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;
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user