From ca9498ce83f965637a7ba30d8e6cf029ee2edc24 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 18:09:18 +0530 Subject: [PATCH] feat(p8): /v1/calendar REST + kernel-client methods + @insignia/iios-meeting-web Task 8.6: CalendarController exposes schedule/request(+fromCallback/fromClaim)/ list/get/consent/transcript/summarize/action-items/providers(+sync) (session-auth). kernel-client RestClient gains scheduleMeeting/requestMeeting/listMeetings/ getMeeting/setConsent/generateTranscript/summarizeMeeting/listActionItems/ connect+syncCalendarProvider + Meeting/MeetingParticipant/MeetingActionItem types. New @insignia/iios-meeting-web (MeetingProvider + useMeetings/useMeeting/useSchedule/ useConsent/useSummarize/useActionItems). Boundary: meeting-web -> contracts + kernel-client (fails on meeting-web->service). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/src/rest.ts | 51 +++++++++- packages/iios-kernel-client/src/types.ts | 30 ++++++ packages/iios-meeting-web/package.json | 27 ++++++ packages/iios-meeting-web/src/index.ts | 2 + packages/iios-meeting-web/src/react.tsx | 97 +++++++++++++++++++ packages/iios-meeting-web/tsconfig.json | 14 +++ packages/iios-meeting-web/tsup.config.ts | 9 ++ .../src/calendar/calendar.controller.ts | 89 +++++++++++++++++ .../iios-service/src/calendar/calendar.dto.ts | 26 +++++ .../src/calendar/calendar.module.ts | 2 + pnpm-lock.yaml | 19 ++++ scripts/check-import-boundary.mjs | 1 + 12 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 packages/iios-meeting-web/package.json create mode 100644 packages/iios-meeting-web/src/index.ts create mode 100644 packages/iios-meeting-web/src/react.tsx create mode 100644 packages/iios-meeting-web/tsconfig.json create mode 100644 packages/iios-meeting-web/tsup.config.ts create mode 100644 packages/iios-service/src/calendar/calendar.controller.ts create mode 100644 packages/iios-service/src/calendar/calendar.dto.ts diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index b61698a..9bc7572 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -1,5 +1,5 @@ import type { IngestInteractionRequest } from '@insignia/iios-contracts'; -import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types'; export interface RestConfig { serviceUrl: string; @@ -149,6 +149,55 @@ export class RestClient { return this.post(`/v1/ai/artifacts/${id}/reject`, {}); } + // ─── Calendar & Meeting (P8) ────────────────────────────────── + async scheduleMeeting(input: { + meetingType: string; + title: string; + startAt: string; + endAt?: string; + timezone?: string; + attendees?: { userId: string; displayName?: string; role?: string; visibility?: string }[]; + }): Promise { + return this.post('/v1/calendar/meetings', input); + } + async requestMeeting(input: { requestedWindow: Record; meetingType?: string }, opts?: { fromCallback?: string; fromClaim?: string }): Promise { + const q = new URLSearchParams(); + if (opts?.fromCallback) q.set('fromCallback', opts.fromCallback); + if (opts?.fromClaim) q.set('fromClaim', opts.fromClaim); + const qs = q.toString(); + return this.post(`/v1/calendar/requests${qs ? `?${qs}` : ''}`, input); + } + async listMeetings(): Promise { + const r = await fetch(this.url('/v1/calendar/meetings'), { headers: this.headers() }); + if (!r.ok) throw new Error(`listMeetings ${r.status}`); + return (await r.json()) as Meeting[]; + } + async getMeeting(id: string): Promise { + const r = await fetch(this.url(`/v1/calendar/meetings/${id}`), { headers: this.headers() }); + if (!r.ok) throw new Error(`getMeeting ${r.status}`); + return (await r.json()) as Meeting; + } + async setConsent(meetingId: string, actorRefId: string, status: string): Promise { + return this.post(`/v1/calendar/meetings/${meetingId}/consent`, { actorRefId, status }); + } + async generateTranscript(meetingId: string): Promise { + return this.post(`/v1/calendar/meetings/${meetingId}/transcript`, {}); + } + async summarizeMeeting(meetingId: string): Promise { + return this.post(`/v1/calendar/meetings/${meetingId}/summarize`, {}); + } + async listActionItems(meetingId: string): Promise { + const r = await fetch(this.url(`/v1/calendar/meetings/${meetingId}/action-items`), { headers: this.headers() }); + if (!r.ok) throw new Error(`listActionItems ${r.status}`); + return (await r.json()) as MeetingActionItem[]; + } + async connectCalendarProvider(): Promise<{ id: string }> { + return this.post<{ id: string }>('/v1/calendar/providers', {}); + } + async syncCalendarProvider(id: string): Promise<{ pulled: number; cursor: string }> { + return this.post(`/v1/calendar/providers/${id}/sync`, {}); + } + private async post(path: string, body: unknown): Promise { const r = await fetch(this.url(path), { method: 'POST', headers: this.headers(), body: JSON.stringify(body) }); if (!r.ok) throw new Error(`POST ${path} ${r.status}`); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 052cb5d..3be602b 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -150,6 +150,36 @@ export interface AiJobResult { artifact: AiArtifact | null; } +export interface MeetingParticipant { + id: string; + actorRefId?: string | null; + role: string; + attendanceStatus: string; + recordingConsent: string; + visibility: string; +} + +export interface Meeting { + id: string; + meetingType: string; + status: string; + title: string; + organizerActorId: string; + startAt?: string | null; + endAt?: string | null; + timezone: string; + calendarEventId?: string | null; + participants?: MeetingParticipant[]; +} + +export interface MeetingActionItem { + id: string; + meetingId: string; + actionItemId: string; + status: string; + actionItem?: { id: string; title: string; status: string; ownerActorId?: string | null }; +} + /** Minimal socket surface so the facade can be unit-tested with a fake. */ export interface SocketLike { on(event: string, handler: (...args: unknown[]) => void): unknown; diff --git a/packages/iios-meeting-web/package.json b/packages/iios-meeting-web/package.json new file mode 100644 index 0000000..368c160 --- /dev/null +++ b/packages/iios-meeting-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@insignia/iios-meeting-web", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@insignia/iios-kernel-client": "workspace:*" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/iios-meeting-web/src/index.ts b/packages/iios-meeting-web/src/index.ts new file mode 100644 index 0000000..653ad12 --- /dev/null +++ b/packages/iios-meeting-web/src/index.ts @@ -0,0 +1,2 @@ +export { MeetingProvider, useMeetings, useMeeting, useSchedule, useConsent, useSummarize, useActionItems } from './react'; +export type { Meeting, MeetingParticipant, MeetingActionItem } from '@insignia/iios-kernel-client'; diff --git a/packages/iios-meeting-web/src/react.tsx b/packages/iios-meeting-web/src/react.tsx new file mode 100644 index 0000000..3aa89b7 --- /dev/null +++ b/packages/iios-meeting-web/src/react.tsx @@ -0,0 +1,97 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { RestClient, type Meeting, type MeetingActionItem } from '@insignia/iios-kernel-client'; + +const MeetingContext = createContext(null); + +export function MeetingProvider({ + serviceUrl, + token, + children, +}: { + serviceUrl: string; + token: string; + children: React.ReactNode; +}): React.ReactElement { + const client = useMemo(() => new RestClient({ serviceUrl, token }), [serviceUrl, token]); + return {children}; +} + +function useClient(): RestClient { + const c = useContext(MeetingContext); + if (!c) throw new Error('meeting hooks must be used within '); + return c; +} + +export function useMeetings(opts?: { pollMs?: number }): { meetings: Meeting[]; refresh: () => Promise } { + const client = useClient(); + const [meetings, setMeetings] = useState([]); + const refresh = useCallback(async () => { + try { + setMeetings(await client.listMeetings()); + } catch { + /* transient */ + } + }, [client]); + useEffect(() => { + void refresh(); + if (!opts?.pollMs) return; + const t = setInterval(() => void refresh(), opts.pollMs); + return () => clearInterval(t); + }, [refresh, opts?.pollMs]); + return { meetings, refresh }; +} + +export function useMeeting(id: string | null): { meeting: Meeting | null; refresh: () => Promise } { + const client = useClient(); + const [meeting, setMeeting] = useState(null); + const refresh = useCallback(async () => { + if (!id) return setMeeting(null); + try { + setMeeting(await client.getMeeting(id)); + } catch { + /* transient */ + } + }, [client, id]); + useEffect(() => { + void refresh(); + }, [refresh]); + return { meeting, refresh }; +} + +export function useSchedule(): (input: Parameters[0]) => Promise { + const client = useClient(); + return (input) => client.scheduleMeeting(input); +} + +export function useConsent(): { + setConsent: (meetingId: string, actorRefId: string, status: string) => Promise; + generateTranscript: (meetingId: string) => Promise; +} { + const client = useClient(); + return { + setConsent: (meetingId, actorRefId, status) => client.setConsent(meetingId, actorRefId, status), + generateTranscript: (meetingId) => client.generateTranscript(meetingId), + }; +} + +export function useSummarize(): (meetingId: string) => Promise { + const client = useClient(); + return (meetingId) => client.summarizeMeeting(meetingId); +} + +export function useActionItems(meetingId: string | null): { actionItems: MeetingActionItem[]; refresh: () => Promise } { + const client = useClient(); + const [actionItems, setActionItems] = useState([]); + const refresh = useCallback(async () => { + if (!meetingId) return setActionItems([]); + try { + setActionItems(await client.listActionItems(meetingId)); + } catch { + /* transient */ + } + }, [client, meetingId]); + useEffect(() => { + void refresh(); + }, [refresh]); + return { actionItems, refresh }; +} diff --git a/packages/iios-meeting-web/tsconfig.json b/packages/iios-meeting-web/tsconfig.json new file mode 100644 index 0000000..4a4d5c4 --- /dev/null +++ b/packages/iios-meeting-web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/iios-meeting-web/tsup.config.ts b/packages/iios-meeting-web/tsup.config.ts new file mode 100644 index 0000000..1cf9cbf --- /dev/null +++ b/packages/iios-meeting-web/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + external: ['react', 'react-dom', '@insignia/iios-kernel-client'], +}); diff --git a/packages/iios-service/src/calendar/calendar.controller.ts b/packages/iios-service/src/calendar/calendar.controller.ts new file mode 100644 index 0000000..21116d4 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.controller.ts @@ -0,0 +1,89 @@ +import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Query } from '@nestjs/common'; +import { IiosConsentStatus, IiosMeetingType } from '@prisma/client'; +import { CalendarService } from './calendar.service'; +import { CalendarAdapter } from './calendar-adapter'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { ConsentDto, RequestMeetingDto, ScheduleMeetingDto } from './calendar.dto'; + +@Controller('v1/calendar') +export class CalendarController { + constructor( + private readonly calendar: CalendarService, + private readonly adapter: CalendarAdapter, + private readonly session: SessionVerifier, + ) {} + + @Post('meetings') + async schedule(@Body() body: ScheduleMeetingDto, @Headers('authorization') auth?: string) { + return this.calendar.scheduleDirect(this.principal(auth), { ...body, meetingType: body.meetingType as IiosMeetingType }); + } + + @Post('requests') + async request( + @Body() body: RequestMeetingDto, + @Query('fromCallback') fromCallback?: string, + @Query('fromClaim') fromClaim?: string, + @Headers('authorization') auth?: string, + ) { + const principal = this.principal(auth); + if (fromCallback) return this.calendar.fromCallback(principal, fromCallback, body.requestedWindow); + if (fromClaim) return this.calendar.fromEventClaim(principal, fromClaim, body.requestedWindow); + return this.calendar.requestMeeting(principal, { + requestedWindow: body.requestedWindow, + meetingType: body.meetingType as IiosMeetingType | undefined, + interactionId: body.interactionId, + targetActorId: body.targetActorId, + }); + } + + @Get('meetings') + async list(@Headers('authorization') auth?: string) { + return this.calendar.listMeetings(this.principal(auth)); + } + + @Get('meetings/:id') + async get(@Param('id') id: string, @Headers('authorization') auth?: string) { + this.principal(auth); + return this.calendar.getMeeting(id); + } + + @Post('meetings/:id/consent') + async consent(@Param('id') id: string, @Body() body: ConsentDto, @Headers('authorization') auth?: string) { + this.principal(auth); + return this.calendar.setConsent(id, body.actorRefId, body.status as IiosConsentStatus); + } + + @Post('meetings/:id/transcript') + async transcript(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.calendar.generateTranscript(id, this.principal(auth)); + } + + @Post('meetings/:id/summarize') + async summarize(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.calendar.summarize(id, this.principal(auth)); + } + + @Get('meetings/:id/action-items') + async actionItems(@Param('id') id: string, @Headers('authorization') auth?: string) { + this.principal(auth); + return this.calendar.listActionItems(id); + } + + @Post('providers') + async connect(@Headers('authorization') auth?: string) { + return this.adapter.connectProvider(this.principal(auth)); + } + + @Post('providers/:id/sync') + async sync(@Param('id') id: string, @Headers('authorization') auth?: string) { + this.principal(auth); + return this.adapter.syncOnce(id); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/calendar/calendar.dto.ts b/packages/iios-service/src/calendar/calendar.dto.ts new file mode 100644 index 0000000..0754c66 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar.dto.ts @@ -0,0 +1,26 @@ +import { IsArray, IsIn, IsObject, IsOptional, IsString } from 'class-validator'; + +const MEETING_TYPES = ['ZOOM', 'PHONE', 'IN_PERSON', 'CALLBACK', 'INTERNAL'] as const; +const CONSENT = ['UNKNOWN', 'GRANTED', 'DENIED', 'REVOKED'] as const; + +export class ScheduleMeetingDto { + @IsIn(MEETING_TYPES) meetingType!: (typeof MEETING_TYPES)[number]; + @IsString() title!: string; + @IsString() startAt!: string; + @IsOptional() @IsString() endAt?: string; + @IsOptional() @IsString() timezone?: string; + @IsOptional() @IsArray() attendees?: { userId: string; displayName?: string; role?: 'REQUIRED' | 'OPTIONAL'; visibility?: 'FULL' | 'LIMITED' | 'NONE' }[]; + @IsOptional() @IsString() requestId?: string; +} + +export class RequestMeetingDto { + @IsObject() requestedWindow!: Record; + @IsOptional() @IsIn(MEETING_TYPES) meetingType?: (typeof MEETING_TYPES)[number]; + @IsOptional() @IsString() interactionId?: string; + @IsOptional() @IsString() targetActorId?: string; +} + +export class ConsentDto { + @IsString() actorRefId!: string; + @IsIn(CONSENT) status!: (typeof CONSENT)[number]; +} diff --git a/packages/iios-service/src/calendar/calendar.module.ts b/packages/iios-service/src/calendar/calendar.module.ts index a396068..b72f93d 100644 --- a/packages/iios-service/src/calendar/calendar.module.ts +++ b/packages/iios-service/src/calendar/calendar.module.ts @@ -5,6 +5,7 @@ import { AiModule } from '../ai/ai.module'; import { CalendarService } from './calendar.service'; import { CalendarProjector } from './calendar.projector'; import { CalendarAdapter } from './calendar-adapter'; +import { CalendarController } from './calendar.controller'; /** * Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events), @@ -13,6 +14,7 @@ import { CalendarAdapter } from './calendar-adapter'; */ @Module({ imports: [OutboxModule, AdaptersModule, AiModule], + controllers: [CalendarController], providers: [CalendarService, CalendarProjector, CalendarAdapter], exports: [CalendarService, CalendarAdapter], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21e62a6..30e5b4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,25 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/iios-meeting-web: + dependencies: + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../iios-kernel-client + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + react: + specifier: ^19.0.0 + version: 19.2.7 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/iios-message-web: dependencies: '@insignia/iios-kernel-client': diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs index b1ccf87..8294bc1 100644 --- a/scripts/check-import-boundary.mjs +++ b/scripts/check-import-boundary.mjs @@ -23,6 +23,7 @@ const ALLOWED = { '@insignia/iios-inbox-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-community-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-ai-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], + '@insignia/iios-meeting-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-support-web': [ '@insignia/iios-contracts', '@insignia/iios-kernel-client',