From 2ec427cecc5d36f6c02b066ca1a5b3dea2a3b4ad Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 18:00:53 +0530 Subject: [PATCH] feat(p8): reminders (sandbox + inbox) + CalendarProjector (AI-event auto-bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8.4: schedule queues one sandbox reminder (OutboundService) + a SYSTEM_ALERT inbox item per participant, idempotent (outbound key + per-meeting marker) + meetingReminderQueued event. CalendarProjector consumes aiArtifactAccepted → for each EVENT claim proposes a meeting_request (PROPOSED, sourceClaimId), idempotent via claim() + per-claim dedup. 2 tests: reminder replay-safe, AI-event→request once. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/calendar/calendar-consent.spec.ts | 4 +- .../src/calendar/calendar-projector.spec.ts | 65 +++++++++++++++++++ .../src/calendar/calendar.module.ts | 3 +- .../src/calendar/calendar.projector.ts | 56 ++++++++++++++++ .../src/calendar/calendar.service.ts | 37 +++++++++++ .../src/calendar/calendar.spec.ts | 5 +- 6 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/iios-service/src/calendar/calendar-projector.spec.ts create mode 100644 packages/iios-service/src/calendar/calendar.projector.ts diff --git a/packages/iios-service/src/calendar/calendar-consent.spec.ts b/packages/iios-service/src/calendar/calendar-consent.spec.ts index d2dcb4e..c635af4 100644 --- a/packages/iios-service/src/calendar/calendar-consent.spec.ts +++ b/packages/iios-service/src/calendar/calendar-consent.spec.ts @@ -6,6 +6,8 @@ import { 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 { OutboundService } from '../adapters/outbound.service'; +import { AdapterRegistry } from '../adapters/adapter.registry'; import { CalendarService } from './calendar.service'; import type { PrismaService } from '../prisma/prisma.service'; import type { FakePorts } from '@insignia/iios-testkit'; @@ -19,7 +21,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po const START = '2026-07-02T10:00:00.000Z'; const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); -const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai()); +const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new AdapterRegistry())); async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) { const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees }); diff --git a/packages/iios-service/src/calendar/calendar-projector.spec.ts b/packages/iios-service/src/calendar/calendar-projector.spec.ts new file mode 100644 index 0000000..7bc53c3 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar-projector.spec.ts @@ -0,0 +1,65 @@ +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 { IIOS_EVENTS, type CloudEvent } 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 { OutboundService } from '../adapters/outbound.service'; +import { AdapterRegistry } from '../adapters/adapter.registry'; +import { CalendarService } from './calendar.service'; +import { CalendarProjector } from './calendar.projector'; +import { OutboxBus } from '../outbox/outbox.bus'; +import type { PrismaService } from '../prisma/prisma.service'; + +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 ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); +const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new AdapterRegistry())); + +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('Calendar reminders + projector (P8)', () => { + it('schedule → one reminder outbound + a SYSTEM_ALERT inbox item per participant, replay-safe', async () => { + const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Sync', startAt: START, attendees: [{ userId: 'bob' }] }); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: `meeting-reminder:${m.id}` } })).toBe(1); + expect(await prisma.iiosInboxItem.count({ where: { kind: 'SYSTEM_ALERT' } })).toBe(2); + + await cal().queueReminders(m.id); // replay + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: `meeting-reminder:${m.id}` } })).toBe(1); + expect(await prisma.iiosInboxItem.count({ where: { kind: 'SYSTEM_ALERT' } })).toBe(2); + }); + + it('accepting an AI EVENT artifact → exactly one meeting_request (idempotent replay)', 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 event = (await prisma.iiosOutboxEvent.findFirstOrThrow({ + where: { eventType: IIOS_EVENTS.aiArtifactAccepted, aggregateId: artifact!.id }, + })).cloudEvent as unknown as CloudEvent; + + const p = new CalendarProjector(asService, new OutboxBus()); + await p.onArtifactAccepted(event); + await p.onArtifactAccepted(event); // replay + const claim = await prisma.iiosAiClaim.findFirstOrThrow({ where: { artifactId: artifact!.id, claimType: 'EVENT' } }); + expect(await prisma.iiosMeetingRequest.count({ where: { sourceClaimId: claim.id } })).toBe(1); + }); +}); diff --git a/packages/iios-service/src/calendar/calendar.module.ts b/packages/iios-service/src/calendar/calendar.module.ts index 910bc2b..4043647 100644 --- a/packages/iios-service/src/calendar/calendar.module.ts +++ b/packages/iios-service/src/calendar/calendar.module.ts @@ -3,6 +3,7 @@ import { OutboxModule } from '../outbox/outbox.module'; import { AdaptersModule } from '../adapters/adapters.module'; import { AiModule } from '../ai/ai.module'; import { CalendarService } from './calendar.service'; +import { CalendarProjector } from './calendar.projector'; /** * Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events), @@ -11,7 +12,7 @@ import { CalendarService } from './calendar.service'; */ @Module({ imports: [OutboxModule, AdaptersModule, AiModule], - providers: [CalendarService], + providers: [CalendarService, CalendarProjector], exports: [CalendarService], }) export class CalendarModule {} diff --git a/packages/iios-service/src/calendar/calendar.projector.ts b/packages/iios-service/src/calendar/calendar.projector.ts new file mode 100644 index 0000000..9347af8 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.projector.ts @@ -0,0 +1,56 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts'; +import { PrismaService } from '../prisma/prisma.service'; +import { OutboxBus } from '../outbox/outbox.bus'; + +/** + * P7→P8 auto-bridge (P8). When a P7 AI artifact is accepted and it carries an + * EVENT claim, propose a meeting_request (PROPOSED) so the event surfaces as a + * schedulable meeting — still human-confirmed before it becomes a meeting. AI + * proposes, a human schedules. Idempotent via claim() and per-sourceClaim dedup. + */ +@Injectable() +export class CalendarProjector implements OnModuleInit { + private readonly consumer = 'calendar-projector'; + + constructor( + private readonly prisma: PrismaService, + private readonly bus: OutboxBus, + ) {} + + onModuleInit(): void { + this.bus.on(IIOS_EVENTS.aiArtifactAccepted, (p) => void this.onArtifactAccepted(p as CloudEvent).catch(() => {})); + } + + async onArtifactAccepted(event: CloudEvent): Promise { + if (!(await this.claim(event.id))) return; + const { artifactId } = event.data as { artifactId: string }; + const artifact = await this.prisma.iiosAiArtifact.findUnique({ where: { id: artifactId }, include: { job: true, claims: true } }); + if (!artifact?.acceptedByActorId) return; + const eventClaims = artifact.claims.filter((c) => c.claimType === 'EVENT'); + for (const c of eventClaims) { + const exists = await this.prisma.iiosMeetingRequest.count({ where: { sourceClaimId: c.id } }); + if (exists > 0) continue; + const json = (c.claimJson ?? {}) as { when?: string }; + await this.prisma.iiosMeetingRequest.create({ + data: { + scopeId: artifact.job.scopeId, + interactionId: artifact.job.interactionId ?? undefined, + requestedByActorId: artifact.acceptedByActorId, + requestedWindowJson: { when: json.when ?? 'unspecified' }, + meetingType: 'INTERNAL', + status: 'PROPOSED', + sourceClaimId: c.id, + }, + }); + } + } + + private async claim(eventId: string): Promise { + const res = await this.prisma.iiosProcessedEvent.createMany({ + data: [{ consumerName: this.consumer, eventId }], + skipDuplicates: true, + }); + return res.count > 0; + } +} diff --git a/packages/iios-service/src/calendar/calendar.service.ts b/packages/iios-service/src/calendar/calendar.service.ts index dcca24c..64dbed8 100644 --- a/packages/iios-service/src/calendar/calendar.service.ts +++ b/packages/iios-service/src/calendar/calendar.service.ts @@ -6,6 +6,7 @@ import { PLATFORM_PORTS } from '../platform/platform-ports'; import { decideOrThrow } from '../platform/fail-closed'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { AiJobService } from '../ai/ai.service'; +import { OutboundService } from '../adapters/outbound.service'; import { checkMeetingConsent } from './calendar-consent'; export interface AttendeeInput { @@ -47,6 +48,7 @@ export class CalendarService { @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, private readonly actors: ActorResolver, private readonly ai: AiJobService, + private readonly outbound: OutboundService, ) {} // ── Genesis: meeting requests ──────────────────────────────────── @@ -159,9 +161,44 @@ export class CalendarService { } await this.emit(IIOS_EVENTS.meetingScheduled, scope.id, meeting.id, { meetingId: meeting.id }); + await this.queueReminders(meeting.id); return this.getMeeting(meeting.id); } + /** + * Queue a meeting reminder: one sandbox notification + a SYSTEM_ALERT inbox item + * per participant. Idempotent — the outbound key dedupes the notification and the + * per-participant inbox item is guarded by the meeting marker. + */ + async queueReminders(meetingId: string): Promise { + const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId }, include: { participants: true } }); + if (!meeting) return; + const cmd = await this.outbound.send( + 'PORTAL', + `meeting:${meeting.id}`, + { text: `Reminder: "${meeting.title}"` }, + `meeting-reminder:${meeting.id}`, + ); + for (const p of meeting.participants) { + if (!p.actorRefId) continue; + const exists = await this.prisma.iiosInboxItem.count({ + where: { ownerActorId: p.actorRefId, kind: 'SYSTEM_ALERT', actionRef: { path: ['meetingId'], equals: meeting.id } }, + }); + if (exists > 0) continue; + await this.prisma.iiosInboxItem.create({ + data: { + scopeId: meeting.scopeId, + ownerActorId: p.actorRefId, + kind: 'SYSTEM_ALERT', + state: 'OPEN', + title: `Upcoming meeting: ${meeting.title}`, + actionRef: { meetingId: meeting.id }, + }, + }); + } + await this.emit(IIOS_EVENTS.meetingReminderQueued, meeting.scopeId, meeting.id, { meetingId: meeting.id, commandId: cmd.id }); + } + /** Direct genesis: request + schedule in one call. */ async scheduleDirect(principal: MessagePrincipal, input: ScheduleInput) { const request = await this.requestMeeting(principal, { diff --git a/packages/iios-service/src/calendar/calendar.spec.ts b/packages/iios-service/src/calendar/calendar.spec.ts index 5682aa6..8f90a70 100644 --- a/packages/iios-service/src/calendar/calendar.spec.ts +++ b/packages/iios-service/src/calendar/calendar.spec.ts @@ -8,6 +8,8 @@ import { MessageService, type MessagePrincipal } from '../messaging/message.serv import { ActorResolver } from '../identity/actor.resolver'; import { AiJobService } from '../ai/ai.service'; import { AiBudgetGuard } from '../ai/ai-budget.guard'; +import { OutboundService } from '../adapters/outbound.service'; +import { AdapterRegistry } from '../adapters/adapter.registry'; import { CalendarService } from './calendar.service'; import type { PrismaService } from '../prisma/prisma.service'; import type { FakePorts } from '@insignia/iios-testkit'; @@ -21,7 +23,8 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po const START = '2026-07-02T10:00:00.000Z'; const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); -const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai()); +const outbound = () => new OutboundService(asService, new AdapterRegistry()); +const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound()); async function makeInteraction(text: string): Promise { const m = new MessageService(asService, makeFakePorts(), actors);