b164ef945c
- MailInternalDto accepts attachments[]; deposit() stores each as a media part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF) - ingest now persists part sizeBytes; contract + DTO carry it - threads.getMessages returns mimeType + sizeBytes so mail readers can render inline images / file chips - test: internal mail stores an image attachment as a MEDIA_REF part Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
8.4 KiB
TypeScript
237 lines
8.4 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import {
|
|
CloudEvent,
|
|
IIOS_EVENTS,
|
|
IiosPlatformPorts,
|
|
IngestInteractionRequest,
|
|
IngestInteractionResponse,
|
|
} from '@insignia/iios-contracts';
|
|
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
|
import { decideOrThrow } from '../platform/fail-closed';
|
|
|
|
/**
|
|
* Normalizes and stores one inbound interaction (P1: native portal only).
|
|
*
|
|
* Order: fail-closed OPA gate → resolve scope → resolve handle (MDM, no silent
|
|
* merge) → actor → channel → thread → (idempotent) write interaction + parts +
|
|
* outbox event in ONE transaction. Idempotency key is unique per (scope, key).
|
|
*/
|
|
@Injectable()
|
|
export class IngestService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
|
) {}
|
|
|
|
async ingest(req: IngestInteractionRequest, idempotencyKey: string): Promise<IngestInteractionResponse> {
|
|
// 1. Fail-closed authorization. Throws PolicyDeniedError → nothing written.
|
|
const decision = await decideOrThrow(this.ports, {
|
|
action: 'iios.interaction.ingest',
|
|
scope: req.scope,
|
|
channel: req.channel.type,
|
|
});
|
|
|
|
// 2. Resolve (or create) the scope snapshot.
|
|
const scope =
|
|
(await this.prisma.iiosScope.findFirst({
|
|
where: {
|
|
orgId: req.scope.orgId,
|
|
appId: req.scope.appId,
|
|
tenantId: req.scope.tenantId ?? null,
|
|
workspaceId: req.scope.workspaceId ?? null,
|
|
},
|
|
})) ??
|
|
(await this.prisma.iiosScope.create({
|
|
data: {
|
|
orgId: req.scope.orgId,
|
|
appId: req.scope.appId,
|
|
buId: req.scope.buId,
|
|
tenantId: req.scope.tenantId,
|
|
workspaceId: req.scope.workspaceId,
|
|
projectId: req.scope.projectId,
|
|
userScopeId: req.scope.userScopeId,
|
|
},
|
|
}));
|
|
|
|
// 3. Resolve the source handle via MDM. Unknown/ambiguous stays UNVERIFIED
|
|
// with a null canonical id — no silent identity merge (Critics KG-01).
|
|
const resolution = await this.ports.mdm.resolveSourceHandle({
|
|
scope: req.scope,
|
|
handleKind: req.source.handleKind,
|
|
externalId: req.source.externalId,
|
|
});
|
|
const isResolved = resolution.status === 'RESOLVED' && !!resolution.canonicalEntityId;
|
|
|
|
const handle = await this.prisma.iiosSourceHandle.upsert({
|
|
where: {
|
|
scopeId_kind_externalId: {
|
|
scopeId: scope.id,
|
|
kind: req.source.handleKind,
|
|
externalId: req.source.externalId,
|
|
},
|
|
},
|
|
create: {
|
|
scopeId: scope.id,
|
|
kind: req.source.handleKind,
|
|
externalId: req.source.externalId,
|
|
displayName: req.source.displayName,
|
|
canonicalEntityId: isResolved ? resolution.canonicalEntityId : null,
|
|
confidence: resolution.confidence,
|
|
verificationState: isResolved ? 'VERIFIED' : 'UNVERIFIED',
|
|
},
|
|
update: {
|
|
lastSeenAt: new Date(),
|
|
displayName: req.source.displayName,
|
|
confidence: resolution.confidence,
|
|
...(isResolved
|
|
? { canonicalEntityId: resolution.canonicalEntityId, verificationState: 'VERIFIED' }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
// 4. Resolve (or create) the actor behind the handle.
|
|
const actor =
|
|
(await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } })) ??
|
|
(await this.prisma.iiosActorRef.create({
|
|
data: {
|
|
kind: 'HUMAN',
|
|
sourceHandleId: handle.id,
|
|
displayName: req.source.displayName,
|
|
canonicalEntityId: isResolved ? resolution.canonicalEntityId : null,
|
|
},
|
|
}));
|
|
|
|
// 5. Resolve (or create) the channel.
|
|
const channel =
|
|
(await this.prisma.iiosChannel.findFirst({
|
|
where: { scopeId: scope.id, channelType: req.channel.type, externalRef: req.channel.externalChannelId ?? null },
|
|
})) ??
|
|
(await this.prisma.iiosChannel.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
channelType: req.channel.type,
|
|
channelName: req.channel.externalChannelId ?? req.channel.type,
|
|
externalRef: req.channel.externalChannelId,
|
|
capabilityContract: (req.channel.capabilityContract ?? undefined) as Prisma.InputJsonValue | undefined,
|
|
},
|
|
}));
|
|
|
|
// 6. Resolve (or create) the thread.
|
|
const thread =
|
|
(req.thread?.externalThreadId
|
|
? await this.prisma.iiosThread.findFirst({
|
|
where: { scopeId: scope.id, externalThreadRef: req.thread.externalThreadId },
|
|
})
|
|
: null) ??
|
|
(await this.prisma.iiosThread.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
channelId: channel.id,
|
|
externalThreadRef: req.thread?.externalThreadId,
|
|
subject: req.thread?.subject,
|
|
createdByActorId: actor.id,
|
|
},
|
|
}));
|
|
|
|
// 7. Idempotency: if this (scope, key) already produced an interaction, return it.
|
|
const existing = await this.prisma.iiosInteraction.findUnique({
|
|
where: { scopeId_idempotencyKey: { scopeId: scope.id, idempotencyKey } },
|
|
});
|
|
if (existing) {
|
|
return {
|
|
interactionId: existing.id,
|
|
sourceHandleId: handle.id,
|
|
threadId: thread.id,
|
|
state: 'DUPLICATE',
|
|
next: [],
|
|
};
|
|
}
|
|
|
|
// 8. Write interaction + parts + outbox event atomically.
|
|
try {
|
|
const interaction = await this.prisma.$transaction(async (tx) => {
|
|
const created = await tx.iiosInteraction.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
kind: req.kind ?? 'MESSAGE',
|
|
threadId: thread.id,
|
|
channelId: channel.id,
|
|
actorId: actor.id,
|
|
idempotencyKey,
|
|
externalId: req.providerEventId,
|
|
occurredAt: new Date(req.occurredAt),
|
|
status: 'NORMALIZED',
|
|
traceId: req.traceId,
|
|
policyDecisionRef: decision.decisionRef,
|
|
metadata: (req.metadata ?? undefined) as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
|
|
await tx.iiosMessagePart.createMany({
|
|
data: req.parts.map((p, i) => ({
|
|
interactionId: created.id,
|
|
partIndex: i,
|
|
kind: p.kind,
|
|
bodyText: p.bodyText,
|
|
contentRef: p.contentRef,
|
|
mimeType: p.mimeType,
|
|
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
|
|
})),
|
|
});
|
|
|
|
const event: CloudEvent = {
|
|
specversion: '1.0',
|
|
id: `evt_${created.id}`,
|
|
type: IIOS_EVENTS.interactionNormalized,
|
|
source: `iios/ingest/${scope.appId}`,
|
|
subject: `interaction/${created.id}`,
|
|
time: new Date().toISOString(),
|
|
datacontenttype: 'application/json',
|
|
traceparent: toTraceparent(created.traceId ?? currentTraceId() ?? newTraceId()),
|
|
insignia: {
|
|
scopeSnapshotId: scope.id,
|
|
correlationId: created.traceId ?? currentTraceId() ?? newTraceId(),
|
|
policyDecisionId: decision.decisionRef,
|
|
idempotencyKey,
|
|
dataClass: 'internal',
|
|
},
|
|
data: { interactionId: created.id, threadId: thread.id, kind: req.kind ?? 'MESSAGE' },
|
|
};
|
|
|
|
await tx.iiosOutboxEvent.create({
|
|
data: {
|
|
aggregateType: 'interaction',
|
|
aggregateId: created.id,
|
|
eventType: IIOS_EVENTS.interactionNormalized,
|
|
cloudEvent: event as unknown as Prisma.InputJsonValue,
|
|
partitionKey: `${scope.orgId}:${scope.appId}:${thread.id}`,
|
|
},
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
return {
|
|
interactionId: interaction.id,
|
|
sourceHandleId: handle.id,
|
|
threadId: thread.id,
|
|
state: 'NORMALIZED',
|
|
next: ['message.persisted'],
|
|
};
|
|
} catch (err) {
|
|
// Idempotency race: a concurrent ingest with the same key won the unique
|
|
// constraint. Return the winner rather than duplicating.
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
|
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
|
|
where: { scopeId_idempotencyKey: { scopeId: scope.id, idempotencyKey } },
|
|
});
|
|
return { interactionId: winner.id, sourceHandleId: handle.id, threadId: thread.id, state: 'DUPLICATE', next: [] };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|