feat: Datenmodell Sprachnotizen (T7 L2 Task 4)

VoiceCapture und VoiceCaptureAudio nach Spec §3 mit CHECKs
(audioBytes 1..2 MiB, durationSec 1..240, CONFIRMED genau mit
inspectionId, audioMime nur bei AUDIO), Recht voice-captures:use für
ADMIN, BEEKEEPER und STAFF, Sperrreihenfolge mit VoiceCapture am Ende,
Ausschluss aus dem Backup mit Begründung, Wächter gegen Suche und
Export.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:20:04 +02:00
1 parent 257286fbef
commit af9df0afe4
10 files changed
+412

No files matched your search

@@ -0,0 +1,96 @@
-- CreateEnum
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
-- CreateEnum
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
-- CreateTable
CREATE TABLE "VoiceCapture" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"inspectionDate" DATE NOT NULL,
"source" "VoiceCaptureSource" NOT NULL,
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
"audioMime" TEXT,
"audioBytes" INTEGER,
"durationSec" INTEGER,
"transcriptEnc" TEXT,
"suggestionEnc" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"claimToken" TEXT,
"lastErrorCode" TEXT,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"inspectionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"readyAt" TIMESTAMP(3),
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
"contentDeletedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceCaptureAudio" (
"captureId" TEXT NOT NULL,
"dataEnc" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
);
-- CreateIndex
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
-- CreateIndex
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
-- CreateIndex
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_durationSec_range"
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
ALTER TABLE "VoiceCapture"
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
-- Permission: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
-- and API_CLIENT get nothing: voice notes are for people who inspect.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.key = 'voice-captures:use'
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
ON CONFLICT DO NOTHING;
+72
View File
@@ -104,6 +104,7 @@ model User {
refreshTokens RefreshToken[]
inspections Inspection[]
voiceCaptures VoiceCapture[]
hiveChanges HiveChange[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
@@ -333,6 +334,7 @@ model Colony {
queenEvaluations QueenEvaluation[]
suggestions Suggestion[]
hiveSetup HiveSetup?
voiceCaptures VoiceCapture[]
@@index([apiaryId])
@@index([status])
@@ -2285,3 +2287,73 @@ enum HiveChangeSource {
INSPECTION
AI_SUGGESTION
}
enum VoiceCaptureSource {
AUDIO
TEXT
}
enum VoiceCaptureStatus {
QUEUED // audio uploaded, waits for Whisper
TRANSCRIBING // claimed by the CronJob
TRANSCRIBED // text present, waits for Claude (also source TEXT)
EXTRACTING // claimed by the worker
READY // suggestions present, waits for the user
CONFIRMED // inspection saved, content deleted
DISCARDED // discarded by the user, content deleted
FAILED // finally failed, code in lastErrorCode
EXPIRED // deadline passed, content deleted
}
// A voice note. Audio and text are personal data: content lives here only
// encrypted and only until it is taken over, at most 7 days. The row itself
// holds no content and stays 90 days for the error and cost overview.
model VoiceCapture {
id String @id @default(cuid())
ownerUserId String
owner User @relation(fields: [ownerUserId], references: [id])
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
inspectionDate DateTime @db.Date // date of the inspection per the form
source VoiceCaptureSource
status VoiceCaptureStatus @default(QUEUED)
audioMime String? // audio/webm, audio/ogg, audio/mp4
audioBytes Int?
durationSec Int? // reported by the device; Whisper measures again
transcriptEnc String? // key-ring ciphertext
suggestionEnc String? // key-ring ciphertext of the checked suggestion
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
claimToken String? // guards against completion by a process that lost the claim
lastErrorCode String? // fixed code, never content
model String? // e.g. "claude-sonnet-5-5"
inputTokens Int?
outputTokens Int?
inspectionId String? // set when CONFIRMED
createdAt DateTime @default(now())
readyAt DateTime?
contentExpiresAt DateTime // createdAt + 7 days
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
updatedAt DateTime @updatedAt
audio VoiceCaptureAudio?
@@index([status, availableAt])
@@index([ownerUserId, status])
@@index([contentExpiresAt])
}
// The audio lives in its own table: deleting is a DELETE of the row (not
// nulling a big column), and the row is never loaded into lists.
model VoiceCaptureAudio {
captureId String @id
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
createdAt DateTime @default(now())
}