6edb0cc0cc
Task 8.2: requestMeeting + fromCallback (bridge) + fromEventClaim (accepted P7 AI EVENT claim, human-confirmed) + direct scheduleDirect. schedule() fails closed via opa, creates meeting + organizer/attendee participants + calendar_event, closes the request, and populates IiosCallbackRequest.meetingRef when bridged. 5 tests: direct/callback/AI genesis, un-accepted claim refused, opa-deny fail-closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.5 KiB
TypeScript
218 lines
8.5 KiB
TypeScript
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma, IiosMeetingType } from '@prisma/client';
|
|
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
|
import { decideOrThrow } from '../platform/fail-closed';
|
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
|
|
|
export interface AttendeeInput {
|
|
userId: string;
|
|
displayName?: string;
|
|
role?: 'REQUIRED' | 'OPTIONAL';
|
|
visibility?: 'FULL' | 'LIMITED' | 'NONE';
|
|
}
|
|
|
|
export interface RequestMeetingInput {
|
|
interactionId?: string;
|
|
targetActorId?: string;
|
|
requestedWindow: Record<string, unknown>;
|
|
meetingType?: IiosMeetingType;
|
|
callbackRequestId?: string;
|
|
sourceClaimId?: string;
|
|
}
|
|
|
|
export interface ScheduleInput {
|
|
requestId?: string;
|
|
meetingType: IiosMeetingType;
|
|
title: string;
|
|
startAt: string;
|
|
endAt?: string;
|
|
timezone?: string;
|
|
attendees?: AttendeeInput[];
|
|
}
|
|
|
|
/**
|
|
* Calendar & Meeting SDK (P8). A meeting is an interaction specialization: it owns
|
|
* calendar semantics and imports message/inbox. Meetings arrive via three genesis
|
|
* paths (callback bridge / direct / accepted AI EVENT claim) that converge on a
|
|
* meeting_request → schedule. Recording artifacts are gated elsewhere (consent).
|
|
*/
|
|
@Injectable()
|
|
export class CalendarService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
|
private readonly actors: ActorResolver,
|
|
) {}
|
|
|
|
// ── Genesis: meeting requests ────────────────────────────────────
|
|
async requestMeeting(principal: MessagePrincipal, input: RequestMeetingInput) {
|
|
const scope = await this.actors.resolveScope(principal);
|
|
const requester = await this.actors.resolveActor(scope.id, principal);
|
|
const request = await this.prisma.iiosMeetingRequest.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
interactionId: input.interactionId,
|
|
requestedByActorId: requester.id,
|
|
targetActorId: input.targetActorId,
|
|
requestedWindowJson: input.requestedWindow as Prisma.InputJsonValue,
|
|
meetingType: input.meetingType ?? 'CALLBACK',
|
|
callbackRequestId: input.callbackRequestId,
|
|
sourceClaimId: input.sourceClaimId,
|
|
},
|
|
});
|
|
await this.emit(IIOS_EVENTS.meetingRequested, scope.id, request.id, { requestId: request.id });
|
|
return request;
|
|
}
|
|
|
|
/** Callback→meeting bridge (the Atlas's headline path). */
|
|
async fromCallback(principal: MessagePrincipal, callbackId: string, requestedWindow: Record<string, unknown>) {
|
|
const callback = await this.prisma.iiosCallbackRequest.findUnique({ where: { id: callbackId } });
|
|
if (!callback) throw new NotFoundException('callback request not found');
|
|
return this.requestMeeting(principal, {
|
|
requestedWindow,
|
|
meetingType: 'CALLBACK',
|
|
callbackRequestId: callback.id,
|
|
});
|
|
}
|
|
|
|
/** P7→P8 tie: an ACCEPTED AI EVENT claim becomes a meeting request (human-confirmed). */
|
|
async fromEventClaim(principal: MessagePrincipal, claimId: string, requestedWindow?: Record<string, unknown>) {
|
|
const claim = await this.prisma.iiosAiClaim.findUnique({ where: { id: claimId } });
|
|
if (!claim || claim.claimType !== 'EVENT') throw new NotFoundException('AI EVENT claim not found');
|
|
if (claim.validationStatus !== 'ACCEPTED') {
|
|
throw new BadRequestException('AI event claim must be human-accepted before it can become a meeting');
|
|
}
|
|
const json = (claim.claimJson ?? {}) as { title?: string; when?: string };
|
|
return this.requestMeeting(principal, {
|
|
requestedWindow: requestedWindow ?? { when: json.when ?? 'unspecified' },
|
|
meetingType: 'INTERNAL',
|
|
sourceClaimId: claim.id,
|
|
});
|
|
}
|
|
|
|
// ── Scheduling ───────────────────────────────────────────────────
|
|
async schedule(principal: MessagePrincipal, input: ScheduleInput) {
|
|
const scope = await this.actors.resolveScope(principal);
|
|
await decideOrThrow(this.ports, { action: 'iios.meeting.schedule', scopeId: scope.id, meetingType: input.meetingType });
|
|
const organizer = await this.actors.resolveActor(scope.id, principal);
|
|
|
|
const meeting = await this.prisma.iiosMeeting.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
meetingType: input.meetingType,
|
|
status: 'SCHEDULED',
|
|
organizerActorId: organizer.id,
|
|
title: input.title,
|
|
startAt: new Date(input.startAt),
|
|
endAt: input.endAt ? new Date(input.endAt) : undefined,
|
|
timezone: input.timezone ?? 'UTC',
|
|
},
|
|
});
|
|
|
|
// Organizer is a full-visibility participant.
|
|
await this.prisma.iiosMeetingParticipant.create({
|
|
data: { meetingId: meeting.id, actorRefId: organizer.id, role: 'ORGANIZER', attendanceStatus: 'ACCEPTED' },
|
|
});
|
|
for (const a of input.attendees ?? []) {
|
|
const actor = await this.actors.resolveActor(scope.id, {
|
|
userId: a.userId,
|
|
orgId: principal.orgId,
|
|
appId: principal.appId,
|
|
displayName: a.displayName,
|
|
});
|
|
await this.prisma.iiosMeetingParticipant.upsert({
|
|
where: { meetingId_actorRefId: { meetingId: meeting.id, actorRefId: actor.id } },
|
|
create: { meetingId: meeting.id, actorRefId: actor.id, role: a.role ?? 'REQUIRED', visibility: a.visibility ?? 'FULL' },
|
|
update: {},
|
|
});
|
|
}
|
|
|
|
// A meeting owns a calendar event (the neutral event record).
|
|
const calEvent = await this.prisma.iiosCalendarEvent.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
title: input.title,
|
|
startAt: new Date(input.startAt),
|
|
endAt: input.endAt ? new Date(input.endAt) : new Date(input.startAt),
|
|
status: 'CONFIRMED',
|
|
},
|
|
});
|
|
await this.prisma.iiosMeeting.update({ where: { id: meeting.id }, data: { calendarEventId: calEvent.id } });
|
|
|
|
// If this came from a request, close the loop — and bridge a callback if present.
|
|
if (input.requestId) {
|
|
const request = await this.prisma.iiosMeetingRequest.update({
|
|
where: { id: input.requestId },
|
|
data: { status: 'SCHEDULED', resultingMeetingId: meeting.id },
|
|
});
|
|
if (request.callbackRequestId) {
|
|
await this.prisma.iiosCallbackRequest.update({
|
|
where: { id: request.callbackRequestId },
|
|
data: { meetingRef: meeting.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.emit(IIOS_EVENTS.meetingScheduled, scope.id, meeting.id, { meetingId: meeting.id });
|
|
return this.getMeeting(meeting.id);
|
|
}
|
|
|
|
/** Direct genesis: request + schedule in one call. */
|
|
async scheduleDirect(principal: MessagePrincipal, input: ScheduleInput) {
|
|
const request = await this.requestMeeting(principal, {
|
|
requestedWindow: { startAt: input.startAt, endAt: input.endAt },
|
|
meetingType: input.meetingType,
|
|
});
|
|
return this.schedule(principal, { ...input, requestId: request.id });
|
|
}
|
|
|
|
async listMeetings(principal: MessagePrincipal) {
|
|
const scope = await this.actors.resolveScope(principal);
|
|
return this.prisma.iiosMeeting.findMany({
|
|
where: { scopeId: scope.id },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
include: { participants: true },
|
|
});
|
|
}
|
|
|
|
async getMeeting(id: string) {
|
|
const meeting = await this.prisma.iiosMeeting.findUnique({
|
|
where: { id },
|
|
include: {
|
|
participants: true,
|
|
transcripts: { include: { segments: true } },
|
|
summaries: true,
|
|
actionItems: { include: { actionItem: true } },
|
|
},
|
|
});
|
|
if (!meeting) throw new NotFoundException('meeting not found');
|
|
return meeting;
|
|
}
|
|
|
|
protected async emit(type: string, scopeId: string, aggregateId: string, data: Record<string, unknown>): Promise<void> {
|
|
const event: CloudEvent = {
|
|
specversion: '1.0',
|
|
id: `evt_cal_${aggregateId}_${type.split('.').slice(-2, -1)[0]}`,
|
|
type,
|
|
source: `iios/calendar/${scopeId}`,
|
|
subject: `meeting/${aggregateId}`,
|
|
time: new Date().toISOString(),
|
|
datacontenttype: 'application/json',
|
|
insignia: { scopeSnapshotId: scopeId, idempotencyKey: `${type}:${aggregateId}`, dataClass: 'internal' },
|
|
data,
|
|
};
|
|
await this.prisma.iiosOutboxEvent.create({
|
|
data: {
|
|
aggregateType: 'meeting',
|
|
aggregateId,
|
|
eventType: type,
|
|
cloudEvent: event as unknown as Prisma.InputJsonValue,
|
|
partitionKey: `${scopeId}:${aggregateId}`,
|
|
},
|
|
});
|
|
}
|
|
}
|