From 83369f20550e4156607fe86d8d5d24276bd969ab Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 18:02:27 +0530 Subject: [PATCH] =?UTF-8?q?feat(p8):=20simulated=20calendar=20adapter=20(p?= =?UTF-8?q?rovider=20=C2=B7=20sync=20cursor=20=C2=B7=20availability)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/calendar/calendar-adapter.spec.ts | 45 ++++++++++ .../src/calendar/calendar-adapter.ts | 90 +++++++++++++++++++ .../src/calendar/calendar.module.ts | 5 +- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/iios-service/src/calendar/calendar-adapter.spec.ts create mode 100644 packages/iios-service/src/calendar/calendar-adapter.ts diff --git a/packages/iios-service/src/calendar/calendar-adapter.spec.ts b/packages/iios-service/src/calendar/calendar-adapter.spec.ts new file mode 100644 index 0000000..8a59d43 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar-adapter.spec.ts @@ -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); + }); +}); diff --git a/packages/iios-service/src/calendar/calendar-adapter.ts b/packages/iios-service/src/calendar/calendar-adapter.ts new file mode 100644 index 0000000..aa9facf --- /dev/null +++ b/packages/iios-service/src/calendar/calendar-adapter.ts @@ -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 }; + } +} diff --git a/packages/iios-service/src/calendar/calendar.module.ts b/packages/iios-service/src/calendar/calendar.module.ts index 4043647..a396068 100644 --- a/packages/iios-service/src/calendar/calendar.module.ts +++ b/packages/iios-service/src/calendar/calendar.module.ts @@ -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 {}