Skip to content

feat: Notion workspace integration (#30)#77

Open
jackulau wants to merge 1 commit into
wespreadjam:mainfrom
jackulau:30
Open

feat: Notion workspace integration (#30)#77
jackulau wants to merge 1 commit into
wespreadjam:mainfrom
jackulau:30

Conversation

@jackulau

Copy link
Copy Markdown

Summary

Implements the Notion workspace integration requested in #30. Adds a bearer credential plus four pure-node operations (create page, update page, query database, append blocks), all using the existing defineBearerCredential / defineNode / fetchWithRetry primitives so the package lands consistently with Apify, Slack, and the rest of the integrations directory.

Closes #30.

Acceptance Criteria

  • Bearer credential
  • All 4 operations
  • Block support
  • Zod schemas
  • Unit tests

What's in the diff

New package packages/nodes/src/integrations/notion/

File Purpose
credentials.ts notionCredential via defineBearerCredential, header Authorization: Bearer {{apiToken}}. apiToken schema is z.string().min(1) + regex that rejects \r, \n, \t, \v, \f, \0 to harden against header injection.
schemas.ts Shared primitives: NotionBlockSchema, NotionPropertiesSchema, NotionFilterSchema, NotionSortSchema, NotionPageObjectSchema (uses .passthrough() so unknown Notion response fields don't fail validation across API versions). Per-operation input/output schemas. Shared helpers buildNotionHeaders(apiToken) (pins Notion-Version: 2022-06-28 on every call) and formatNotionError(error, fallback) (parses FetchRetryError.body into the canonical Notion API error <status> (<code>): <message> string so unauthorized / restricted_resource / rate_limited / conflict_error all surface cleanly).
notion-create-page.ts POST https://api.notion.com/v1/pages. Input takes parentDatabaseId, properties, optional children / icon / cover. Response id, url, created_time, last_edited_time, archived, properties mapped to camelCase output.
notion-update-page.ts PATCH https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}. Zod .refine rejects no-op updates (pageId alone); explicit properties: {} is accepted as intent. Only keys the caller supplied are serialized into the body.
notion-query-database.ts POST https://api.notion.com/v1/databases/${encodeURIComponent(databaseId)}/query. Input: optional filter, sorts, startCursor, plus pageSize (default 100, bounded 1–100). Body converts camelCase → snake_case (start_cursor, page_size). Output converts snake_case → camelCase (hasMore, nextCursor).
notion-append-blocks.ts PATCH https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}/children. children bounded 1–100 (Notion's per-request cap — we fail at the schema boundary instead of letting Notion reject with a 400). Returns appendedBlockIds mapped from results[].id.
index.ts Re-exports nodes, schemas, types, helpers, credential.
__tests__/notion.test.ts 60 vitest tests (see Test plan below).

Modified

  • packages/nodes/src/integrations/index.ts — new // Notion integrations export block mirroring the Apify block.
  • packages/core/src/types/node.tsNodeCredentials gains notion?: { apiToken: string } (same additive pattern used by every existing integration).

Architectural alignment

Per CLAUDE.md:

  • Pure nodes. No retry / cache / timeout / rate-limit logic inside Notion node code — all of that is fetchWithRetry's responsibility and ExecutionConfig's responsibility at the engine level. Each executor is validate input → build body → fetch → map response → return.
  • No z.any() / as any. Opaque Notion shapes use z.record(z.string(), z.unknown()); page objects use .passthrough() to tolerate forward-compat API additions without opening the type system.
  • Consistent with Apify/Slack. Same file layout, same defineNode shape, same return { success, output } | { success: false, error } contract, same fetchWithRetry options (maxRetries: 3, backoffMs: 1000, timeoutMs: 30000).

Test plan

60 vitest tests all passing. Full @jam-nodes/nodes suite is 251 passing / 4 failing — the 4 failures are the pre-existing src/logic/__tests__/webhook-trigger.test.ts zod v3/v4 compat errors on main and are unchanged by this PR (0 regressions; baseline was 247 passing / 4 failing before this branch).

  • Credential metadata: notion / bearer type, Authorization: Bearer {{apiToken}} template
  • Credential schema: rejects {}, empty apiToken, apiToken with \n, apiToken with \r; accepts valid token
  • Shared schemas: NotionBlockSchema is permissive, NotionSortSchema rejects missing direction / missing property|timestamp, accepts property+direction and timestamp+direction variants
  • buildNotionHeaders sends Authorization, Content-Type: application/json, Notion-Version: 2022-06-28
  • notion_create_page input schema: minimal payload, rejects empty parentDatabaseId
  • notion_create_page executor: missing creds → notion.apiToken error, happy path asserts URL/method/all headers + mapped output, children forwarding, icon and cover forwarding (new), properties content equality (not just .toBeDefined()), 400 validation_error, 401 unauthorized, 403 restricted_resource, present-but-empty properties: {}
  • notion_update_page input schema: rejects pageId-only no-op, accepts archived-only, accepts present-but-empty properties, rejects empty pageId
  • notion_update_page executor: missing creds, properties update (URL + method + body keys), archive with no properties key in body, present-but-empty properties preserved in body, icon and cover forwarding (new), URL-unsafe pageId encoded via encodeURIComponent, 404 object_not_found, 409 conflict_error on archived page
  • notion_query_database input schema: default pageSize of 100, rejects pageSize: 0, rejects pageSize: 101, rejects empty databaseId
  • notion_query_database executor: missing creds, happy path (results array + hasMore + nextCursor), filter / sorts / page_size (snake-case) / start_cursor forwarding, empty results, cursor boundary (empty results with has_more: true), start_cursor omitted when not provided, passthrough on unknown page fields, 400 validation_error
  • notion_append_blocks input schema: rejects empty children, accepts single block, accepts exactly 100 children, rejects 101 children, rejects empty blockId
  • notion_append_blocks executor: missing creds, appends and returns appendedBlockIds, after cursor forwarded, after omitted when not provided, URL encoding of blockId, empty results → empty array, 429 rate_limited after retry exhaustion
  • Integration barrel: all four type identifiers, all four category: 'integration', Notion-Version header sent on every operation via a cross-node sweep

Commands used:

npm run test -w @jam-nodes/nodes -- src/integrations/notion    # 60/60 pass
npm run test -w @jam-nodes/nodes                                 # 251/255 pass (4 pre-existing, unchanged)

Implements the Notion integration node pack requested in issue wespreadjam#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Integration] Notion - Workspace operations

1 participant