feat(p8): reminders (sandbox + inbox) + CalendarProjector (AI-event auto-bridge)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:00:53 +05:30
parent 55acc3c187
commit 2ec427cecc
6 changed files with 167 additions and 3 deletions
@@ -6,6 +6,8 @@ import { type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver'; import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; 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 { CalendarService } from './calendar.service';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
import type { FakePorts } from '@insignia/iios-testkit'; 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 START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); 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' }[]) { async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees }); const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
@@ -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<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('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);
});
});
@@ -3,6 +3,7 @@ import { OutboxModule } from '../outbox/outbox.module';
import { AdaptersModule } from '../adapters/adapters.module'; import { AdaptersModule } from '../adapters/adapters.module';
import { AiModule } from '../ai/ai.module'; import { AiModule } from '../ai/ai.module';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { CalendarProjector } from './calendar.projector';
/** /**
* Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events), * Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events),
@@ -11,7 +12,7 @@ import { CalendarService } from './calendar.service';
*/ */
@Module({ @Module({
imports: [OutboxModule, AdaptersModule, AiModule], imports: [OutboxModule, AdaptersModule, AiModule],
providers: [CalendarService], providers: [CalendarService, CalendarProjector],
exports: [CalendarService], exports: [CalendarService],
}) })
export class CalendarModule {} export class CalendarModule {}
@@ -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<void> {
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<boolean> {
const res = await this.prisma.iiosProcessedEvent.createMany({
data: [{ consumerName: this.consumer, eventId }],
skipDuplicates: true,
});
return res.count > 0;
}
}
@@ -6,6 +6,7 @@ import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed'; import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { OutboundService } from '../adapters/outbound.service';
import { checkMeetingConsent } from './calendar-consent'; import { checkMeetingConsent } from './calendar-consent';
export interface AttendeeInput { export interface AttendeeInput {
@@ -47,6 +48,7 @@ export class CalendarService {
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver, private readonly actors: ActorResolver,
private readonly ai: AiJobService, private readonly ai: AiJobService,
private readonly outbound: OutboundService,
) {} ) {}
// ── Genesis: meeting requests ──────────────────────────────────── // ── 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.emit(IIOS_EVENTS.meetingScheduled, scope.id, meeting.id, { meetingId: meeting.id });
await this.queueReminders(meeting.id);
return this.getMeeting(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<void> {
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. */ /** Direct genesis: request + schedule in one call. */
async scheduleDirect(principal: MessagePrincipal, input: ScheduleInput) { async scheduleDirect(principal: MessagePrincipal, input: ScheduleInput) {
const request = await this.requestMeeting(principal, { const request = await this.requestMeeting(principal, {
@@ -8,6 +8,8 @@ import { MessageService, type MessagePrincipal } from '../messaging/message.serv
import { ActorResolver } from '../identity/actor.resolver'; import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; 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 { CalendarService } from './calendar.service';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
import type { FakePorts } from '@insignia/iios-testkit'; 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 START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); 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<string> { async function makeInteraction(text: string): Promise<string> {
const m = new MessageService(asService, makeFakePorts(), actors); const m = new MessageService(asService, makeFakePorts(), actors);