From 8b1827902355596472dde1fda8b22d5bcbac785c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sun, 12 Apr 2026 22:21:39 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20daily=20Notion=20=E2=86=92=20Bedroc?= =?UTF-8?q?k=20KB=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a scheduled Lambda that pulls all pages from the Office Operations Notion teamspace, converts them to markdown, uploads to the KB S3 bucket under a notion/ prefix, and triggers a Bedrock ingestion job. Runs daily at 02:00 UTC via EventBridge. Notion API key stored in Secrets Manager at seahaven/notion/api-key (placeholder — fill in post-deploy). --- lambda/notion-sync/index.ts | 156 ++++++++++++++ lib/constructs/notion-sync.ts | 83 ++++++++ lib/seahaven-slack-bot-stack.ts | 9 + package-lock.json | 348 +++++++++++++++++++++++++++++++- package.json | 4 +- 5 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 lambda/notion-sync/index.ts create mode 100644 lib/constructs/notion-sync.ts diff --git a/lambda/notion-sync/index.ts b/lambda/notion-sync/index.ts new file mode 100644 index 0000000..696e5eb --- /dev/null +++ b/lambda/notion-sync/index.ts @@ -0,0 +1,156 @@ +import { Client } from '@notionhq/client'; +import { NotionToMarkdown } from 'notion-to-md'; +import { + S3Client, + PutObjectCommand, + ListObjectsV2Command, + DeleteObjectsCommand, +} from '@aws-sdk/client-s3'; +import { + BedrockAgentClient, + StartIngestionJobCommand, +} from '@aws-sdk/client-bedrock-agent'; +import { + SecretsManagerClient, + GetSecretValueCommand, +} from '@aws-sdk/client-secrets-manager'; + +const s3 = new S3Client({}); +const bedrockAgent = new BedrockAgentClient({ region: process.env.REGION! }); +const secrets = new SecretsManagerClient({}); + +const NOTION_PREFIX = 'notion/'; + +let cachedApiKey: string | undefined; + +async function getApiKey(): Promise { + if (cachedApiKey) return cachedApiKey; + const res = await secrets.send( + new GetSecretValueCommand({ SecretId: process.env.NOTION_SECRET_ARN! }), + ); + const parsed = JSON.parse(res.SecretString!); + cachedApiKey = parsed.apiKey; + return cachedApiKey!; +} + +function getPageTitle(page: Record): string { + // Regular pages store the title in properties.title or properties.Name + const props = page.properties ?? {}; + for (const prop of Object.values(props) as any[]) { + if (prop?.type === 'title' && Array.isArray(prop.title) && prop.title[0]?.plain_text) { + return prop.title[0].plain_text; + } + } + return page.id as string; +} + +async function clearOldFiles(bucket: string): Promise { + let continuationToken: string | undefined; + const toDelete: { Key: string }[] = []; + + do { + const res = await s3.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: NOTION_PREFIX, + ContinuationToken: continuationToken, + }), + ); + for (const obj of res.Contents ?? []) { + if (obj.Key) toDelete.push({ Key: obj.Key }); + } + continuationToken = res.NextContinuationToken; + } while (continuationToken); + + if (toDelete.length === 0) return; + + // S3 DeleteObjects accepts up to 1000 keys per request + for (let i = 0; i < toDelete.length; i += 1000) { + await s3.send( + new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: toDelete.slice(i, i + 1000) }, + }), + ); + } + console.log(`Deleted ${toDelete.length} stale Notion file(s) from S3`); +} + +async function getAllPages(notion: Client): Promise[]> { + const results: Record[] = []; + let cursor: string | undefined; + + do { + const res = await notion.search({ + filter: { value: 'page', property: 'object' }, + page_size: 100, + start_cursor: cursor, + }); + results.push(...(res.results as Record[])); + cursor = res.has_more && res.next_cursor ? res.next_cursor : undefined; + } while (cursor); + + return results; +} + +export const handler = async (): Promise => { + const apiKey = await getApiKey(); + const notion = new Client({ auth: apiKey }); + const n2m = new NotionToMarkdown({ notionClient: notion }); + + const bucket = process.env.KB_BUCKET_NAME!; + const kbId = process.env.KNOWLEDGE_BASE_ID!; + const dsId = process.env.DATA_SOURCE_ID!; + + console.log('Clearing stale Notion files from S3...'); + await clearOldFiles(bucket); + + console.log('Fetching pages from Notion Office Operations teamspace...'); + const pages = await getAllPages(notion); + console.log(`Found ${pages.length} page(s)`); + + let synced = 0; + let failed = 0; + + for (const page of pages) { + const pageId = page.id as string; + const title = getPageTitle(page); + + try { + const mdBlocks = await n2m.pageToMarkdown(pageId); + const { parent: mdContent } = n2m.toMarkdownString(mdBlocks); + + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${NOTION_PREFIX}${pageId}.md`, + Body: `# ${title}\n\n${mdContent}`, + ContentType: 'text/markdown', + Metadata: { + 'notion-page-id': pageId, + 'notion-title': title.slice(0, 256), // S3 metadata value limit + }, + }), + ); + + console.log(`Synced: "${title}" (${pageId})`); + synced++; + } catch (err) { + console.error(`Failed to sync page "${title}" (${pageId}):`, err); + failed++; + } + } + + console.log(`Upload complete. ${synced} synced, ${failed} failed.`); + + console.log('Triggering Bedrock KB ingestion job...'); + const ingestionRes = await bedrockAgent.send( + new StartIngestionJobCommand({ + knowledgeBaseId: kbId, + dataSourceId: dsId, + }), + ); + console.log( + `Ingestion job started: ${ingestionRes.ingestionJob?.ingestionJobId}`, + ); +}; diff --git a/lib/constructs/notion-sync.ts b/lib/constructs/notion-sync.ts new file mode 100644 index 0000000..99a8312 --- /dev/null +++ b/lib/constructs/notion-sync.ts @@ -0,0 +1,83 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import * as events from 'aws-cdk-lib/aws-events'; +import * as targets from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as path from 'path'; + +export interface NotionSyncProps { + region: string; + kbDocsBucket: s3.Bucket; + knowledgeBaseId: string; + dataSourceId: string; +} + +export class NotionSyncConstruct extends Construct { + public readonly syncLambda: lambdaNodejs.NodejsFunction; + public readonly notionSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: NotionSyncProps) { + super(scope, id); + + // Notion integration token — placeholder created here, filled in post-deploy. + // Update via: AWS Console → Secrets Manager → seahaven/notion/api-key → Retrieve and edit + this.notionSecret = new secretsmanager.Secret(this, 'NotionSecret', { + secretName: 'seahaven/notion/api-key', + description: 'Notion integration token for the Office Operations teamspace KB sync', + secretObjectValue: { + apiKey: cdk.SecretValue.unsafePlainText('REPLACE_ME_after_deploy'), + }, + }); + + // Lambda — fetches Notion pages, uploads markdown to S3, triggers KB ingestion + this.syncLambda = new lambdaNodejs.NodejsFunction(this, 'SyncLambda', { + functionName: 'seahaven-notion-sync', + entry: path.join(__dirname, '../../lambda/notion-sync/index.ts'), + runtime: lambda.Runtime.NODEJS_22_X, + memorySize: 512, + timeout: cdk.Duration.minutes(5), + environment: { + NOTION_SECRET_ARN: this.notionSecret.secretArn, + KB_BUCKET_NAME: props.kbDocsBucket.bucketName, + KNOWLEDGE_BASE_ID: props.knowledgeBaseId, + DATA_SOURCE_ID: props.dataSourceId, + REGION: props.region, + }, + }); + + // Allow Lambda to read the Notion secret + this.notionSecret.grantRead(this.syncLambda); + + // Allow Lambda to read existing notion/ objects and write new ones + props.kbDocsBucket.grantReadWrite(this.syncLambda); + + // Allow Lambda to start a Bedrock KB ingestion job + this.syncLambda.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['bedrock:StartIngestionJob'], + resources: [ + `arn:aws:bedrock:${props.region}:*:knowledge-base/${props.knowledgeBaseId}`, + ], + }), + ); + + // EventBridge rule — fires daily at 02:00 UTC + const dailyRule = new events.Rule(this, 'DailySyncRule', { + ruleName: 'seahaven-notion-daily-sync', + description: 'Daily Notion → KB sync at 02:00 UTC', + schedule: events.Schedule.cron({ minute: '0', hour: '2' }), + }); + + dailyRule.addTarget(new targets.LambdaFunction(this.syncLambda)); + + // Surface the secret name so operators know where to put the token + new cdk.CfnOutput(scope, 'NotionSecretName', { + value: this.notionSecret.secretName, + description: 'Set your Notion integration token here after deploy (apiKey field)', + }); + } +} diff --git a/lib/seahaven-slack-bot-stack.ts b/lib/seahaven-slack-bot-stack.ts index 63163bb..f5cbca0 100644 --- a/lib/seahaven-slack-bot-stack.ts +++ b/lib/seahaven-slack-bot-stack.ts @@ -4,6 +4,7 @@ import { ConversationLogConstruct } from './constructs/conversation-log'; import { KnowledgeBaseConstruct } from './constructs/knowledge-base'; import { BedrockAgentConstruct } from './constructs/bedrock-agent'; import { SlackHandlerConstruct } from './constructs/slack-handler'; +import { NotionSyncConstruct } from './constructs/notion-sync'; export class SeahavenSlackBotStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { @@ -35,6 +36,14 @@ export class SeahavenSlackBotStack extends cdk.Stack { knowledgeBaseArn: knowledgeBase.knowledgeBase.knowledgeBaseArn, }); + // ── Notion → KB daily sync (EventBridge + Lambda) ──────────────────────── + new NotionSyncConstruct(this, 'NotionSync', { + region: this.region, + kbDocsBucket: knowledgeBase.docsBucket, + knowledgeBaseId: knowledgeBase.knowledgeBase.knowledgeBaseId, + dataSourceId: knowledgeBase.dataSource.dataSourceId, + }); + // ── Slack webhook handler (API Gateway + Lambda) ─────────────────────────── new SlackHandlerConstruct(this, 'SlackHandler', { accountId: this.account, diff --git a/package-lock.json b/package-lock.json index 623b700..be0a3b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@cdklabs/generative-ai-cdk-constructs": "^0.1.0", + "@notionhq/client": "^2.2.15", "aws-cdk-lib": "^2.180.0", - "constructs": "^10.4.2" + "constructs": "^10.4.2", + "notion-to-md": "^3.1.1" }, "devDependencies": { "@types/aws-lambda": "^8.10.149", @@ -594,6 +596,19 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@notionhq/client": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@notionhq/client/-/client-2.3.0.tgz", + "integrity": "sha512-l7WqTCpQqC+HibkB9chghONQTYcxNQT0/rOJemBfmuKQRTu2vuV8B3yA395iKaUdDo7HI+0KvQaz9687Xskzkw==", + "license": "MIT", + "dependencies": { + "@types/node-fetch": "^2.5.10", + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -633,12 +648,21 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -672,6 +696,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/aws-cdk": { "version": "2.1118.0", "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1118.0.tgz", @@ -1078,6 +1108,19 @@ "node": ">= 6" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/cdk-nag": { "version": "2.37.55", "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.37.55.tgz", @@ -1088,6 +1131,18 @@ "constructs": "^10.0.5" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/constructs": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", @@ -1101,6 +1156,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1111,6 +1175,65 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1153,6 +1276,119 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1160,6 +1396,97 @@ "dev": true, "license": "ISC" }, + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/notion-to-md": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/notion-to-md/-/notion-to-md-3.1.9.tgz", + "integrity": "sha512-SsYhhigh+jOv06QOiFynOQATPMl96CspWDIL3Q5klzp4eaZ1dYaPI3ELoly80G1K0jf730u3ItvfwskzPKK41g==", + "license": "ISC", + "dependencies": { + "markdown-table": "^2.0.0", + "node-fetch": "2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -1222,7 +1549,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -1232,6 +1558,22 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index cecbebd..b38d909 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ }, "dependencies": { "@cdklabs/generative-ai-cdk-constructs": "^0.1.0", + "@notionhq/client": "^2.2.15", "aws-cdk-lib": "^2.180.0", - "constructs": "^10.4.2" + "constructs": "^10.4.2", + "notion-to-md": "^3.1.1" } } From f254776ba6887c047fb27c2249163676384e9a73 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Apr 2026 00:00:39 -0400 Subject: [PATCH 2/2] docs: update README for Notion KB sync Documents the notion-sync Lambda, daily EventBridge schedule, Notion secret setup, and updated project structure. Refs #4 --- README.md | 48 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a6b3726..f9db1bb 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,14 @@ slack-processor Lambda ← calls Bedrock Agent, logs to DynamoDB, posts r │ ▼ Bedrock Agent (Claude Sonnet 4.5) - ├── Knowledge Base (AOSS + S3) ← SA8000 docs, SOPs, employee handbook + ├── Knowledge Base (AOSS + S3) ← SA8000 docs, SOPs, employee handbook, Notion pages ├── QBO_Lookup action group ← QuickBooks vendor search └── Google_Maps_Lookup action group ← fallback vendor search + +EventBridge (daily 02:00 UTC) + │ + ▼ +notion-sync Lambda ← exports Office Operations Notion pages → S3 → KB ingestion ``` ### Vendor Query Priority @@ -49,13 +54,14 @@ Bedrock Agent (Claude Sonnet 4.5) ## Secrets Manager -These three secrets must exist before deploying. Create them via the AWS Console or CLI: +These secrets must exist before deploying. The Slack, QBO, and Maps secrets must be created manually before first deploy. The Notion secret is created automatically by CDK with a placeholder — update it after deploy. -| Secret Name | Structure | -|---|---| -| `seahaven/slack/credentials` | `{ "botToken": "xoxb-...", "signingSecret": "..." }` | -| `seahaven/qbo/oauth` | `{ "clientId": "", "clientSecret": "", "refreshToken": "", "realmId": "" }` | -| `seahaven/google/maps-api-key` | `{ "apiKey": "" }` | +| Secret Name | Created | Structure | +|---|---|---| +| `seahaven/slack/credentials` | Manual (pre-deploy) | `{ "botToken": "xoxb-...", "signingSecret": "..." }` | +| `seahaven/qbo/oauth` | Manual (pre-deploy) | `{ "clientId": "", "clientSecret": "", "refreshToken": "", "realmId": "" }` | +| `seahaven/google/maps-api-key` | Manual (pre-deploy) | `{ "apiKey": "" }` | +| `seahaven/notion/api-key` | Auto (CDK) | `{ "apiKey": "secret_..." }` | ## Deployment @@ -71,6 +77,7 @@ Stack outputs after deploy: - `KBDocsBucketName` — S3 bucket to upload knowledge base documents - `AgentId` — Bedrock Agent ID - `SlackWebhookUrl` — URL to register in Slack app settings +- `NotionSecretName` — Secrets Manager secret to populate with your Notion integration token ## Post-Deployment Setup @@ -93,7 +100,27 @@ aws bedrock-agent start-ingestion-job \ Or trigger from the Bedrock console: **Knowledge Bases → seahaven-kb → Sync**. -### 3. Configure Slack app +### 3. Configure Notion sync + +1. Create a Notion internal integration at **Settings → Connections → Develop or manage integrations** +2. Name it `seahaven-office-ops` and associate it with the **Office Operations** teamspace +3. Copy the integration token (`secret_...`) +4. In AWS Console: **Secrets Manager → `seahaven/notion/api-key` → Edit** — set `apiKey` to your token +5. Run a manual test: + +```bash +aws lambda invoke \ + --function-name seahaven-notion-sync \ + --region us-east-1 \ + --log-type Tail \ + --query 'LogResult' \ + --output text \ + /dev/null | base64 -d +``` + +The sync runs automatically every day at 02:00 UTC via EventBridge. + +### 4. Configure Slack app In the [Slack API dashboard](https://api.slack.com/apps): @@ -115,11 +142,13 @@ lib/ bedrock-agent.ts Bedrock Agent + QBO/Maps action groups slack-handler.ts API Gateway + webhook/processor Lambdas conversation-log.ts DynamoDB table + notion-sync.ts EventBridge daily cron + notion-sync Lambda + Secrets Manager lambda/ slack-webhook/ Verifies Slack signature, fires processor async slack-processor/ Calls agent, writes DynamoDB, posts Slack reply qbo-lookup/ Bedrock action group — QuickBooks vendor search maps-lookup/ Bedrock action group — Google Maps Places search + notion-sync/ Daily Notion → S3 export, triggers KB ingestion scripts/ create-aoss-index.ts Manual fallback for AOSS index creation (not needed in normal deploy) ``` @@ -127,4 +156,5 @@ scripts/ ## Known Maintenance Items - **QBO refresh token** expires after 100 days of inactivity. Rotate via the [Intuit OAuth Playground](https://developer.intuit.com/app/developer/playground) and update the `seahaven/qbo/oauth` secret. -- **KB sync** must be triggered manually after uploading new documents. Consider adding a scheduled EventBridge rule for automatic nightly syncs. +- **Notion sync** runs daily at 02:00 UTC automatically. To trigger an immediate sync, invoke `seahaven-notion-sync` manually via the Lambda console or CLI. +- **KB sync for manual S3 uploads** (non-Notion docs) must still be triggered manually after uploading new documents to the S3 bucket.