feat(p8): simulated calendar adapter (provider · sync cursor · availability)

Task 8.5: CalendarAdapter connects a SIMULATED provider account + sync cursor,
deterministically pulls stable external events (idempotent by externalEventId,
advancing the cursor), records availability windows, and computes free/busy from
overlapping calendar events. No network. Real provider via Capability Broker = P9.
3 tests: connect, sync idempotent + cursor advance, availability busy/free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:02:27 +05:30
parent 2ec427cecc
commit 83369f2055
3 changed files with 138 additions and 2 deletions
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { CalendarAdapter } from './calendar-adapter';
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 adapter = () => new CalendarAdapter(asService, actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('CalendarAdapter (P8 — simulated, no network)', () => {
it('connect → provider account + sync cursor', async () => {
const acct = await adapter().connectProvider(alice);
expect(acct.providerType).toBe('SIMULATED');
const cursor = await prisma.iiosCalendarSyncCursor.findUniqueOrThrow({ where: { providerAccountId: acct.id } });
expect(cursor.status).toBe('IDLE');
});
it('syncOnce twice advances the cursor without duplicating events (idempotent)', async () => {
const acct = await adapter().connectProvider(alice);
const r1 = await adapter().syncOnce(acct.id);
const r2 = await adapter().syncOnce(acct.id);
expect(Number(r2.cursor)).toBeGreaterThan(Number(r1.cursor));
expect(await prisma.iiosCalendarEvent.count({ where: { providerAccountId: acct.id } })).toBe(2);
});
it('availability reflects a busy synced calendar event', async () => {
const acct = await adapter().connectProvider(alice);
await adapter().syncOnce(acct.id);
const busy = await adapter().availability(alice, { startAt: '2026-07-02T09:15:00.000Z', endAt: '2026-07-02T09:45:00.000Z' });
expect(busy.free).toBe(false);
const free = await adapter().availability(alice, { startAt: '2026-07-02T18:00:00.000Z', endAt: '2026-07-02T18:30:00.000Z' });
expect(free.free).toBe(true);
});
});
@@ -0,0 +1,90 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
/**
* Simulated calendar adapter (P8). Models the external-calendar sync shape the
* Atlas calls for — provider account, incremental sync cursor, external event ids
* — deterministically and with NO network. A real provider (Google/Outlook/Zoom)
* via the Capability Broker replaces this in P9.
*/
@Injectable()
export class CalendarAdapter {
constructor(
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
) {}
async connectProvider(principal: MessagePrincipal) {
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
const account = await this.prisma.iiosCalendarProviderAccount.create({
data: { scopeId: scope.id, actorRefId: actor.id, providerType: 'SIMULATED', accountHandleToken: `sim:${actor.id}` },
});
await this.prisma.iiosCalendarSyncCursor.create({ data: { providerAccountId: account.id, status: 'IDLE' } });
return account;
}
/**
* Deterministically "pull" a stable set of external events (idempotent by
* externalEventId) and advance the cursor. Re-running never duplicates events.
*/
async syncOnce(providerAccountId: string) {
const account = await this.prisma.iiosCalendarProviderAccount.findUnique({ where: { id: providerAccountId } });
if (!account) throw new NotFoundException('provider account not found');
const fixtures = [
{ externalEventId: `ext-${providerAccountId}-standup`, title: 'Daily standup', startAt: '2026-07-02T09:00:00.000Z', endAt: '2026-07-02T09:30:00.000Z' },
{ externalEventId: `ext-${providerAccountId}-review`, title: 'Design review', startAt: '2026-07-02T14:00:00.000Z', endAt: '2026-07-02T15:00:00.000Z' },
];
for (const f of fixtures) {
await this.prisma.iiosCalendarEvent.upsert({
where: { providerAccountId_externalEventId: { providerAccountId, externalEventId: f.externalEventId } },
create: {
scopeId: account.scopeId,
providerAccountId,
externalEventId: f.externalEventId,
title: f.title,
startAt: new Date(f.startAt),
endAt: new Date(f.endAt),
status: 'CONFIRMED',
},
update: { title: f.title },
});
}
const cursor = await this.prisma.iiosCalendarSyncCursor.findUniqueOrThrow({ where: { providerAccountId } });
const next = String(Number(cursor.externalCursor ?? '0') + 1);
await this.prisma.iiosCalendarSyncCursor.update({
where: { providerAccountId },
data: { externalCursor: next, lastSyncAt: new Date(), status: 'IDLE' },
});
return { pulled: fixtures.length, cursor: next };
}
async setAvailability(principal: MessagePrincipal, input: { startAt: string; endAt: string; status?: string; source?: string }) {
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
return this.prisma.iiosAvailabilityWindow.create({
data: {
scopeId: scope.id,
actorRefId: actor.id,
startAt: new Date(input.startAt),
endAt: new Date(input.endAt),
source: input.source ?? 'USER',
status: input.status ?? 'ACTIVE',
},
});
}
/** Free/busy over a window: busy if any confirmed calendar event in scope overlaps. */
async availability(principal: MessagePrincipal, window: { startAt: string; endAt: string }) {
const scope = await this.actors.resolveScope(principal);
const start = new Date(window.startAt);
const end = new Date(window.endAt);
const conflicts = await this.prisma.iiosCalendarEvent.findMany({
where: { scopeId: scope.id, status: 'CONFIRMED', startAt: { lt: end }, endAt: { gt: start } },
});
return { free: conflicts.length === 0, conflicts };
}
}
@@ -4,6 +4,7 @@ import { AdaptersModule } from '../adapters/adapters.module';
import { AiModule } from '../ai/ai.module';
import { CalendarService } from './calendar.service';
import { CalendarProjector } from './calendar.projector';
import { CalendarAdapter } from './calendar-adapter';
/**
* Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events),
@@ -12,7 +13,7 @@ import { CalendarProjector } from './calendar.projector';
*/
@Module({
imports: [OutboxModule, AdaptersModule, AiModule],
providers: [CalendarService, CalendarProjector],
exports: [CalendarService],
providers: [CalendarService, CalendarProjector, CalendarAdapter],
exports: [CalendarService, CalendarAdapter],
})
export class CalendarModule {}