feat(iios): DSR right-to-erasure (redact-in-place) + compliance hold (P9)
Adds IiosComplianceHold + IiosSourceHandle.redactedAt and a DsrService whose eraseSubject redacts the caller's PII in place (message bodies, identity display, raw payloads, support/inbox/transcript text) while preserving externalId (source preservation), structure, and the audit trail. An active compliance hold blocks erasure until released; every op is audited and tenant-fenced. Erasure is idempotent (KG-03). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "IiosSourceHandle" ADD COLUMN "redactedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosComplianceHold" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT NOT NULL,
|
||||||
|
"holdReason" TEXT NOT NULL,
|
||||||
|
"appliedByActorId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"releasedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "IiosComplianceHold_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosComplianceHold_scopeId_targetType_targetId_status_idx" ON "IiosComplianceHold"("scopeId", "targetType", "targetId", "status");
|
||||||
@@ -326,6 +326,7 @@ model IiosSourceHandle {
|
|||||||
firstSeenAt DateTime @default(now())
|
firstSeenAt DateTime @default(now())
|
||||||
lastSeenAt DateTime @default(now())
|
lastSeenAt DateTime @default(now())
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
redactedAt DateTime? // set when this subject has been erased (DSR, P9)
|
||||||
|
|
||||||
actors IiosActorRef[]
|
actors IiosActorRef[]
|
||||||
|
|
||||||
@@ -492,6 +493,23 @@ model IiosProcessedEvent {
|
|||||||
@@id([consumerName, eventId])
|
@@id([consumerName, eventId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
|
||||||
|
/// erasure/retention deletion until an authorised release. Never auto-released.
|
||||||
|
model IiosComplianceHold {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
targetType String // 'data_subject' | 'message' | 'media'
|
||||||
|
targetId String
|
||||||
|
holdReason String
|
||||||
|
appliedByActorId String?
|
||||||
|
status String @default("ACTIVE") // ACTIVE | RELEASED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime?
|
||||||
|
releasedAt DateTime?
|
||||||
|
|
||||||
|
@@index([scopeId, targetType, targetId, status])
|
||||||
|
}
|
||||||
|
|
||||||
/// Projection replay cursor (P9, KG-06). Ordered high-water-mark + rolling checksum per
|
/// Projection replay cursor (P9, KG-06). Ordered high-water-mark + rolling checksum per
|
||||||
/// (projection, topic, partition) proving replay reproduces the same projection outcome.
|
/// (projection, topic, partition) proving replay reproduces the same projection outcome.
|
||||||
model IiosProjectionCursor {
|
model IiosProjectionCursor {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { RoutingModule } from './routing/routing.module';
|
|||||||
import { AiModule } from './ai/ai.module';
|
import { AiModule } from './ai/ai.module';
|
||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
import { CapabilityModule } from './capability/capability.module';
|
import { CapabilityModule } from './capability/capability.module';
|
||||||
|
import { DsrModule } from './dsr/dsr.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { MetricsController } from './observability/metrics.controller';
|
import { MetricsController } from './observability/metrics.controller';
|
||||||
import { DevController } from './dev/dev.controller';
|
import { DevController } from './dev/dev.controller';
|
||||||
@@ -37,6 +38,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
AiModule,
|
AiModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
CapabilityModule,
|
CapabilityModule,
|
||||||
|
DsrModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController, DevController],
|
controllers: [HealthController, MetricsController, DevController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Post } from '@nestjs/common';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { DsrService } from './dsr.service';
|
||||||
|
import { PlaceHoldDto } from './dsr.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-subject rights (P9 DSR). Tenant-scoped: every op acts within the caller's own
|
||||||
|
* scope. Erasure is self-service (the caller erases their own subject); holds are
|
||||||
|
* managed by the tenant's admins.
|
||||||
|
*/
|
||||||
|
@Controller('v1/dsr')
|
||||||
|
export class DsrController {
|
||||||
|
constructor(
|
||||||
|
private readonly dsr: DsrService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('erase')
|
||||||
|
async erase(@Headers('authorization') auth?: string) {
|
||||||
|
return this.dsr.eraseSubject(this.principal(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('holds')
|
||||||
|
async placeHold(@Body() body: PlaceHoldDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.dsr.placeHold(this.principal(auth), { targetType: body.targetType, targetId: body.targetId, reason: body.reason, expiresAt: body.expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('holds')
|
||||||
|
async listHolds(@Headers('authorization') auth?: string) {
|
||||||
|
return this.dsr.listHolds(this.principal(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('holds/:id/release')
|
||||||
|
async releaseHold(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
|
return this.dsr.releaseHold(this.principal(auth), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(authorization?: string): MessagePrincipal {
|
||||||
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class PlaceHoldDto {
|
||||||
|
@IsIn(['data_subject', 'message', 'media']) targetType!: 'data_subject' | 'message' | 'media';
|
||||||
|
@IsString() targetId!: string; // an id, or 'self' for the caller's own subject
|
||||||
|
@IsString() reason!: string;
|
||||||
|
@IsOptional() @IsString() expiresAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DsrService } from './dsr.service';
|
||||||
|
import { DsrController } from './dsr.controller';
|
||||||
|
|
||||||
|
/** Data-subject rights (P9 DSR): right-to-erasure + compliance holds. */
|
||||||
|
@Module({
|
||||||
|
controllers: [DsrController],
|
||||||
|
providers: [DsrService],
|
||||||
|
exports: [DsrService],
|
||||||
|
})
|
||||||
|
export class DsrModule {}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { SupportService } from '../support/support.service';
|
||||||
|
import { MessageService } from '../messaging/message.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
|
import { DsrService } from './dsr.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const actors = new ActorResolver(asService);
|
||||||
|
|
||||||
|
const dsr = () => new DsrService(asService, actors);
|
||||||
|
const support = () => new SupportService(asService, makeFakePorts(), actors, new IdempotencyService(asService));
|
||||||
|
const messages = () => new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
|
||||||
|
const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Jane Doe' };
|
||||||
|
|
||||||
|
/** Seed the caller as a data subject with PII: a ticket subject + an authored message body. */
|
||||||
|
async function seedSubject(p: MessagePrincipal, subject: string, body: string) {
|
||||||
|
const ticket = await support().createTicket(p, { subject });
|
||||||
|
const m = messages();
|
||||||
|
const { threadId } = await m.openThread(null, p);
|
||||||
|
const msg = await m.send(threadId, p, { content: body }, `k-${randomUUID()}`);
|
||||||
|
return { ticketId: ticket.id, interactionId: msg.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('DsrService — right-to-erasure (P9 slice 8)', () => {
|
||||||
|
it('redacts PII in place, preserves externalId, and stamps redactedAt', async () => {
|
||||||
|
const { ticketId, interactionId } = await seedSubject(cust, 'my SSN is 123-45', 'call me at 555-0100');
|
||||||
|
const before = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'cust' } });
|
||||||
|
|
||||||
|
const res = await dsr().eraseSubject(cust);
|
||||||
|
expect(res.status).toBe('ERASED');
|
||||||
|
|
||||||
|
const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } });
|
||||||
|
expect(part.bodyText).toBe('[redacted]');
|
||||||
|
const ticket = await prisma.iiosTicket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||||
|
expect(ticket.subject).toBe('[redacted]');
|
||||||
|
const handle = await prisma.iiosSourceHandle.findUniqueOrThrow({ where: { id: before.id } });
|
||||||
|
expect(handle.displayName).toBe('[redacted]');
|
||||||
|
expect(handle.externalId).toBe('cust'); // source preservation — external id kept
|
||||||
|
expect(handle.redactedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the audit trail + structure (ledgers and rows survive)', async () => {
|
||||||
|
const { ticketId, interactionId } = await seedSubject(cust, 'secret', 'secret body');
|
||||||
|
await dsr().eraseSubject(cust);
|
||||||
|
|
||||||
|
// audit link recorded, and the ticket-created ledger event still exists
|
||||||
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.subject.erased' } })).toBe(1);
|
||||||
|
expect(await prisma.iiosOutboxEvent.count()).toBeGreaterThan(0);
|
||||||
|
// structure kept: the interaction + ticket rows still exist (redacted, not deleted)
|
||||||
|
expect(await prisma.iiosInteraction.findUnique({ where: { id: interactionId } })).not.toBeNull();
|
||||||
|
expect(await prisma.iiosTicket.findUnique({ where: { id: ticketId } })).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a second erase is a no-op', async () => {
|
||||||
|
await seedSubject(cust, 's', 'b');
|
||||||
|
expect((await dsr().eraseSubject(cust)).status).toBe('ERASED');
|
||||||
|
expect((await dsr().eraseSubject(cust)).status).toBe('ALREADY_ERASED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an active compliance hold blocks erasure until released', async () => {
|
||||||
|
const { interactionId } = await seedSubject(cust, 's', 'private body');
|
||||||
|
await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });
|
||||||
|
|
||||||
|
await expect(dsr().eraseSubject(cust)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } });
|
||||||
|
expect(part.bodyText).toBe('private body'); // NOT redacted while held
|
||||||
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.erasure.blocked' } })).toBe(1);
|
||||||
|
|
||||||
|
const hold = (await dsr().listHolds(cust))[0]!;
|
||||||
|
await dsr().releaseHold(cust, hold.id);
|
||||||
|
expect((await dsr().eraseSubject(cust)).status).toBe('ERASED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds are tenant-fenced (a second tenant cannot see or release them)', async () => {
|
||||||
|
await seedSubject(cust, 's', 'b');
|
||||||
|
const hold = await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'legal' });
|
||||||
|
|
||||||
|
const other: MessagePrincipal = { userId: 'intruder', orgId: 'org_other', appId: 'portal-demo', displayName: 'B' };
|
||||||
|
expect(await dsr().listHolds(other)).toHaveLength(0);
|
||||||
|
await expect(dsr().releaseHold(other, hold.id)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
|
|
||||||
|
const REDACTED = '[redacted]';
|
||||||
|
|
||||||
|
export interface PlaceHoldInput {
|
||||||
|
targetType: string;
|
||||||
|
targetId: string;
|
||||||
|
reason: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-subject rights (P9 DSR, KG-03). Right-to-erasure is doc-mandated as
|
||||||
|
* tombstone/redact — never a hard delete: PII columns are stripped in place while
|
||||||
|
* structure, IDs, and the audit trail are preserved ("source preservation"). An active
|
||||||
|
* compliance hold blocks erasure until an authorised release. All ops are audited and
|
||||||
|
* strictly tenant-scoped (the caller can only act within their own scope).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DsrService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Erase the CALLER's own subject (self-service GDPR "erase me"). */
|
||||||
|
async eraseSubject(principal: MessagePrincipal) {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return { status: 'NOTHING_TO_ERASE' as const };
|
||||||
|
|
||||||
|
const handle = await this.prisma.iiosSourceHandle.findUnique({
|
||||||
|
where: { scopeId_kind_externalId: { scopeId: scope.id, kind: 'PORTAL_USER', externalId: principal.userId } },
|
||||||
|
include: { actors: true },
|
||||||
|
});
|
||||||
|
if (!handle) return { status: 'NOTHING_TO_ERASE' as const };
|
||||||
|
if (handle.redactedAt) return { status: 'ALREADY_ERASED' as const, subjectId: handle.id };
|
||||||
|
|
||||||
|
// A live compliance hold over this subject blocks erasure (legal hold overrides erasure).
|
||||||
|
const hold = await this.prisma.iiosComplianceHold.findFirst({
|
||||||
|
where: {
|
||||||
|
scopeId: scope.id,
|
||||||
|
targetType: 'data_subject',
|
||||||
|
targetId: handle.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (hold) {
|
||||||
|
await recordAudit(this.prisma, { action: 'dsr.erasure.blocked', resourceType: 'data_subject', resourceId: handle.id, scopeId: scope.id });
|
||||||
|
throw new ConflictException('erasure blocked by an active compliance hold');
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorIds = handle.actors.map((a) => a.id);
|
||||||
|
const interactionIds = (
|
||||||
|
await this.prisma.iiosInteraction.findMany({ where: { actorId: { in: actorIds } }, select: { id: true } })
|
||||||
|
).map((i) => i.id);
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
// Message content + raw provider payloads authored by the subject.
|
||||||
|
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: { in: interactionIds } }, data: { bodyText: REDACTED, contentRef: null } }),
|
||||||
|
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: { in: interactionIds } }, data: { payload: { redacted: true } } }),
|
||||||
|
// Support + inbox PII the subject authored/owns.
|
||||||
|
this.prisma.iiosTicket.updateMany({ where: { requesterActorId: { in: actorIds } }, data: { subject: REDACTED } }),
|
||||||
|
this.prisma.iiosCallbackRequest.updateMany({ where: { requesterActorId: { in: actorIds } }, data: { notes: null } }),
|
||||||
|
this.prisma.iiosInboxItem.updateMany({ where: { ownerActorId: { in: actorIds } }, data: { title: REDACTED, summary: null } }),
|
||||||
|
// Meeting transcript content the subject spoke; revoke their recording consent.
|
||||||
|
this.prisma.iiosTranscriptSegment.updateMany({ where: { speakerActorId: { in: actorIds } }, data: { contentRef: REDACTED } }),
|
||||||
|
this.prisma.iiosMeetingParticipant.updateMany({ where: { actorRefId: { in: actorIds } }, data: { recordingConsent: 'REVOKED' } }),
|
||||||
|
// Identity: redact member-facing display, PRESERVE externalId (source preservation).
|
||||||
|
this.prisma.iiosActorRef.updateMany({ where: { id: { in: actorIds } }, data: { displayName: REDACTED } }),
|
||||||
|
this.prisma.iiosSourceHandle.update({ where: { id: handle.id }, data: { displayName: REDACTED, metadata: Prisma.DbNull, redactedAt: new Date() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit(this.prisma, { action: 'dsr.subject.erased', resourceType: 'data_subject', resourceId: handle.id, scopeId: scope.id });
|
||||||
|
return { status: 'ERASED' as const, subjectId: handle.id, redactedInteractions: interactionIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async placeHold(principal: MessagePrincipal, input: PlaceHoldInput) {
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
let targetId = input.targetId;
|
||||||
|
if (targetId === 'self') {
|
||||||
|
const handle = await this.prisma.iiosSourceHandle.findUnique({
|
||||||
|
where: { scopeId_kind_externalId: { scopeId: scope.id, kind: 'PORTAL_USER', externalId: principal.userId } },
|
||||||
|
});
|
||||||
|
if (!handle) throw new NotFoundException('no subject exists for the caller yet');
|
||||||
|
targetId = handle.id;
|
||||||
|
}
|
||||||
|
const appliedBy = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
const hold = await this.prisma.iiosComplianceHold.create({
|
||||||
|
data: {
|
||||||
|
scopeId: scope.id,
|
||||||
|
targetType: input.targetType,
|
||||||
|
targetId,
|
||||||
|
holdReason: input.reason,
|
||||||
|
appliedByActorId: appliedBy.id,
|
||||||
|
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAudit(this.prisma, { action: 'dsr.hold.placed', resourceType: 'compliance_hold', resourceId: hold.id, scopeId: scope.id, actorRefId: appliedBy.id });
|
||||||
|
return hold;
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseHold(principal: MessagePrincipal, id: string) {
|
||||||
|
const hold = await this.prisma.iiosComplianceHold.findUnique({ where: { id } });
|
||||||
|
if (!hold) throw new NotFoundException('compliance hold not found');
|
||||||
|
const scope = await this.actors.assertOwns(principal, hold.scopeId); // KG-02 fence
|
||||||
|
const released = await this.prisma.iiosComplianceHold.update({ where: { id }, data: { status: 'RELEASED', releasedAt: new Date() } });
|
||||||
|
await recordAudit(this.prisma, { action: 'dsr.hold.released', resourceType: 'compliance_hold', resourceId: id, scopeId: scope.id });
|
||||||
|
return released;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listHolds(principal: MessagePrincipal) {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return [];
|
||||||
|
return this.prisma.iiosComplianceHold.findMany({ where: { scopeId: scope.id, status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
|
|||||||
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`TRUNCATE TABLE
|
`TRUNCATE TABLE
|
||||||
"IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
|
"IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
|
||||||
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
||||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||||
|
|||||||
Reference in New Issue
Block a user