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[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
voiceCaptures VoiceCapture[]
hiveChanges HiveChange[] hiveChanges HiveChange[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[] swarmControlChecks SwarmControlCheck[]
@@ -333,6 +334,7 @@ model Colony {
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
suggestions Suggestion[] suggestions Suggestion[]
hiveSetup HiveSetup? hiveSetup HiveSetup?
voiceCaptures VoiceCapture[]
@@index([apiaryId]) @@index([apiaryId])
@@index([status]) @@index([status])
@@ -2285,3 +2287,73 @@ enum HiveChangeSource {
INSPECTION INSPECTION
AI_SUGGESTION 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())
}
@@ -415,6 +415,8 @@ export const PERMISSION_CATALOG = [
// Beute in 3D: hive systems and their part-type catalog. Reading the // Beute in 3D: hive systems and their part-type catalog. Reading the
// catalog and writing a colony's setup use the colonies permissions. // catalog and writing a colony's setup use the colonies permissions.
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900), ...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[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -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 () => { it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
await importData({ await importData({
apiary: [{ id: 'apiary-1' }], apiary: [{ id: 'apiary-1' }],
@@ -158,6 +158,10 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
NotificationDelivery: NotificationDelivery:
'Delivery queue - restoring it would resend old messages.', 'Delivery queue - restoring it would resend old messages.',
TelegramPollState: 'Update offset and poll lease of this deployment.', 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<string>( const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -259,6 +259,7 @@ describe('lock keys per table', () => {
'AuthorityNotice', 'AuthorityNotice',
'ApiaryRestriction', 'ApiaryRestriction',
'ComplianceAcknowledgement', 'ComplianceAcknowledgement',
'VoiceCapture',
]); ]);
}); });
+1
View File
@@ -59,6 +59,7 @@ const LOCKABLE_TABLES = {
AuthorityNotice: lockable('AuthorityNotice'), AuthorityNotice: lockable('AuthorityNotice'),
ApiaryRestriction: lockable('ApiaryRestriction'), ApiaryRestriction: lockable('ApiaryRestriction'),
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'), ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
VoiceCapture: lockable('VoiceCapture'),
} as const; } as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES; export type LockableTable = keyof typeof LOCKABLE_TABLES;
+32
View File
@@ -1,3 +1,5 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
@@ -95,3 +97,33 @@ describe('Search and filters (e2e)', () => {
expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]); 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 });
}
});
});
+134
View File
@@ -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<string, unknown> = {}) {
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);
});
});
@@ -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<boolean> {
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 });
});
});