From 6edb0cc0cc9e270087157886104afdee33f455bb Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 17:54:03 +0530 Subject: [PATCH] =?UTF-8?q?feat(p8):=20CalendarService=20=E2=80=94=203=20g?= =?UTF-8?q?enesis=20paths=20converge=20on=20schedule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/iios-service/src/app.module.ts | 2 + .../src/calendar/calendar.module.ts | 17 ++ .../src/calendar/calendar.service.ts | 217 ++++++++++++++++++ .../src/calendar/calendar.spec.ts | 89 +++++++ 4 files changed, 325 insertions(+) create mode 100644 packages/iios-service/src/calendar/calendar.module.ts create mode 100644 packages/iios-service/src/calendar/calendar.service.ts create mode 100644 packages/iios-service/src/calendar/calendar.spec.ts diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index dddeefe..8c94c65 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -11,6 +11,7 @@ import { SupportModule } from './support/support.module'; import { AdaptersModule } from './adapters/adapters.module'; import { RoutingModule } from './routing/routing.module'; import { AiModule } from './ai/ai.module'; +import { CalendarModule } from './calendar/calendar.module'; import { HealthController } from './health.controller'; import { DevController } from './dev/dev.controller'; @@ -28,6 +29,7 @@ import { DevController } from './dev/dev.controller'; AdaptersModule, RoutingModule, AiModule, + CalendarModule, ], controllers: [HealthController, DevController], }) diff --git a/packages/iios-service/src/calendar/calendar.module.ts b/packages/iios-service/src/calendar/calendar.module.ts new file mode 100644 index 0000000..910bc2b --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { OutboxModule } from '../outbox/outbox.module'; +import { AdaptersModule } from '../adapters/adapters.module'; +import { AiModule } from '../ai/ai.module'; +import { CalendarService } from './calendar.service'; + +/** + * Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events), + * AdaptersModule (sandbox reminders via OutboundService) and AiModule (summary + + * action-item extraction reuse the P7 proposal layer). + */ +@Module({ + imports: [OutboxModule, AdaptersModule, AiModule], + providers: [CalendarService], + exports: [CalendarService], +}) +export class CalendarModule {} diff --git a/packages/iios-service/src/calendar/calendar.service.ts b/packages/iios-service/src/calendar/calendar.service.ts new file mode 100644 index 0000000..a1d5d1b --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.service.ts @@ -0,0 +1,217 @@ +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}`, + }, + }); + } +} diff --git a/packages/iios-service/src/calendar/calendar.spec.ts b/packages/iios-service/src/calendar/calendar.spec.ts new file mode 100644 index 0000000..a4f4e7b --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.spec.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit'; +import { PolicyDeniedError } from '@insignia/iios-contracts'; +import { MessageService, type MessagePrincipal } from '../messaging/message.service'; +import { ActorResolver } from '../identity/actor.resolver'; +import { AiJobService } from '../ai/ai.service'; +import { AiBudgetGuard } from '../ai/ai-budget.guard'; +import { CalendarService } from './calendar.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { FakePorts } from '@insignia/iios-testkit'; + +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 alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; +const START = '2026-07-02T10:00:00.000Z'; + +const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors); +const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); + +async function makeInteraction(text: string): Promise { + const m = new MessageService(asService, makeFakePorts(), actors); + const { threadId } = await m.openThread(null, alice); + const msg = await m.send(threadId, alice, { content: text }, `k-${randomUUID()}`); + return msg.id; +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('CalendarService (P8 — 3 genesis paths → schedule)', () => { + it('direct schedule → SCHEDULED meeting + organizer/attendee participants + calendar_event', async () => { + const m = await cal().scheduleDirect(alice, { + meetingType: 'INTERNAL', + title: 'Weekly sync', + startAt: START, + attendees: [{ userId: 'bob', displayName: 'Bob' }], + }); + expect(m.status).toBe('SCHEDULED'); + expect(m.participants).toHaveLength(2); + expect(m.participants.some((p) => p.role === 'ORGANIZER')).toBe(true); + expect(m.calendarEventId).toBeTruthy(); + }); + + it('callback→meeting bridge populates IiosCallbackRequest.meetingRef', async () => { + const scope = await actors.resolveScope(alice); + const actor = await actors.resolveActor(scope.id, alice); + const cb = await prisma.iiosCallbackRequest.create({ + data: { scopeId: scope.id, requesterActorId: actor.id, preferChannel: 'PHONE', status: 'PENDING' }, + }); + const req = await cal().fromCallback(alice, cb.id, { when: 'tomorrow' }); + expect(req.callbackRequestId).toBe(cb.id); + const m = await cal().schedule(alice, { requestId: req.id, meetingType: 'CALLBACK', title: 'Callback with customer', startAt: START }); + const reloaded = await prisma.iiosCallbackRequest.findUniqueOrThrow({ where: { id: cb.id } }); + expect(reloaded.meetingRef).toBe(m.id); + }); + + it('AI path: an accepted EVENT claim becomes a meeting_request', async () => { + const interactionId = await makeInteraction('Maybe a party at 9 PM?'); + const { artifact } = await ai().runJob({ interactionId, jobType: 'EXTRACT', principal: alice }); + await ai().accept(artifact!.id, alice); + const claim = await prisma.iiosAiClaim.findFirstOrThrow({ where: { artifactId: artifact!.id, claimType: 'EVENT' } }); + + const req = await cal().fromEventClaim(alice, claim.id); + expect(req.sourceClaimId).toBe(claim.id); + const m = await cal().schedule(alice, { requestId: req.id, meetingType: 'INTERNAL', title: 'Party planning', startAt: START }); + expect(m.status).toBe('SCHEDULED'); + }); + + it('AI path refuses an un-accepted EVENT claim (human confirmation required)', async () => { + const interactionId = await makeInteraction('Maybe a party at 9 PM?'); + const { artifact } = await ai().runJob({ interactionId, jobType: 'EXTRACT', principal: alice }); + const claim = await prisma.iiosAiClaim.findFirstOrThrow({ where: { artifactId: artifact!.id, claimType: 'EVENT' } }); + await expect(cal().fromEventClaim(alice, claim.id)).rejects.toThrow(); + }); + + it('fails closed: opa deny → PolicyDeniedError, no meeting', async () => { + const ports = makeFakePorts(); + ports.opa.deny('no meeting scheduling'); + await expect(cal(ports).scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'x', startAt: START })).rejects.toBeInstanceOf(PolicyDeniedError); + expect(await prisma.iiosMeeting.count()).toBe(0); + }); +});