notion.ts168 lines · main
1import 'server-only'
2
3const NOTION_API_BASE = 'https://api.notion.com/v1'
4const NOTION_VERSION = '2022-06-28'
5
6export interface NotionConfig {
7 apiKey: string
8}
9
10interface NotionDatabaseProperty {
11 id: string
12 name: string
13 type: string
14}
15
16interface NotionDatabaseSchema {
17 properties: Record<string, NotionDatabaseProperty>
18}
19
20export 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
132function 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}