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; 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) { 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) { 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): Promise { 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}`, }, }); } }