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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user