notion.ts168 lines · main
| 1 | import 'server-only' |
| 2 | |
| 3 | const NOTION_API_BASE = 'https://api.notion.com/v1' |
| 4 | const NOTION_VERSION = '2022-06-28' |
| 5 | |
| 6 | export interface NotionConfig { |
| 7 | apiKey: string |
| 8 | } |
| 9 | |
| 10 | interface NotionDatabaseProperty { |
| 11 | id: string |
| 12 | name: string |
| 13 | type: string |
| 14 | } |
| 15 | |
| 16 | interface NotionDatabaseSchema { |
| 17 | properties: Record<string, NotionDatabaseProperty> |
| 18 | } |
| 19 | |
| 20 | export class NotionClient { |
| 21 | private apiKey: string |
| 22 | private schemaCache = new Map<string, Promise<NotionDatabaseSchema>>() |
| 23 | |
| 24 | constructor(config: NotionConfig) { |
| 25 | if (!config.apiKey) throw new Error('NotionClient: apiKey is required') |
| 26 | this.apiKey = config.apiKey |
| 27 | } |
| 28 | |
| 29 | private async request<T>(path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> { |
| 30 | const response = await fetch(`${NOTION_API_BASE}${path}`, { |
| 31 | method, |
| 32 | headers: { |
| 33 | Authorization: `Bearer ${this.apiKey}`, |
| 34 | 'Notion-Version': NOTION_VERSION, |
| 35 | 'Content-Type': 'application/json', |
| 36 | }, |
| 37 | body: body ? JSON.stringify(body) : undefined, |
| 38 | }) |
| 39 | |
| 40 | if (!response.ok) { |
| 41 | const errorText = await response.text() |
| 42 | throw new Error(`Notion API request failed: ${response.status} - ${errorText}`) |
| 43 | } |
| 44 | |
| 45 | return (await response.json()) as T |
| 46 | } |
| 47 | |
| 48 | private getSchema(databaseId: string): Promise<NotionDatabaseSchema> { |
| 49 | let pending = this.schemaCache.get(databaseId) |
| 50 | if (!pending) { |
| 51 | // databaseId is validated at the schema layer, but encodeURIComponent |
| 52 | // keeps this safe as defense-in-depth if the client ever grows a caller |
| 53 | // that bypasses the zod boundary. |
| 54 | pending = this.request<NotionDatabaseSchema>( |
| 55 | `/databases/${encodeURIComponent(databaseId)}`, |
| 56 | 'GET' |
| 57 | ) |
| 58 | this.schemaCache.set(databaseId, pending) |
| 59 | } |
| 60 | return pending |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Create a page in the given database. Property types are auto-detected from |
| 65 | * the database schema, so values can be passed as plain strings/numbers and |
| 66 | * will be wrapped in the correct Notion property shape. |
| 67 | * Unknown columns (not present in the database) are silently skipped. |
| 68 | * |
| 69 | * If the database has an `email`-typed column and `values` includes a value |
| 70 | * for it, the database is first queried for an existing row with that email. |
| 71 | * If one exists, the call is a no-op (skip-on-duplicate). Notion has no |
| 72 | * native dedupe, so this is the only thing standing between us and a row |
| 73 | * for every refresh of the form. |
| 74 | */ |
| 75 | async createDatabasePage(databaseId: string, values: Record<string, unknown>): Promise<void> { |
| 76 | const schema = await this.getSchema(databaseId) |
| 77 | const properties: Record<string, unknown> = {} |
| 78 | |
| 79 | for (const [name, value] of Object.entries(values)) { |
| 80 | if (value === undefined || value === null || value === '') continue |
| 81 | const schemaProp = schema.properties[name] |
| 82 | if (!schemaProp) continue |
| 83 | const encoded = encodePropertyValue(schemaProp.type, value) |
| 84 | if (encoded !== undefined) properties[name] = encoded |
| 85 | } |
| 86 | |
| 87 | const emailColumn = Object.values(schema.properties).find((p) => p.type === 'email') |
| 88 | if (emailColumn) { |
| 89 | const incomingEmail = values[emailColumn.name] |
| 90 | if (typeof incomingEmail === 'string' && incomingEmail.trim() !== '') { |
| 91 | const exists = await this.pageExistsByEmail( |
| 92 | databaseId, |
| 93 | emailColumn.name, |
| 94 | incomingEmail.trim() |
| 95 | ) |
| 96 | if (exists) { |
| 97 | console.warn('[crm/notion] Skipping duplicate submission', { |
| 98 | databaseId, |
| 99 | emailColumn: emailColumn.name, |
| 100 | }) |
| 101 | return |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | await this.request('/pages', 'POST', { |
| 107 | parent: { database_id: databaseId }, |
| 108 | properties, |
| 109 | }) |
| 110 | } |
| 111 | |
| 112 | private async pageExistsByEmail( |
| 113 | databaseId: string, |
| 114 | propertyName: string, |
| 115 | email: string |
| 116 | ): Promise<boolean> { |
| 117 | const result = await this.request<{ results: unknown[] }>( |
| 118 | `/databases/${encodeURIComponent(databaseId)}/query`, |
| 119 | 'POST', |
| 120 | { |
| 121 | filter: { |
| 122 | property: propertyName, |
| 123 | email: { equals: email }, |
| 124 | }, |
| 125 | page_size: 1, |
| 126 | } |
| 127 | ) |
| 128 | return result.results.length > 0 |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | function encodePropertyValue(type: string, value: unknown): unknown { |
| 133 | const str = typeof value === 'string' ? value : String(value) |
| 134 | |
| 135 | switch (type) { |
| 136 | case 'title': |
| 137 | return { title: [{ text: { content: str } }] } |
| 138 | case 'rich_text': |
| 139 | return { rich_text: [{ text: { content: str } }] } |
| 140 | case 'email': |
| 141 | return { email: str } |
| 142 | case 'phone_number': |
| 143 | return { phone_number: str } |
| 144 | case 'url': |
| 145 | return { url: str } |
| 146 | case 'number': { |
| 147 | const n = typeof value === 'number' ? value : Number(str) |
| 148 | return Number.isFinite(n) ? { number: n } : undefined |
| 149 | } |
| 150 | case 'checkbox': { |
| 151 | const b = typeof value === 'boolean' ? value : str === 'true' || str === 'on' |
| 152 | return { checkbox: b } |
| 153 | } |
| 154 | case 'date': |
| 155 | return { date: { start: str } } |
| 156 | case 'select': |
| 157 | return { select: { name: str } } |
| 158 | case 'multi_select': |
| 159 | return { |
| 160 | multi_select: str |
| 161 | .split(',') |
| 162 | .map((s) => ({ name: s.trim() })) |
| 163 | .filter((o) => o.name), |
| 164 | } |
| 165 | default: |
| 166 | return { rich_text: [{ text: { content: str } }] } |
| 167 | } |
| 168 | } |