From 561e6984abb1bef524fce199774d8196219364f5 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:46:07 -0500 Subject: [PATCH] feat: add Notion workspace integration (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Notion integration node pack requested in issue #30: bearer credential plus four operations (create page, update page, query database, append blocks) with Zod schemas and unit tests. - credentials.ts: defineBearerCredential with notion Integration Token, apiToken schema rejects empty + control characters (CRLF defense) - schemas.ts: shared NotionBlockSchema / NotionPropertiesSchema / NotionFilterSchema / NotionSortSchema / NotionPageObjectSchema, plus buildNotionHeaders (pins Notion-Version: 2022-06-28) and formatNotionError (parses FetchRetryError body to surface Notion code/message in the final error string) - notion-create-page: POST /v1/pages, forwards properties/children/icon/cover - notion-update-page: PATCH /v1/pages/{pageId} with encodeURIComponent, refine rejects no-op updates, present-but-empty properties accepted - notion-query-database: POST /v1/databases/{databaseId}/query, camelCase output (hasMore, nextCursor) from snake_case API response, pageSize defaults to 100 with min(1)/max(100) bounds - notion-append-blocks: PATCH /v1/blocks/{blockId}/children, children bounded 1-100 (Notion per-request cap) - NodeCredentials gains notion?: { apiToken: string } - integrations/index.ts re-exports all four nodes + schemas + types All four executors follow the existing apify/slack pattern: pure defineNode calls using fetchWithRetry, returning { success, output } or { success: false, error } — no cross-cutting concerns leak into node code (per CLAUDE.md architectural decision). 60 vitest tests cover: credential metadata, schema validation (including CRLF rejection, boundary conditions, defaults, no-op refinement), happy paths with URL/method/header/body assertions, error paths (400/401/403/404/409/429), URL encoding for path parameters, cursor pagination boundaries (empty results with has_more: true, null next_cursor), and the 100-block append cap. --- packages/core/src/types/node.ts | 4 + packages/nodes/src/integrations/index.ts | 35 + .../notion/__tests__/notion.test.ts | 959 ++++++++++++++++++ .../src/integrations/notion/credentials.ts | 24 + .../nodes/src/integrations/notion/index.ts | 51 + .../notion/notion-append-blocks.ts | 94 ++ .../integrations/notion/notion-create-page.ts | 107 ++ .../notion/notion-query-database.ts | 108 ++ .../integrations/notion/notion-update-page.ts | 105 ++ .../nodes/src/integrations/notion/schemas.ts | 205 ++++ 10 files changed, 1692 insertions(+) create mode 100644 packages/nodes/src/integrations/notion/__tests__/notion.test.ts create mode 100644 packages/nodes/src/integrations/notion/credentials.ts create mode 100644 packages/nodes/src/integrations/notion/index.ts create mode 100644 packages/nodes/src/integrations/notion/notion-append-blocks.ts create mode 100644 packages/nodes/src/integrations/notion/notion-create-page.ts create mode 100644 packages/nodes/src/integrations/notion/notion-query-database.ts create mode 100644 packages/nodes/src/integrations/notion/notion-update-page.ts create mode 100644 packages/nodes/src/integrations/notion/schemas.ts diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..c6cd960 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -84,6 +84,10 @@ export interface NodeCredentials { refreshToken: string expiresAt: number } + /** Notion API credentials */ + notion?: { + apiToken: string + } } /** diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..4855d80 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -275,3 +275,38 @@ export { type SlackSearchMatch, slackCredential, } from './slack/index.js' + +// Notion integrations +export { + notionCreatePageNode, + NotionCreatePageInputSchema, + NotionCreatePageOutputSchema, + type NotionCreatePageInput, + type NotionCreatePageOutput, + notionUpdatePageNode, + NotionUpdatePageInputSchema, + NotionUpdatePageOutputSchema, + type NotionUpdatePageInput, + type NotionUpdatePageOutput, + notionQueryDatabaseNode, + NotionQueryDatabaseInputSchema, + NotionQueryDatabaseOutputSchema, + type NotionQueryDatabaseInput, + type NotionQueryDatabaseOutput, + notionAppendBlocksNode, + NotionAppendBlocksInputSchema, + NotionAppendBlocksOutputSchema, + type NotionAppendBlocksInput, + type NotionAppendBlocksOutput, + NotionBlockSchema, + NotionPropertiesSchema, + NotionFilterSchema, + NotionSortSchema, + NotionPageObjectSchema, + type NotionBlock, + type NotionProperties, + type NotionFilter, + type NotionSort, + type NotionPageObject, + notionCredential, +} from './notion/index.js' diff --git a/packages/nodes/src/integrations/notion/__tests__/notion.test.ts b/packages/nodes/src/integrations/notion/__tests__/notion.test.ts new file mode 100644 index 0000000..2f1ee5d --- /dev/null +++ b/packages/nodes/src/integrations/notion/__tests__/notion.test.ts @@ -0,0 +1,959 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + notionCredential, + notionCreatePageNode, + notionUpdatePageNode, + notionQueryDatabaseNode, + notionAppendBlocksNode, + NotionCreatePageInputSchema, + NotionUpdatePageInputSchema, + NotionQueryDatabaseInputSchema, + NotionAppendBlocksInputSchema, + NotionSortSchema, + NotionBlockSchema, + buildNotionHeaders, +} from '../index.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const makeContext = (credentials?: Record) => ({ + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, + credentials, +}); + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const notionPageBody = (overrides: Record = {}) => ({ + id: 'page_123', + url: 'https://notion.so/page_123', + created_time: '2026-04-11T10:00:00.000Z', + last_edited_time: '2026-04-11T10:00:00.000Z', + archived: false, + properties: { Name: { title: [{ text: { content: 'Test' } }] } }, + ...overrides, +}); + +// ============================================================================= +// Credential +// ============================================================================= + +describe('notion credentials', () => { + it('defines bearer credential metadata', () => { + expect(notionCredential.name).toBe('notion'); + expect(notionCredential.type).toBe('bearer'); + expect(notionCredential.authenticate.properties.Authorization).toBe( + 'Bearer {{apiToken}}' + ); + }); + + it('schema requires apiToken', () => { + const result = notionCredential.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('schema rejects empty apiToken', () => { + const result = notionCredential.schema.safeParse({ apiToken: '' }); + expect(result.success).toBe(false); + }); + + it('schema accepts non-empty apiToken', () => { + const result = notionCredential.schema.safeParse({ apiToken: 'secret_abc' }); + expect(result.success).toBe(true); + }); + + it('schema rejects apiToken with embedded newline (CRLF header-injection defense)', () => { + const result = notionCredential.schema.safeParse({ + apiToken: 'secret_abc\nX-Injected: evil', + }); + expect(result.success).toBe(false); + }); + + it('schema rejects apiToken with embedded carriage return', () => { + const result = notionCredential.schema.safeParse({ + apiToken: 'secret_abc\rinject', + }); + expect(result.success).toBe(false); + }); +}); + +// ============================================================================= +// Shared schemas +// ============================================================================= + +describe('notion shared schemas', () => { + it('NotionBlockSchema is permissive', () => { + const result = NotionBlockSchema.safeParse({ + type: 'paragraph', + paragraph: { rich_text: [] }, + }); + expect(result.success).toBe(true); + }); + + it('NotionSortSchema rejects missing direction', () => { + const result = NotionSortSchema.safeParse({ property: 'Name' }); + expect(result.success).toBe(false); + }); + + it('NotionSortSchema rejects missing property/timestamp', () => { + const result = NotionSortSchema.safeParse({ direction: 'ascending' }); + expect(result.success).toBe(false); + }); + + it('NotionSortSchema accepts property + direction', () => { + const result = NotionSortSchema.safeParse({ + property: 'Name', + direction: 'ascending', + }); + expect(result.success).toBe(true); + }); + + it('NotionSortSchema accepts timestamp + direction', () => { + const result = NotionSortSchema.safeParse({ + timestamp: 'created_time', + direction: 'descending', + }); + expect(result.success).toBe(true); + }); + + it('buildNotionHeaders returns Authorization and Notion-Version headers', () => { + const headers = buildNotionHeaders('secret_abc'); + expect(headers.Authorization).toBe('Bearer secret_abc'); + expect(headers['Notion-Version']).toBe('2022-06-28'); + expect(headers['Content-Type']).toBe('application/json'); + }); +}); + +// ============================================================================= +// notion_create_page +// ============================================================================= + +describe('NotionCreatePageInputSchema', () => { + it('validates a minimal payload', () => { + const result = NotionCreatePageInputSchema.safeParse({ + parentDatabaseId: 'db_123', + properties: { Name: { title: [{ text: { content: 'X' } }] } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty parentDatabaseId', () => { + const result = NotionCreatePageInputSchema.safeParse({ + parentDatabaseId: '', + properties: {}, + }); + expect(result.success).toBe(false); + }); +}); + +describe('notion_create_page', () => { + it('fails when API token is missing', async () => { + const result = await notionCreatePageNode.executor( + { parentDatabaseId: 'db_123', properties: {} }, + makeContext() + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('notion.apiToken'); + }); + + it('creates page and returns id + url', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + const result = await notionCreatePageNode.executor( + { + parentDatabaseId: 'db_123', + properties: { Name: { title: [{ text: { content: 'Test Page' } }] } }, + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.id).toBe('page_123'); + expect(result.output.url).toBe('https://notion.so/page_123'); + expect(result.output.createdTime).toBe('2026-04-11T10:00:00.000Z'); + expect(result.output.archived).toBe(false); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.notion.com/v1/pages'); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers.Authorization).toBe('Bearer secret_abc'); + expect(headers['Notion-Version']).toBe('2022-06-28'); + expect(headers['Content-Type']).toBe('application/json'); + const body = JSON.parse(init.body as string); + expect(body.parent).toEqual({ database_id: 'db_123' }); + expect(body.properties).toEqual({ + Name: { title: [{ text: { content: 'Test Page' } }] }, + }); + }); + + it('forwards icon and cover into request body', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + await notionCreatePageNode.executor( + { + parentDatabaseId: 'db_123', + properties: {}, + icon: { type: 'emoji', emoji: '📄' }, + cover: { + type: 'external', + external: { url: 'https://example.com/cover.png' }, + }, + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.icon).toEqual({ type: 'emoji', emoji: '📄' }); + expect(body.cover).toEqual({ + type: 'external', + external: { url: 'https://example.com/cover.png' }, + }); + }); + + it('forwards children blocks', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + await notionCreatePageNode.executor( + { + parentDatabaseId: 'db_123', + properties: {}, + children: [ + { type: 'paragraph', paragraph: { rich_text: [{ text: { content: 'hi' } }] } }, + ], + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.children).toHaveLength(1); + expect(body.children[0].type).toBe('paragraph'); + }); + + it('propagates Notion validation_error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse(400, { + code: 'validation_error', + message: 'Bad property', + }) + ) + ); + + const result = await notionCreatePageNode.executor( + { parentDatabaseId: 'db_123', properties: {} }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('validation_error'); + expect(result.error).toContain('Bad property'); + }); + + it('surfaces 401 unauthorized distinct from missing creds', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(401, { code: 'unauthorized', message: 'API token is invalid.' }) + ) + ); + + const result = await notionCreatePageNode.executor( + { parentDatabaseId: 'db_123', properties: {} }, + makeContext({ notion: { apiToken: 'bad_token' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('401'); + expect(result.error).toContain('unauthorized'); + }); + + it('surfaces 403 restricted_resource distinct from 401', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(403, { + code: 'restricted_resource', + message: 'Integration not shared with parent.', + }) + ) + ); + + const result = await notionCreatePageNode.executor( + { parentDatabaseId: 'db_123', properties: {} }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('403'); + expect(result.error).toContain('restricted_resource'); + }); + + it('accepts explicit empty properties object', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + const result = await notionCreatePageNode.executor( + { parentDatabaseId: 'db_123', properties: {} }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.properties).toEqual({}); + }); +}); + +// ============================================================================= +// notion_update_page +// ============================================================================= + +describe('NotionUpdatePageInputSchema', () => { + it('rejects no-op update (pageId only)', () => { + const result = NotionUpdatePageInputSchema.safeParse({ pageId: 'p1' }); + expect(result.success).toBe(false); + }); + + it('accepts archived-only update', () => { + const result = NotionUpdatePageInputSchema.safeParse({ + pageId: 'p1', + archived: true, + }); + expect(result.success).toBe(true); + }); + + it('accepts present-but-empty properties', () => { + const result = NotionUpdatePageInputSchema.safeParse({ + pageId: 'p1', + properties: {}, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty pageId', () => { + const result = NotionUpdatePageInputSchema.safeParse({ + pageId: '', + archived: true, + }); + expect(result.success).toBe(false); + }); +}); + +describe('notion_update_page', () => { + it('fails when API token is missing', async () => { + const result = await notionUpdatePageNode.executor( + { pageId: 'p1', archived: true }, + makeContext() + ); + expect(result.success).toBe(false); + expect(result.error).toContain('notion.apiToken'); + }); + + it('updates page properties', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody({ id: 'p1' }))); + vi.stubGlobal('fetch', fetchMock); + + const result = await notionUpdatePageNode.executor( + { + pageId: 'p1', + properties: { Name: { title: [{ text: { content: 'Renamed' } }] } }, + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.notion.com/v1/pages/p1'); + expect(init.method).toBe('PATCH'); + const body = JSON.parse(init.body as string); + expect(body.properties).toBeDefined(); + expect(body.archived).toBeUndefined(); + expect(body.icon).toBeUndefined(); + }); + + it('forwards icon and cover into update body', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + await notionUpdatePageNode.executor( + { + pageId: 'p1', + icon: { type: 'emoji', emoji: '✅' }, + cover: { + type: 'external', + external: { url: 'https://example.com/new-cover.png' }, + }, + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.icon).toEqual({ type: 'emoji', emoji: '✅' }); + expect(body.cover).toEqual({ + type: 'external', + external: { url: 'https://example.com/new-cover.png' }, + }); + expect(body.properties).toBeUndefined(); + expect(body.archived).toBeUndefined(); + }); + + it('archives page without sending properties key', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody({ archived: true }))); + vi.stubGlobal('fetch', fetchMock); + + await notionUpdatePageNode.executor( + { pageId: 'p1', archived: true }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body).toEqual({ archived: true }); + }); + + it('preserves explicit empty properties in body', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + await notionUpdatePageNode.executor( + { pageId: 'p1', properties: {} }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.properties).toEqual({}); + }); + + it('encodes url-unsafe pageId', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, notionPageBody({ id: 'abc def' }))); + vi.stubGlobal('fetch', fetchMock); + + await notionUpdatePageNode.executor( + { pageId: 'abc def/../evil', archived: true }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + `https://api.notion.com/v1/pages/${encodeURIComponent('abc def/../evil')}` + ); + expect(url).not.toContain(' '); + expect(url).not.toContain('/../'); + }); + + it('propagates 404 object_not_found', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(404, { code: 'object_not_found', message: 'Could not find page.' }) + ) + ); + + const result = await notionUpdatePageNode.executor( + { pageId: 'p_missing', archived: true }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('object_not_found'); + }); + + it('propagates 409 conflict_error on archived page', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(409, { code: 'conflict_error', message: 'Page is archived.' }) + ) + ); + + const result = await notionUpdatePageNode.executor( + { + pageId: 'p1', + properties: { Name: { title: [{ text: { content: 'x' } }] } }, + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('conflict_error'); + }); +}); + +// ============================================================================= +// notion_query_database +// ============================================================================= + +describe('NotionQueryDatabaseInputSchema', () => { + it('applies default pageSize of 100', () => { + const result = NotionQueryDatabaseInputSchema.safeParse({ databaseId: 'db1' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.pageSize).toBe(100); + } + }); + + it('rejects pageSize 0', () => { + const result = NotionQueryDatabaseInputSchema.safeParse({ + databaseId: 'db1', + pageSize: 0, + }); + expect(result.success).toBe(false); + }); + + it('rejects pageSize > 100', () => { + const result = NotionQueryDatabaseInputSchema.safeParse({ + databaseId: 'db1', + pageSize: 101, + }); + expect(result.success).toBe(false); + }); + + it('rejects empty databaseId', () => { + const result = NotionQueryDatabaseInputSchema.safeParse({ databaseId: '' }); + expect(result.success).toBe(false); + }); +}); + +describe('notion_query_database', () => { + it('fails when API token is missing', async () => { + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext() + ); + expect(result.success).toBe(false); + expect(result.error).toContain('notion.apiToken'); + }); + + it('queries database and returns mapped results', async () => { + const notionPage = (id: string) => ({ + id, + url: `https://notion.so/${id}`, + created_time: '2026-04-11T10:00:00.000Z', + last_edited_time: '2026-04-11T10:00:00.000Z', + archived: false, + properties: {}, + }); + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + results: [notionPage('p1'), notionPage('p2')], + has_more: true, + next_cursor: 'cursor_1', + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.results).toHaveLength(2); + expect(result.output.hasMore).toBe(true); + expect(result.output.nextCursor).toBe('cursor_1'); + } + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.notion.com/v1/databases/db1/query'); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Notion-Version']).toBe('2022-06-28'); + }); + + it('forwards filter, sorts, and uses snake_case page_size', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + results: [], + has_more: false, + next_cursor: null, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await notionQueryDatabaseNode.executor( + { + databaseId: 'db1', + filter: { property: 'Name', title: { contains: 'x' } }, + sorts: [{ property: 'Name', direction: 'ascending' }], + pageSize: 50, + startCursor: 'cursor_0', + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.filter).toEqual({ property: 'Name', title: { contains: 'x' } }); + expect(body.sorts).toEqual([{ property: 'Name', direction: 'ascending' }]); + expect(body.page_size).toBe(50); + expect(body.start_cursor).toBe('cursor_0'); + }); + + it('handles empty results + null next_cursor', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse(200, { results: [], has_more: false, next_cursor: null }) + ) + ); + + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.results).toHaveLength(0); + expect(result.output.hasMore).toBe(false); + expect(result.output.nextCursor).toBeNull(); + } + }); + + it('handles cursor boundary (empty results with has_more: true)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse(200, { results: [], has_more: true, next_cursor: 'cursor_2' }) + ) + ); + + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.hasMore).toBe(true); + expect(result.output.nextCursor).toBe('cursor_2'); + } + }); + + it('omits start_cursor key when not provided', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { results: [], has_more: false, next_cursor: null }) + ); + vi.stubGlobal('fetch', fetchMock); + + await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect('start_cursor' in body).toBe(false); + }); + + it('accepts passthrough fields on returned page objects', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse(200, { + results: [ + { + id: 'p1', + url: 'https://notion.so/p1', + created_time: '2026-04-11T10:00:00.000Z', + last_edited_time: '2026-04-11T10:00:00.000Z', + archived: false, + properties: {}, + icon: { type: 'emoji', emoji: '⭐' }, + }, + ], + has_more: false, + next_cursor: null, + }) + ) + ); + + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + }); + + it('propagates 400 validation_error', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(400, { code: 'validation_error', message: 'Invalid filter.' }) + ) + ); + + const result = await notionQueryDatabaseNode.executor( + { databaseId: 'db1', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('validation_error'); + }); +}); + +// ============================================================================= +// notion_append_blocks +// ============================================================================= + +describe('NotionAppendBlocksInputSchema', () => { + it('rejects empty children array', () => { + const result = NotionAppendBlocksInputSchema.safeParse({ + blockId: 'b1', + children: [], + }); + expect(result.success).toBe(false); + }); + + it('accepts single block', () => { + const result = NotionAppendBlocksInputSchema.safeParse({ + blockId: 'b1', + children: [{ type: 'paragraph' }], + }); + expect(result.success).toBe(true); + }); + + it('accepts exactly 100 children (boundary)', () => { + const result = NotionAppendBlocksInputSchema.safeParse({ + blockId: 'b1', + children: Array.from({ length: 100 }, () => ({ type: 'paragraph' })), + }); + expect(result.success).toBe(true); + }); + + it('rejects 101 children (boundary over cap)', () => { + const result = NotionAppendBlocksInputSchema.safeParse({ + blockId: 'b1', + children: Array.from({ length: 101 }, () => ({ type: 'paragraph' })), + }); + expect(result.success).toBe(false); + }); + + it('rejects empty blockId', () => { + const result = NotionAppendBlocksInputSchema.safeParse({ + blockId: '', + children: [{ type: 'paragraph' }], + }); + expect(result.success).toBe(false); + }); +}); + +describe('notion_append_blocks', () => { + it('fails when API token is missing', async () => { + const result = await notionAppendBlocksNode.executor( + { blockId: 'b1', children: [{ type: 'paragraph' }] }, + makeContext() + ); + expect(result.success).toBe(false); + expect(result.error).toContain('notion.apiToken'); + }); + + it('appends blocks and returns appendedBlockIds', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + results: [{ id: 'b_new1' }, { id: 'b_new2' }], + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await notionAppendBlocksNode.executor( + { + blockId: 'page_abc', + children: [ + { type: 'paragraph', paragraph: { rich_text: [] } }, + { type: 'heading_1', heading_1: { rich_text: [] } }, + ], + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.appendedBlockIds).toEqual(['b_new1', 'b_new2']); + } + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.notion.com/v1/blocks/page_abc/children'); + expect(init.method).toBe('PATCH'); + const body = JSON.parse(init.body as string); + expect(body.children).toHaveLength(2); + expect('after' in body).toBe(false); + }); + + it('forwards after cursor when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { results: [{ id: 'b_new' }] }) + ); + vi.stubGlobal('fetch', fetchMock); + + await notionAppendBlocksNode.executor( + { + blockId: 'b1', + children: [{ type: 'paragraph' }], + after: 'b0', + }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.after).toBe('b0'); + }); + + it('encodes blockId in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { results: [] }) + ); + vi.stubGlobal('fetch', fetchMock); + + await notionAppendBlocksNode.executor( + { blockId: 'abc def', children: [{ type: 'paragraph' }] }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.notion.com/v1/blocks/abc%20def/children'); + }); + + it('returns empty appendedBlockIds on empty results', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse(200, { results: [] })) + ); + + const result = await notionAppendBlocksNode.executor( + { blockId: 'b1', children: [{ type: 'paragraph' }] }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.appendedBlockIds).toEqual([]); + } + }); + + it('propagates 429 rate limit error', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse(429, { code: 'rate_limited', message: 'Rate limited.' }) + ) + ); + + const result = await notionAppendBlocksNode.executor( + { blockId: 'b1', children: [{ type: 'paragraph' }] }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('rate_limited'); + }); +}); + +// ============================================================================= +// Integration barrel +// ============================================================================= + +describe('notion integration barrel', () => { + it('exports all four nodes with expected type ids', () => { + expect(notionCreatePageNode.type).toBe('notion_create_page'); + expect(notionUpdatePageNode.type).toBe('notion_update_page'); + expect(notionQueryDatabaseNode.type).toBe('notion_query_database'); + expect(notionAppendBlocksNode.type).toBe('notion_append_blocks'); + }); + + it('every node uses category "integration"', () => { + expect(notionCreatePageNode.category).toBe('integration'); + expect(notionUpdatePageNode.category).toBe('integration'); + expect(notionQueryDatabaseNode.category).toBe('integration'); + expect(notionAppendBlocksNode.category).toBe('integration'); + }); + + it('every node sends the Notion-Version header on success', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, notionPageBody())); + vi.stubGlobal('fetch', fetchMock); + + await notionCreatePageNode.executor( + { parentDatabaseId: 'db', properties: {} }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + await notionUpdatePageNode.executor( + { pageId: 'p', archived: true }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { results: [], has_more: false, next_cursor: null }) + ); + await notionQueryDatabaseNode.executor( + { databaseId: 'db', pageSize: 100 }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { results: [] })); + await notionAppendBlocksNode.executor( + { blockId: 'b', children: [{ type: 'paragraph' }] }, + makeContext({ notion: { apiToken: 'secret_abc' } }) + ); + + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit; + const headers = init.headers as Record; + expect(headers['Notion-Version']).toBe('2022-06-28'); + expect(headers.Authorization).toBe('Bearer secret_abc'); + } + }); +}); diff --git a/packages/nodes/src/integrations/notion/credentials.ts b/packages/nodes/src/integrations/notion/credentials.ts new file mode 100644 index 0000000..ec0aefa --- /dev/null +++ b/packages/nodes/src/integrations/notion/credentials.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { defineBearerCredential } from '@jam-nodes/core'; + +export const notionCredential = defineBearerCredential({ + name: 'notion', + displayName: 'Notion Integration Token', + documentationUrl: 'https://developers.notion.com/docs/authorization', + schema: z.object({ + apiToken: z + .string() + .min(1) + .regex( + /^[^\r\n\t\v\f\0]+$/, + 'apiToken must not contain whitespace control characters (\\r, \\n, \\t, \\v, \\f, \\0). Stripping them would also break bearer tokens containing those bytes.' + ), + }), + authenticate: { + type: 'header', + properties: { + Authorization: 'Bearer {{apiToken}}', + 'Notion-Version': '2022-06-28', + }, + }, +}); diff --git a/packages/nodes/src/integrations/notion/index.ts b/packages/nodes/src/integrations/notion/index.ts new file mode 100644 index 0000000..2f7e059 --- /dev/null +++ b/packages/nodes/src/integrations/notion/index.ts @@ -0,0 +1,51 @@ +export { + notionCreatePageNode, + NotionCreatePageInputSchema, + NotionCreatePageOutputSchema, + type NotionCreatePageInput, + type NotionCreatePageOutput, +} from './notion-create-page.js'; + +export { + notionUpdatePageNode, + NotionUpdatePageInputSchema, + NotionUpdatePageOutputSchema, + type NotionUpdatePageInput, + type NotionUpdatePageOutput, +} from './notion-update-page.js'; + +export { + notionQueryDatabaseNode, + NotionQueryDatabaseInputSchema, + NotionQueryDatabaseOutputSchema, + type NotionQueryDatabaseInput, + type NotionQueryDatabaseOutput, +} from './notion-query-database.js'; + +export { + notionAppendBlocksNode, + NotionAppendBlocksInputSchema, + NotionAppendBlocksOutputSchema, + type NotionAppendBlocksInput, + type NotionAppendBlocksOutput, +} from './notion-append-blocks.js'; + +export { + NotionRichTextSchema, + NotionBlockSchema, + NotionPropertiesSchema, + NotionFilterSchema, + NotionSortSchema, + NotionPageObjectSchema, + NOTION_API_BASE, + NOTION_API_VERSION, + buildNotionHeaders, + type NotionRichText, + type NotionBlock, + type NotionProperties, + type NotionFilter, + type NotionSort, + type NotionPageObject, +} from './schemas.js'; + +export { notionCredential } from './credentials.js'; diff --git a/packages/nodes/src/integrations/notion/notion-append-blocks.ts b/packages/nodes/src/integrations/notion/notion-append-blocks.ts new file mode 100644 index 0000000..c313b96 --- /dev/null +++ b/packages/nodes/src/integrations/notion/notion-append-blocks.ts @@ -0,0 +1,94 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + NotionAppendBlocksInputSchema, + NotionAppendBlocksOutputSchema, + NOTION_API_BASE, + buildNotionHeaders, + formatNotionError, + type NotionAppendBlocksInput, + type NotionAppendBlocksOutput, +} from './schemas.js'; + +export { + NotionAppendBlocksInputSchema, + NotionAppendBlocksOutputSchema, + type NotionAppendBlocksInput, + type NotionAppendBlocksOutput, +} from './schemas.js'; + +interface NotionAppendBlocksResponse { + results: Array<{ id: string; [key: string]: unknown }>; +} + +interface NotionErrorResponse { + code?: string; + message?: string; + status?: number; +} + +export const notionAppendBlocksNode = defineNode({ + type: 'notion_append_blocks', + name: 'Notion Append Blocks', + description: 'Append child blocks to a Notion page or parent block (Notion caps at 100 blocks per request).', + category: 'integration', + inputSchema: NotionAppendBlocksInputSchema, + outputSchema: NotionAppendBlocksOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: NotionAppendBlocksInput, context) => { + try { + const apiToken = context.credentials?.notion?.apiToken as string | undefined; + if (!apiToken) { + return { + success: false, + error: + 'Notion API token not configured. Please provide context.credentials.notion.apiToken.', + }; + } + + const body: Record = { + children: input.children, + }; + if (input.after !== undefined) body.after = input.after; + + const response = await fetchWithRetry( + `${NOTION_API_BASE}/blocks/${encodeURIComponent(input.blockId)}/children`, + { + method: 'PATCH', + headers: buildNotionHeaders(apiToken), + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorData = (await response + .json() + .catch(() => ({}))) as NotionErrorResponse; + const code = errorData.code ?? 'unknown_error'; + const message = errorData.message ?? response.statusText; + return { + success: false, + error: `Notion API error ${response.status} (${code}): ${message}`, + }; + } + + const data = (await response.json()) as NotionAppendBlocksResponse; + + const output: NotionAppendBlocksOutput = { + appendedBlockIds: data.results.map((block) => block.id), + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: formatNotionError(error, 'Failed to append Notion blocks'), + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/notion/notion-create-page.ts b/packages/nodes/src/integrations/notion/notion-create-page.ts new file mode 100644 index 0000000..441436b --- /dev/null +++ b/packages/nodes/src/integrations/notion/notion-create-page.ts @@ -0,0 +1,107 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + NotionCreatePageInputSchema, + NotionCreatePageOutputSchema, + NOTION_API_BASE, + buildNotionHeaders, + formatNotionError, + type NotionCreatePageInput, + type NotionCreatePageOutput, +} from './schemas.js'; + +export { + NotionCreatePageInputSchema, + NotionCreatePageOutputSchema, + type NotionCreatePageInput, + type NotionCreatePageOutput, +} from './schemas.js'; + +interface NotionPageResponse { + id: string; + url: string; + created_time: string; + last_edited_time: string; + archived: boolean; + properties: Record; +} + +interface NotionErrorResponse { + code?: string; + message?: string; + status?: number; +} + +export const notionCreatePageNode = defineNode({ + type: 'notion_create_page', + name: 'Notion Create Page', + description: 'Create a new page inside a Notion database, with optional content blocks, icon, and cover.', + category: 'integration', + inputSchema: NotionCreatePageInputSchema, + outputSchema: NotionCreatePageOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: NotionCreatePageInput, context) => { + try { + const apiToken = context.credentials?.notion?.apiToken as string | undefined; + if (!apiToken) { + return { + success: false, + error: + 'Notion API token not configured. Please provide context.credentials.notion.apiToken.', + }; + } + + const body: Record = { + parent: { database_id: input.parentDatabaseId }, + properties: input.properties, + }; + if (input.children !== undefined) body.children = input.children; + if (input.icon !== undefined) body.icon = input.icon; + if (input.cover !== undefined) body.cover = input.cover; + + const response = await fetchWithRetry( + `${NOTION_API_BASE}/pages`, + { + method: 'POST', + headers: buildNotionHeaders(apiToken), + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorData = (await response + .json() + .catch(() => ({}))) as NotionErrorResponse; + const code = errorData.code ?? 'unknown_error'; + const message = errorData.message ?? response.statusText; + return { + success: false, + error: `Notion API error ${response.status} (${code}): ${message}`, + }; + } + + const data = (await response.json()) as NotionPageResponse; + + const output: NotionCreatePageOutput = { + id: data.id, + url: data.url, + createdTime: data.created_time, + lastEditedTime: data.last_edited_time, + archived: data.archived, + properties: data.properties, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: formatNotionError(error, 'Failed to create Notion page'), + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/notion/notion-query-database.ts b/packages/nodes/src/integrations/notion/notion-query-database.ts new file mode 100644 index 0000000..ba8583d --- /dev/null +++ b/packages/nodes/src/integrations/notion/notion-query-database.ts @@ -0,0 +1,108 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + NotionQueryDatabaseInputSchema, + NotionQueryDatabaseOutputSchema, + NOTION_API_BASE, + buildNotionHeaders, + formatNotionError, + type NotionQueryDatabaseInput, + type NotionQueryDatabaseOutput, +} from './schemas.js'; + +export { + NotionQueryDatabaseInputSchema, + NotionQueryDatabaseOutputSchema, + type NotionQueryDatabaseInput, + type NotionQueryDatabaseOutput, +} from './schemas.js'; + +interface NotionQueryResponse { + results: Array<{ + id: string; + url: string; + created_time: string; + last_edited_time: string; + archived: boolean; + properties: Record; + [key: string]: unknown; + }>; + has_more: boolean; + next_cursor: string | null; +} + +interface NotionErrorResponse { + code?: string; + message?: string; + status?: number; +} + +export const notionQueryDatabaseNode = defineNode({ + type: 'notion_query_database', + name: 'Notion Query Database', + description: 'Query a Notion database with optional filter, sorts, cursor, and page size.', + category: 'integration', + inputSchema: NotionQueryDatabaseInputSchema, + outputSchema: NotionQueryDatabaseOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: NotionQueryDatabaseInput, context) => { + try { + const apiToken = context.credentials?.notion?.apiToken as string | undefined; + if (!apiToken) { + return { + success: false, + error: + 'Notion API token not configured. Please provide context.credentials.notion.apiToken.', + }; + } + + const body: Record = { + page_size: input.pageSize, + }; + if (input.filter !== undefined) body.filter = input.filter; + if (input.sorts !== undefined) body.sorts = input.sorts; + if (input.startCursor !== undefined) body.start_cursor = input.startCursor; + + const response = await fetchWithRetry( + `${NOTION_API_BASE}/databases/${encodeURIComponent(input.databaseId)}/query`, + { + method: 'POST', + headers: buildNotionHeaders(apiToken), + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorData = (await response + .json() + .catch(() => ({}))) as NotionErrorResponse; + const code = errorData.code ?? 'unknown_error'; + const message = errorData.message ?? response.statusText; + return { + success: false, + error: `Notion API error ${response.status} (${code}): ${message}`, + }; + } + + const data = (await response.json()) as NotionQueryResponse; + + const output: NotionQueryDatabaseOutput = { + results: data.results, + hasMore: data.has_more, + nextCursor: data.next_cursor, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: formatNotionError(error, 'Failed to query Notion database'), + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/notion/notion-update-page.ts b/packages/nodes/src/integrations/notion/notion-update-page.ts new file mode 100644 index 0000000..0cc6833 --- /dev/null +++ b/packages/nodes/src/integrations/notion/notion-update-page.ts @@ -0,0 +1,105 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + NotionUpdatePageInputSchema, + NotionUpdatePageOutputSchema, + NOTION_API_BASE, + buildNotionHeaders, + formatNotionError, + type NotionUpdatePageInput, + type NotionUpdatePageOutput, +} from './schemas.js'; + +export { + NotionUpdatePageInputSchema, + NotionUpdatePageOutputSchema, + type NotionUpdatePageInput, + type NotionUpdatePageOutput, +} from './schemas.js'; + +interface NotionPageResponse { + id: string; + url: string; + created_time: string; + last_edited_time: string; + archived: boolean; + properties: Record; +} + +interface NotionErrorResponse { + code?: string; + message?: string; + status?: number; +} + +export const notionUpdatePageNode = defineNode({ + type: 'notion_update_page', + name: 'Notion Update Page', + description: 'Update a Notion page — change property values, archive/unarchive, or update icon/cover.', + category: 'integration', + inputSchema: NotionUpdatePageInputSchema, + outputSchema: NotionUpdatePageOutputSchema, + estimatedDuration: 3, + capabilities: { + supportsRerun: true, + }, + + executor: async (input: NotionUpdatePageInput, context) => { + try { + const apiToken = context.credentials?.notion?.apiToken as string | undefined; + if (!apiToken) { + return { + success: false, + error: + 'Notion API token not configured. Please provide context.credentials.notion.apiToken.', + }; + } + + const body: Record = {}; + if (input.properties !== undefined) body.properties = input.properties; + if (input.archived !== undefined) body.archived = input.archived; + if (input.icon !== undefined) body.icon = input.icon; + if (input.cover !== undefined) body.cover = input.cover; + + const response = await fetchWithRetry( + `${NOTION_API_BASE}/pages/${encodeURIComponent(input.pageId)}`, + { + method: 'PATCH', + headers: buildNotionHeaders(apiToken), + body: JSON.stringify(body), + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorData = (await response + .json() + .catch(() => ({}))) as NotionErrorResponse; + const code = errorData.code ?? 'unknown_error'; + const message = errorData.message ?? response.statusText; + return { + success: false, + error: `Notion API error ${response.status} (${code}): ${message}`, + }; + } + + const data = (await response.json()) as NotionPageResponse; + + const output: NotionUpdatePageOutput = { + id: data.id, + url: data.url, + createdTime: data.created_time, + lastEditedTime: data.last_edited_time, + archived: data.archived, + properties: data.properties, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: formatNotionError(error, 'Failed to update Notion page'), + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/notion/schemas.ts b/packages/nodes/src/integrations/notion/schemas.ts new file mode 100644 index 0000000..c127ec0 --- /dev/null +++ b/packages/nodes/src/integrations/notion/schemas.ts @@ -0,0 +1,205 @@ +import { z } from 'zod'; +import { FetchRetryError } from '../../utils/http.js'; + +// ============================================================================= +// Shared primitives +// ============================================================================= + +/** + * Notion Rich Text / property / block objects have a deep type matrix that + * changes across API versions. We pass them through as permissive records so + * callers can build arbitrary Notion payloads without us re-modeling the + * entire Notion type surface (which would be out of scope for issue #30). + */ +export const NotionRichTextSchema = z.record(z.string(), z.unknown()); +export const NotionBlockSchema = z.record(z.string(), z.unknown()); +export const NotionPropertiesSchema = z.record(z.string(), z.unknown()); +export const NotionFilterSchema = z.record(z.string(), z.unknown()); + +export const NotionSortSchema = z + .object({ + property: z.string().optional(), + timestamp: z.enum(['created_time', 'last_edited_time']).optional(), + direction: z.enum(['ascending', 'descending']), + }) + .refine((data) => data.property !== undefined || data.timestamp !== undefined, { + message: 'NotionSortSchema requires either `property` or `timestamp`', + }); + +/** + * Minimal shape of a Notion Page object as returned by the API. + * `.passthrough()` keeps any extra fields the API adds across versions so we + * don't have to bump the schema every time Notion adds a new field. + */ +export const NotionPageObjectSchema = z + .object({ + id: z.string(), + url: z.string(), + created_time: z.string(), + last_edited_time: z.string(), + archived: z.boolean(), + properties: NotionPropertiesSchema, + }) + .passthrough(); + +export type NotionRichText = z.infer; +export type NotionBlock = z.infer; +export type NotionProperties = z.infer; +export type NotionFilter = z.infer; +export type NotionSort = z.infer; +export type NotionPageObject = z.infer; + +// ============================================================================= +// Shared HTTP header helper +// ============================================================================= + +export const NOTION_API_BASE = 'https://api.notion.com/v1'; +export const NOTION_API_VERSION = '2022-06-28'; + +export function buildNotionHeaders(apiToken: string): Record { + return { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': NOTION_API_VERSION, + }; +} + +/** + * Format an error into the canonical Notion error string used by every + * operation's executor. Handles two shapes of failure: + * + * 1. `FetchRetryError` thrown by `fetchWithRetry` (auth 401/403, rate-limit + * exhaustion 429, or server errors). We surface the Notion `code`/`message` + * from the attached body when present, so callers get the real API reason + * (e.g. `unauthorized`, `restricted_resource`, `rate_limited`) instead of + * just the generic retry-layer message. + * 2. Any other error — return `error.message` if possible, otherwise the + * supplied fallback. + */ +export function formatNotionError(error: unknown, fallback: string): string { + if (error instanceof FetchRetryError) { + let code = 'unknown_error'; + let message = error.message; + if (error.body) { + try { + const parsed = JSON.parse(error.body) as { + code?: string; + message?: string; + }; + if (parsed.code) code = parsed.code; + if (parsed.message) message = parsed.message; + } catch { + // body was not JSON — fall back to the FetchRetryError message + } + } + const statusPart = error.status !== undefined ? `${error.status} ` : ''; + return `Notion API error ${statusPart}(${code}): ${message}`; + } + return error instanceof Error ? error.message : fallback; +} + +// ============================================================================= +// notionCreatePage +// ============================================================================= + +export const NotionCreatePageInputSchema = z.object({ + /** Parent database ID where the page will be created */ + parentDatabaseId: z.string().min(1, 'parentDatabaseId is required'), + /** Page properties keyed by property name (matches the database schema) */ + properties: NotionPropertiesSchema, + /** Optional child blocks to add as page content */ + children: z.array(NotionBlockSchema).optional(), + /** Optional page icon (emoji or external URL) */ + icon: z.record(z.string(), z.unknown()).optional(), + /** Optional page cover (external URL) */ + cover: z.record(z.string(), z.unknown()).optional(), +}); + +export const NotionCreatePageOutputSchema = z.object({ + id: z.string(), + url: z.string(), + createdTime: z.string(), + lastEditedTime: z.string(), + archived: z.boolean(), + properties: NotionPropertiesSchema, +}); + +export type NotionCreatePageInput = z.infer; +export type NotionCreatePageOutput = z.infer; + +// ============================================================================= +// notionUpdatePage +// ============================================================================= + +export const NotionUpdatePageInputSchema = z + .object({ + pageId: z.string().min(1, 'pageId is required'), + properties: NotionPropertiesSchema.optional(), + archived: z.boolean().optional(), + icon: z.record(z.string(), z.unknown()).optional(), + cover: z.record(z.string(), z.unknown()).optional(), + }) + .refine( + (data) => + data.properties !== undefined || + data.archived !== undefined || + data.icon !== undefined || + data.cover !== undefined, + { + message: + 'notionUpdatePage requires at least one of properties, archived, icon, or cover', + } + ); + +export const NotionUpdatePageOutputSchema = z.object({ + id: z.string(), + url: z.string(), + createdTime: z.string(), + lastEditedTime: z.string(), + archived: z.boolean(), + properties: NotionPropertiesSchema, +}); + +export type NotionUpdatePageInput = z.infer; +export type NotionUpdatePageOutput = z.infer; + +// ============================================================================= +// notionQueryDatabase +// ============================================================================= + +export const NotionQueryDatabaseInputSchema = z.object({ + databaseId: z.string().min(1, 'databaseId is required'), + filter: NotionFilterSchema.optional(), + sorts: z.array(NotionSortSchema).optional(), + startCursor: z.string().optional(), + pageSize: z.number().int().min(1).max(100).default(100), +}); + +export const NotionQueryDatabaseOutputSchema = z.object({ + results: z.array(NotionPageObjectSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullable(), +}); + +export type NotionQueryDatabaseInput = z.infer; +export type NotionQueryDatabaseOutput = z.infer; + +// ============================================================================= +// notionAppendBlocks +// ============================================================================= + +export const NotionAppendBlocksInputSchema = z.object({ + /** Block or page ID whose children we are appending to */ + blockId: z.string().min(1, 'blockId is required'), + /** Blocks to append (Notion caps per-request at 100) */ + children: z.array(NotionBlockSchema).min(1).max(100), + /** Optional block ID to append after */ + after: z.string().optional(), +}); + +export const NotionAppendBlocksOutputSchema = z.object({ + appendedBlockIds: z.array(z.string()), +}); + +export type NotionAppendBlocksInput = z.infer; +export type NotionAppendBlocksOutput = z.infer;