From af9df0afe438122bdc40569c394be2696814c7a1 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 09:20:04 +0200 Subject: [PATCH] feat: Datenmodell Sprachnotizen (T7 L2 Task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migration.sql | 96 +++++++++++++ ApiCore-Backend/prisma/schema.prisma | 72 ++++++++++ .../auth/permissions/permission-catalog.ts | 2 + .../src/backup/backup.service.spec.ts | 14 ++ ApiCore-Backend/src/backup/backup.service.ts | 4 + ApiCore-Backend/src/prisma/row-lock.spec.ts | 1 + ApiCore-Backend/src/prisma/row-lock.ts | 1 + ApiCore-Backend/test/search.e2e-spec.ts | 32 +++++ ApiCore-Backend/test/t7-schema.e2e-spec.ts | 134 ++++++++++++++++++ .../test/voice-permissions.e2e-spec.ts | 56 ++++++++ 10 files changed, 412 insertions(+) create mode 100644 ApiCore-Backend/prisma/migrations/20261019100000_t7_voice_captures/migration.sql create mode 100644 ApiCore-Backend/test/t7-schema.e2e-spec.ts create mode 100644 ApiCore-Backend/test/voice-permissions.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20261019100000_t7_voice_captures/migration.sql b/ApiCore-Backend/prisma/migrations/20261019100000_t7_voice_captures/migration.sql new file mode 100644 index 0000000..885c5ba --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261019100000_t7_voice_captures/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index f20fefa..25888db 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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()) +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 10c0f04..bd863ad 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -415,6 +415,8 @@ export const PERMISSION_CATALOG = [ // Beute in 3D: hive systems and their part-type catalog. Reading the // catalog and writing a colony's setup use the colonies permissions. ...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900), + // T7 Freihand: voice notes. Every endpoint also needs inspections:create. + ...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 6fa0ba5..526a07f 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -1536,6 +1536,20 @@ describe('BackupService', () => { ); }); + it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => { + const bundle = await service.exportAll(); + + for (const [model, key] of [ + ['VoiceCapture', 'voiceCapture'], + ['VoiceCaptureAudio', 'voiceCaptureAudio'], + ] as const) { + expect(bundle.data).not.toHaveProperty(key); + expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch( + /ephemeral, personal data, not deliverable after a restore/, + ); + } + }); + it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => { await importData({ apiary: [{ id: 'apiary-1' }], diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 4b7e041..7157386 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -158,6 +158,10 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly< NotificationDelivery: 'Delivery queue - restoring it would resend old messages.', TelegramPollState: 'Update offset and poll lease of this deployment.', + VoiceCapture: + 'Voice notes are ephemeral, personal data, not deliverable after a restore (content is deleted after at most 7 days).', + VoiceCaptureAudio: + 'Encrypted audio of voice notes: ephemeral, personal data, not deliverable after a restore.', }; const DOCUMENT_ENTITY_TYPES = new Set( diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts index fa235c4..ba938ec 100644 --- a/ApiCore-Backend/src/prisma/row-lock.spec.ts +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -259,6 +259,7 @@ describe('lock keys per table', () => { 'AuthorityNotice', 'ApiaryRestriction', 'ComplianceAcknowledgement', + 'VoiceCapture', ]); }); diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts index 7ee5f6b..cad6e95 100644 --- a/ApiCore-Backend/src/prisma/row-lock.ts +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -59,6 +59,7 @@ const LOCKABLE_TABLES = { AuthorityNotice: lockable('AuthorityNotice'), ApiaryRestriction: lockable('ApiaryRestriction'), ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'), + VoiceCapture: lockable('VoiceCapture'), } as const; export type LockableTable = keyof typeof LOCKABLE_TABLES; diff --git a/ApiCore-Backend/test/search.e2e-spec.ts b/ApiCore-Backend/test/search.e2e-spec.ts index cb541cc..601c735 100644 --- a/ApiCore-Backend/test/search.e2e-spec.ts +++ b/ApiCore-Backend/test/search.e2e-spec.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; @@ -95,3 +97,33 @@ describe('Search and filters (e2e)', () => { expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]); }); }); + +describe('Voice capture content guard', () => { + // VoiceCapture holds personal data that is deleted after at most 7 days. It + // must never be reachable through search or any export/report builder. + const sourceRoots = ['search', 'export'].map((dir) => + path.resolve(__dirname, '../src', dir), + ); + + function sourceFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith('.ts') ? [full] : []; + }); + } + + it('VoiceCapture appears in no search provider and no export', () => { + const files = sourceRoots.flatMap(sourceFiles); + + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + expect({ + file: path.basename(file), + mentions: /voiceCapture|VoiceCapture/.test( + fs.readFileSync(file, 'utf8'), + ), + }).toEqual({ file: path.basename(file), mentions: false }); + } + }); +}); diff --git a/ApiCore-Backend/test/t7-schema.e2e-spec.ts b/ApiCore-Backend/test/t7-schema.e2e-spec.ts new file mode 100644 index 0000000..aa682bd --- /dev/null +++ b/ApiCore-Backend/test/t7-schema.e2e-spec.ts @@ -0,0 +1,134 @@ +import { PrismaService } from '../src/prisma/prisma.service'; + +/** + * Schema-level guarantees of the T7 migration that the Prisma schema alone + * cannot express (CHECK constraints, cascade) against a real PostgreSQL. The + * test database is shared and not empty: every row here is created by this + * file and removed again. + */ +describe('T7 schema (e2e)', () => { + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const CHECK_VIOLATION = /check constraint|violates/i; + + let userId: string; + let apiaryId: string; + let colonyId: string; + let inspectionId: string; + + function capture(extra: Record = {}) { + return prisma.voiceCapture.create({ + data: { + ownerUserId: userId, + colonyId, + inspectionDate: new Date('2026-10-16'), + source: 'AUDIO', + contentExpiresAt: new Date('2026-10-23T00:00:00Z'), + ...extra, + }, + }); + } + + beforeAll(async () => { + prisma = new PrismaService(); + const role = await prisma.role.findUniqueOrThrow({ + where: { key: 'READONLY' }, + }); + const user = await prisma.user.create({ + data: { + email: `t7-schema-${suffix}@example.com`, + passwordHash: 'x', + name: 'T7 schema', + roleId: role.id, + }, + }); + userId = user.id; + const apiary = await prisma.apiary.create({ + data: { name: `T7 Stand ${suffix}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { code: `T7-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY' }, + }); + colonyId = colony.id; + const inspection = await prisma.inspection.create({ + data: { colonyId, userId, date: new Date('2026-10-16') }, + }); + inspectionId = inspection.id; + }); + + afterEach(async () => { + await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); + }); + + afterAll(async () => { + await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); + await prisma.inspection.deleteMany({ where: { id: inspectionId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + }); + + it('accepts a plain queued audio capture with defaults', async () => { + const row = await capture({ + audioMime: 'audio/webm', + audioBytes: 2097152, + durationSec: 240, + }); + + expect(row.status).toBe('QUEUED'); + expect(row.attempts).toBe(0); + expect(row.claimToken).toBeNull(); + }); + + it('rejects audioBytes 0 and 2097153', async () => { + await expect(capture({ audioBytes: 0 })).rejects.toThrow(CHECK_VIOLATION); + await expect(capture({ audioBytes: 2097153 })).rejects.toThrow( + CHECK_VIOLATION, + ); + }); + + it('rejects durationSec 241', async () => { + await expect(capture({ durationSec: 241 })).rejects.toThrow( + CHECK_VIOLATION, + ); + await expect(capture({ durationSec: 0 })).rejects.toThrow(CHECK_VIOLATION); + }); + + it('rejects CONFIRMED without inspectionId and inspectionId on a non-confirmed row', async () => { + await expect(capture({ status: 'CONFIRMED' })).rejects.toThrow( + CHECK_VIOLATION, + ); + await expect(capture({ status: 'READY', inspectionId })).rejects.toThrow( + CHECK_VIOLATION, + ); + const confirmed = await capture({ status: 'CONFIRMED', inspectionId }); + expect(confirmed.inspectionId).toBe(inspectionId); + }); + + it('rejects audioMime on a TEXT capture', async () => { + await expect( + capture({ source: 'TEXT', audioMime: 'audio/webm' }), + ).rejects.toThrow(CHECK_VIOLATION); + const text = await capture({ source: 'TEXT' }); + expect(text.audioMime).toBeNull(); + }); + + it('deleting a capture cascades to its audio row', async () => { + const row = await capture({ + audioMime: 'audio/webm', + audioBytes: 3, + audio: { create: { dataEnc: Buffer.from([1, 2, 3]) } }, + }); + expect( + await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }), + ).toBe(1); + + await prisma.voiceCapture.delete({ where: { id: row.id } }); + + expect( + await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }), + ).toBe(0); + }); +}); diff --git a/ApiCore-Backend/test/voice-permissions.e2e-spec.ts b/ApiCore-Backend/test/voice-permissions.e2e-spec.ts new file mode 100644 index 0000000..c8ea1a0 --- /dev/null +++ b/ApiCore-Backend/test/voice-permissions.e2e-spec.ts @@ -0,0 +1,56 @@ +import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog'; +import { PrismaService } from '../src/prisma/prisma.service'; + +const KEY = 'voice-captures:use'; + +/** + * The migration grants the T7 permission to the system roles (the boot-time + * catalog sync only grants new permissions to ADMIN). Checked against the + * migrated test database. + */ +describe('T7 voice permissions (e2e)', () => { + let prisma: PrismaService; + + beforeAll(() => { + prisma = new PrismaService(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + async function holds(roleKey: string): Promise { + const grant = await prisma.rolePermission.findFirst({ + where: { role: { key: roleKey }, permission: { key: KEY } }, + }); + return grant !== null; + } + + it('ADMIN, BEEKEEPER and STAFF hold voice-captures:use', async () => { + expect(await holds('ADMIN')).toBe(true); + expect(await holds('BEEKEEPER')).toBe(true); + expect(await holds('STAFF')).toBe(true); + }); + + it('READONLY and API_CLIENT do not', async () => { + expect(await holds('READONLY')).toBe(false); + expect(await holds('API_CLIENT')).toBe(false); + }); + + it('matches the code catalog in the Sprache group at sortOrder 4000', async () => { + const entry = PERMISSION_CATALOG.find((e) => e.key === KEY); + const row = await prisma.permission.findUniqueOrThrow({ + where: { key: KEY }, + }); + + expect(entry).toBeDefined(); + expect(row).toMatchObject({ + resource: 'voice-captures', + action: 'use', + groupDe: 'Sprache', + labelDe: 'Benutzen', + sortOrder: 4000, + }); + expect(entry).toMatchObject({ groupDe: 'Sprache', sortOrder: 4000 }); + }); +});