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