feat(p8): CalendarService — 3 genesis paths converge on schedule

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>
This commit is contained in:
2026-07-01 17:54:03 +05:30
parent 21772e7611
commit 6edb0cc0cc
4 changed files with 325 additions and 0 deletions
+2
View File
@@ -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],
})
@@ -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 {}
@@ -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<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}`,
},
});
}
}
@@ -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<string> {
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);
});
});