diff --git a/package-lock.json b/package-lock.json index a507c15..cdbcfd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@mozilla/readability": "^0.5.0", + "@notionhq/client": "^2.2.15", "@types/got": "^9.6.12", "@types/jsdom": "^21.1.6", "body-parser": "^1.20.2", @@ -1320,6 +1321,18 @@ "node": ">= 8" } }, + "node_modules/@notionhq/client": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/@notionhq/client/-/client-2.2.15.tgz", + "integrity": "sha512-XhdSY/4B1D34tSco/GION+23GMjaS9S2zszcqYkMHo8RcWInymF6L1x+Gk7EmHdrSxNFva2WM8orhC4BwQCwgw==", + "dependencies": { + "@types/node-fetch": "^2.5.10", + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -1991,6 +2004,28 @@ "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -7928,6 +7963,44 @@ "node": ">=6.0.0" } }, + "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==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/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==" + }, + "node_modules/node-fetch/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==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", diff --git a/package.json b/package.json index 17fa854..ab5fde9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@mozilla/readability": "^0.5.0", + "@notionhq/client": "^2.2.15", "@types/got": "^9.6.12", "@types/jsdom": "^21.1.6", "body-parser": "^1.20.2", diff --git a/src/api-docs/openAPIDocumentGenerator.ts b/src/api-docs/openAPIDocumentGenerator.ts index 40a6788..b430210 100644 --- a/src/api-docs/openAPIDocumentGenerator.ts +++ b/src/api-docs/openAPIDocumentGenerator.ts @@ -2,6 +2,7 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-open import { excelGeneratorRegistry } from '@/routes/excelGenerator/excelGeneratorRouter'; import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter'; +import { notionDatabaseRegistry } from '@/routes/notionDatabase/notionDatabaseRouter'; import { powerpointGeneratorRegistry } from '@/routes/powerpointGenerator/powerpointGeneratorRouter'; import { articleReaderRegistry } from '@/routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRegistry } from '@/routes/wordGenerator/wordGeneratorRouter'; @@ -15,6 +16,7 @@ export function generateOpenAPIDocument() { powerpointGeneratorRegistry, wordGeneratorRegistry, excelGeneratorRegistry, + notionDatabaseRegistry, ]); const generator = new OpenApiGeneratorV3(registry.definitions); diff --git a/src/routes/notionDatabase/notionDatabaseModel.ts b/src/routes/notionDatabase/notionDatabaseModel.ts new file mode 100644 index 0000000..4616e89 --- /dev/null +++ b/src/routes/notionDatabase/notionDatabaseModel.ts @@ -0,0 +1,271 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +// Define Notion Database Structure Reader +export type NotionDatabaseStructureViewerResponse = z.infer; +export const NotionDatabaseStructureViewerResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseStructureViewerRequestBodySchema = z.object({ + databaseId: z.string().openapi({ + description: 'The ID of the Notion database whose structure is being viewed.', + }), + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), +}); +export type NotionDatabaseStructureViewerRequestBody = z.infer; + +// Define Notion Database Create +export type NotionDatabaseCreatePageResponse = z.infer; +export const NotionDatabaseCreatePageResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseCreatePageRequestBodySchema = z.object({ + databaseId: z.string().openapi({ + description: 'The ID of the Notion database whose structure is being viewed.', + }), + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), + properties: z.array(z.object({})), +}); +export type NotionDatabaseCreatePageRequestBody = z.infer; + +// Define Notion Database Update +export type NotionDatabaseUpdatePageResponse = z.infer; +export const NotionDatabaseUpdatePageResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseUpdatePageRequestBodySchema = z.object({ + pageId: z.string().openapi({ + description: 'The ID of the Notion Page whose structure is being viewed.', + }), + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), + properties: z.array(z.object({})), +}); +export type NotionDatabaseUpdatePageRequestBody = z.infer; + +// Define Notion Database Delete +export type NotionDatabaseArchivePageResponse = z.infer; +export const NotionDatabaseArchivePageResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseArchivePageRequestBodySchema = z.object({ + pageId: z.string().openapi({ + description: 'The ID of the Notion Page whose structure is being viewed.', + }), + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), +}); +export type NotionDatabaseArchivePageRequestBody = z.infer; + +// Define Notion Database Query +export type NotionDatabaseQueryPageResponse = z.infer; +export const NotionDatabaseQueryPageResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseQueryPageRequestBodySchema = z.object({ + databaseId: z.string().openapi({ + description: 'The ID of the Notion Database whose structure is being viewed.', + }), + databaseStructure: z + .array( + z.object({ + name: z.string().openapi({ + description: 'The name of the property.', + }), + type: z + .string() + .openapi({ + description: 'The type of the property.', + }) + .refine( + (value) => + [ + 'title', + 'number', + 'multi_select', + 'select', + 'checkbox', + 'url', + 'status', + 'email', + 'date', + 'files', + 'phone_number', + 'rich_text', + ].includes(value), + { + message: 'Invalid type', + } + ), + options: z + .array( + z.object({ + name: z.string().openapi({ + description: 'Name of the option.', + }), + }) + ) + .optional() + .openapi({ + description: 'List of options for select, multi-select, and status properties.', + }), + }) + ) + .openapi({ + description: + 'An array of properties from the Notion database structure, used to generate filter or sort criteria.', + }), + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), + query: z.object({}), + sorts: z.array(z.object({})), + pageSize: z.number().optional(), + startCursor: z.any().optional(), +}); +export type NotionDatabaseQueryPageRequestBody = z.infer; + +// Define Notion Database Maker +export type NotionDatabaseMakerResponse = z.infer; +export const NotionDatabaseMakerResponseSchema = z.object({}); +// Request Body Schema +export const NotionDatabaseMakerRequestBodySchema = z.object({ + notionApiKey: z.string().openapi({ + description: + 'The Notion API Key getting from Notion Integration Page at https://www.notion.so/profile/integrations', + }), + parent: z + .object({ + type: z.enum(['page_id']), + pageId: z.string().optional(), + databaseId: z.string().optional(), + }) + .refine((data) => data.pageId, { + message: 'Page ID must be provided.', + }), + icon: z.string().optional(), + cover: z.string().url().optional(), + isInline: z.boolean().optional(), + title: z + .array( + z.object({ + type: z.literal('text'), + text: z + .object({ + content: z.string().nonempty('Content is required.'), + }) + .required(), + annotations: z + .object({ + italic: z.boolean().default(false), + bold: z.boolean().default(false), + color: z.string().default('default'), + strikethrough: z.boolean().default(false), + underline: z.boolean().default(false), + }) + .default({ + italic: false, + bold: false, + color: 'default', + strikethrough: false, + underline: false, + }) + .optional(), + }) + ) + .nonempty('Title is required.'), + description: z + .array( + z.object({ + type: z.literal('text'), + text: z + .object({ + content: z.string().nonempty('Content is required.'), + }) + .required(), + annotations: z + .object({ + italic: z.boolean().default(false), + bold: z.boolean().default(false), + color: z.string().default('default'), + strikethrough: z.boolean().default(false), + underline: z.boolean().default(false), + }) + .default({ + italic: false, + bold: false, + color: 'default', + strikethrough: false, + underline: false, + }) + .optional(), + }) + ) + .optional(), + notionProperties: z + .array( + z.object({ + propertyName: z.string().nonempty('Property name is required.'), + propertyType: z.enum([ + 'title', + 'rich_text', + 'number', + 'select', + 'status', + 'multi_select', + 'date', + 'url', + 'email', + 'phone_number', + 'checkbox', + 'files', + 'formula', + ]), + options: z + .array( + z.object({ + name: z.string().nonempty('Option name is required.'), + color: z + .enum(['default', 'gray', 'brown', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink', 'red']) + .optional(), + }) + ) + .optional(), + format: z + .enum([ + 'number', + 'number_with_commas', + 'percent', + 'dollar', + 'euro', + 'pound', + 'yen', + 'ruble', + 'rupee', + 'won', + 'yuan', + 'real', + 'lira', + 'franc', + 'singapore_dollar', + 'australian_dollar', + 'canadian_dollar', + 'hong_kong_dollar', + 'new_zealand_dollar', + ]) + .optional(), + formula: z.string().optional(), + dateFormat: z.string().optional(), + }) + ) + .nonempty('Notion properties are required.'), +}); +export type NotionDatabaseMakerRequestBody = z.infer; diff --git a/src/routes/notionDatabase/notionDatabaseRouter.ts b/src/routes/notionDatabase/notionDatabaseRouter.ts new file mode 100644 index 0000000..284b07e --- /dev/null +++ b/src/routes/notionDatabase/notionDatabaseRouter.ts @@ -0,0 +1,567 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { Client as NotionClient } from '@notionhq/client'; +import express, { Request, Response, Router } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +import { createApiRequestBody } from '@/api-docs/openAPIRequestBuilders'; +import { createApiResponse } from '@/api-docs/openAPIResponseBuilders'; +import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse'; +import { handleServiceResponse } from '@/common/utils/httpHandlers'; + +import { + NotionDatabaseArchivePageRequestBodySchema, + NotionDatabaseArchivePageResponseSchema, + NotionDatabaseCreatePageRequestBodySchema, + NotionDatabaseCreatePageResponseSchema, + NotionDatabaseMakerRequestBodySchema, + NotionDatabaseMakerResponseSchema, + NotionDatabaseQueryPageRequestBodySchema, + NotionDatabaseQueryPageResponseSchema, + NotionDatabaseStructureViewerRequestBodySchema, + NotionDatabaseStructureViewerResponseSchema, + NotionDatabaseUpdatePageRequestBodySchema, + NotionDatabaseUpdatePageResponseSchema, +} from './notionDatabaseModel'; +import { + buildColumnSchema, + mapNotionRichTextProperty, + validateDatabaseQueryConfig, + validateNotionProperties, +} from './utils'; + +export const COMPRESS = true; +export const notionDatabaseRegistry = new OpenAPIRegistry(); +notionDatabaseRegistry.register('Notion Database', NotionDatabaseStructureViewerResponseSchema); +notionDatabaseRegistry.registerPath({ + method: 'post', + path: '/notion-database/view-structure', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseStructureViewerRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseStructureViewerResponseSchema, 'Success'), +}); + +notionDatabaseRegistry.registerPath({ + method: 'post', + path: '/notion-database/create-page', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseCreatePageRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseCreatePageResponseSchema, 'Success'), +}); + +notionDatabaseRegistry.registerPath({ + method: 'patch', + path: '/notion-database/update-page', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseUpdatePageRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseUpdatePageResponseSchema, 'Success'), +}); + +notionDatabaseRegistry.registerPath({ + method: 'patch', + path: '/notion-database/archive-page', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseArchivePageRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseArchivePageResponseSchema, 'Success'), +}); + +notionDatabaseRegistry.registerPath({ + method: 'post', + path: '/notion-database/query-pages', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseQueryPageRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseQueryPageResponseSchema, 'Success'), +}); + +notionDatabaseRegistry.registerPath({ + method: 'post', + path: '/notion-database/create-database', + tags: ['Notion Database'], + request: { + body: createApiRequestBody(NotionDatabaseMakerRequestBodySchema, 'application/json'), + }, + responses: createApiResponse(NotionDatabaseMakerResponseSchema, 'Success'), +}); + +const DEFAULT_ANNOTATIONS = { + italic: false, + bold: false, + color: 'default', + strikethrough: false, + underline: false, +}; + +// Helper function to initialize the Notion client +export function initNotionClient(apiKey: string) { + return new NotionClient({ auth: apiKey }); +} + +function mapNotionPropertyRequestBody(properties: any[] = []) { + // Construct the properties object from the notionProperties array + const notionProperties: any = {}; + properties.forEach((property: any) => { + const { propertyName, propertyType, value } = property; + + // Map each property type to the appropriate format for the Notion API + switch (propertyType) { + case 'title': + case 'rich_text': + notionProperties[propertyName] = { + [propertyType]: value.map((item: any) => { + const annotationConfigs = item.annotations || DEFAULT_ANNOTATIONS; + return { + type: 'text', + text: { content: item.text.content }, + annotations: { + italic: annotationConfigs.italic !== undefined ? annotationConfigs.italic : DEFAULT_ANNOTATIONS.italic, + bold: annotationConfigs.bold !== undefined ? annotationConfigs.bold : DEFAULT_ANNOTATIONS.bold, + color: annotationConfigs.color ? annotationConfigs.color : DEFAULT_ANNOTATIONS.color, + strikethrough: + annotationConfigs.strikethrough !== undefined + ? annotationConfigs.strikethrough + : DEFAULT_ANNOTATIONS.strikethrough, + underline: + annotationConfigs.underline !== undefined + ? annotationConfigs.underline + : DEFAULT_ANNOTATIONS.underline, + }, + }; + }), + }; + break; + case 'number': + notionProperties[propertyName] = { + number: value, + }; + break; + case 'select': + case 'status': + notionProperties[propertyName] = { + [propertyType]: { + name: value.name, + }, + }; + break; + case 'multi_select': + notionProperties[propertyName] = { + multi_select: value.map((item: any) => ({ + name: item.name, + })), + }; + break; + case 'date': + notionProperties[propertyName] = { + date: { + start: value.start, + end: value.end || null, + time_zone: value.time_zone || null, + }, + }; + break; + case 'url': + notionProperties[propertyName] = { + url: value, + }; + break; + case 'email': + notionProperties[propertyName] = { + email: value, + }; + break; + case 'phone_number': + notionProperties[propertyName] = { + phone_number: value, + }; + break; + case 'checkbox': + notionProperties[propertyName] = { + checkbox: value, + }; + break; + case 'files': + notionProperties[propertyName] = { + // Note: This verion only support external files + files: value + .filter((file: any) => file.external && file.external.url) + .map((file: any) => ({ + name: file.name, + external: { + url: file.external.url, + }, + })), + }; + break; + default: + console.info(`Unknown property type: ${propertyType}`); + break; + } + }); + + return notionProperties; +} + +export const notionDatabaseRouter: Router = (() => { + const router = express.Router(); + + router.post('/view-structure', async (_req: Request, res: Response) => { + const { notionApiKey, databaseId } = _req.body; + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (!databaseId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Database ID is required!', + 'Please make sure you have sent the Database ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const notion = initNotionClient(notionApiKey); + const database = await notion.databases.retrieve({ database_id: databaseId }); + const result = { + databaseId: databaseId, + structure: database.properties, + }; + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Structure retrieved successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + let responseObject = ''; + ``; + if (errorMessage.includes('')) { + responseObject = `Sorry, we couldn't get the database structure.`; + } + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error ${errorMessage}`, + responseObject, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + router.post('/create-page', async (_req: Request, res: Response) => { + const { notionApiKey, databaseId, properties, databaseStructure = [] } = _req.body; + + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (!databaseId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Database ID is required!', + 'Please make sure you have sent the Database ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + try { + const notion = initNotionClient(notionApiKey); + // Validate properties before creating + validateNotionProperties(databaseStructure, properties); + const notionProperties = mapNotionPropertyRequestBody(properties); + const result = await notion.pages.create({ + parent: { database_id: databaseId }, + properties: notionProperties, + }); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Page created successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error: ${errorMessage}`, + `Sorry, we couldn't create new page in the Notion database!`, + errorMessage.includes('[Validation Error]') ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + router.post('/update-page', async (_req: Request, res: Response) => { + const { notionApiKey, pageId, properties, databaseStructure = [] } = _req.body; + + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (!pageId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Page ID is required!', + 'Please make sure you have sent the Page ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const notion = initNotionClient(notionApiKey); + // Validate properties before creating + validateNotionProperties(databaseStructure, properties); + const notionProperties = mapNotionPropertyRequestBody(properties); + const result = await notion.pages.update({ + page_id: pageId, + properties: notionProperties, + }); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Page updated successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error: ${errorMessage}`, + `Sorry, we couldn't update the page!!`, + errorMessage.includes('[Validation Error]') ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + router.post('/archive-page', async (_req: Request, res: Response) => { + const { notionApiKey, pageId } = _req.body; + + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (!pageId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Page ID is required!', + 'Please make sure you have sent the Page ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const notion = initNotionClient(notionApiKey); + const result = await notion.pages.update({ + page_id: pageId, + archived: true, + }); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Page removed successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + let responseObject = ''; + if (errorMessage.includes('')) { + responseObject = `Sorry, we couldn't remove the page!`; + } + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error ${errorMessage}`, + responseObject, + StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + router.post('/query-pages', async (_req: Request, res: Response) => { + const { + notionApiKey, + databaseId, + databaseStructure = [], + filter = {}, + sorts = [], + pageSize = 100, + startCursor, + } = _req.body; + + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (!databaseId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Database ID is required!', + 'Please make sure you have sent the Database ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const notion = initNotionClient(notionApiKey); + // Validate databaseStructure against filters and sorts + validateDatabaseQueryConfig(databaseStructure, filter, sorts); + const result = await notion.databases.query({ + database_id: databaseId, + filter, + sorts, + page_size: pageSize, + start_cursor: startCursor, + }); + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Pages query successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error: ${errorMessage}`, + `Sorry, we couldn't query the pages!`, + errorMessage.includes('[Validation Error]') ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + router.post('/create-database', async (_req: Request, res: Response) => { + const { + notionApiKey, + parent, + icon, + cover, + title, + description, + isInline = false, + notionProperties = [], + } = _req.body; + + if (!notionApiKey) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Notion Key is required!', + 'Please make sure you have sent the Notion Key from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + if (parent && parent.type === 'page_id' && !parent.pageId) { + const validateServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + '[Validation Error] Page ID is required!. Please provide specific Page ID or Page URL', + 'Please make sure you have sent the Page ID from TypingMind.', + StatusCodes.BAD_REQUEST + ); + return handleServiceResponse(validateServiceResponse, res); + } + + try { + const notion = initNotionClient(notionApiKey); + + // Initialize an empty object to hold properties + const databaseProperties: Record = {}; + // Iterate over notionProperties and maintain the order + notionProperties.forEach((property: any) => { + const schema = buildColumnSchema(property); // Assume this builds the required schema + for (const [key, value] of Object.entries(schema.properties)) { + databaseProperties[key] = value; + } + }); + + // Prepare the request payload to create the Notion database + const payload: any = { + parent: { type: parent.type, page_id: parent.pageId }, + title: mapNotionRichTextProperty(title), + description: mapNotionRichTextProperty(description), + is_inline: isInline, + properties: databaseProperties, + }; + + if (icon) { + payload.icon = { type: 'emoji', emoji: icon }; + } + + if (cover) { + payload.cover = { type: 'external', external: { url: cover } }; + } + + // Call the Notion client to create the database + const result = await notion.databases.create(payload); + + const serviceResponse = new ServiceResponse( + ResponseStatus.Success, + 'Database created successfully', + result, + StatusCodes.OK + ); + return handleServiceResponse(serviceResponse, res); + } catch (error) { + const errorMessage = (error as Error).message; + const errorServiceResponse = new ServiceResponse( + ResponseStatus.Failed, + `Error: ${errorMessage}`, + `Sorry, we couldn't create Database!`, + errorMessage.includes('[Validation Error]') ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR + ); + return handleServiceResponse(errorServiceResponse, res); + } + }); + + return router; +})(); diff --git a/src/routes/notionDatabase/utils.ts b/src/routes/notionDatabase/utils.ts new file mode 100644 index 0000000..58ea80e --- /dev/null +++ b/src/routes/notionDatabase/utils.ts @@ -0,0 +1,165 @@ +function flattenConditions(conditions: any[]): any[] { + return conditions.reduce((acc, condition) => { + if (condition.and) { + // Recursively flatten "and" conditions + return acc.concat(flattenConditions(condition.and)); + } else if (condition.or) { + // Recursively flatten "or" conditions + return acc.concat(flattenConditions(condition.or)); + } + // Base case: direct condition object + return acc.concat(condition); + }, []); +} + +export function validateDatabaseQueryConfig(databaseStructures: any[], filter: any, sorts: any[]): void { + const validPropertyNames = databaseStructures.map((property) => property.name); + + // Flatten filter conditions recursively + let flatConditions = []; + if (Object.prototype.hasOwnProperty.call(filter, 'and') || Object.prototype.hasOwnProperty.call(filter, 'or')) { + flatConditions = flattenConditions(filter.and || filter.or); + } + + // Validate flattened filter conditions + for (const condition of flatConditions) { + if (!validPropertyNames.includes(condition.property)) { + throw new Error( + `[Validation Error] The property '${condition.property}' used in filters is invalid. Make sure it matches with current database structure.` + ); + } + } + + // Validate sorts + for (const sort of sorts) { + if (!validPropertyNames.includes(sort.property)) { + throw new Error( + `[Validation Error] The property '${sort.property}' used in sorts is invalid. Make sure it matches with current database structure.` + ); + } + } +} + +export function validateNotionProperties(databaseStructure: any[], properties: any[]): void { + const validProperties = new Map(databaseStructure.map((prop) => [prop.name, prop.type])); + + properties.forEach((property) => { + const { propertyName, propertyType } = property; + + if (!validProperties.has(propertyName)) { + throw new Error( + `[Validation Error] Property '${propertyName}' does not exist in the database structure. Make sure it matches with the current database structure.` + ); + } + + if (validProperties.get(propertyName) !== propertyType) { + throw new Error( + `[Validation Error] Property '${propertyName}' has an invalid type '${propertyType}'. Expected type is '${validProperties.get(propertyName)}'.` + ); + } + }); +} + +export function buildColumnSchema({ propertyName, propertyType, options = [], format, formatDate, formula = '' }: any) { + const schema: any = { + properties: {}, + }; + + // Define properties based on the column type + switch (propertyType) { + case 'title': + schema.properties[propertyName] = { title: {} }; + break; + case 'rich_text': + schema.properties[propertyName] = { rich_text: {} }; + break; + case 'number': + schema.properties[propertyName] = { number: { format: format } }; + break; + case 'select': + schema.properties[propertyName] = { + select: { + options: options.map((option: any) => ({ + name: option.name, + color: option.color || 'default', + })), + }, + }; + break; + case 'multi_select': + schema.properties[propertyName] = { + multi_select: { + options: options.map((option: any) => ({ + name: option.name, + color: option.color || 'default', + })), + }, + }; + break; + case 'date': + schema.properties[propertyName] = { + date: { + format: formatDate, + }, + }; + break; + case 'checkbox': + schema.properties[propertyName] = { checkbox: {} }; + break; + case 'url': + schema.properties[propertyName] = { url: {} }; + break; + case 'email': + schema.properties[propertyName] = { email: {} }; + break; + case 'phone_number': + schema.properties[propertyName] = { phone_number: {} }; + break; + case 'formula': + schema.properties[propertyName] = { + formula: { + expression: formula, // Formula expression goes here + }, + }; + break; + case 'status': + schema.properties[propertyName] = { status: {} }; + break; + case 'files': + schema.properties[propertyName] = { files: {} }; + break; + default: + throw new Error(`Unknown column type: ${propertyType}`); + } + + return schema; +} + +export function mapNotionRichTextProperty(value: any[]) { + const DEFAULT_ANNOTATIONS = { + italic: false, + bold: false, + color: 'default', + strikethrough: false, + underline: false, + }; + + return value.map((item) => { + const annotationConfigs = item.annotations || DEFAULT_ANNOTATIONS; + return { + type: 'text', + text: { content: item.text.content }, + annotations: { + italic: annotationConfigs.italic !== undefined ? annotationConfigs.italic : DEFAULT_ANNOTATIONS.italic, + bold: annotationConfigs.bold !== undefined ? annotationConfigs.bold : DEFAULT_ANNOTATIONS.bold, + color: annotationConfigs.color ? annotationConfigs.color : DEFAULT_ANNOTATIONS.color, + strikethrough: + annotationConfigs.strikethrough !== undefined + ? annotationConfigs.strikethrough + : DEFAULT_ANNOTATIONS.strikethrough, + underline: + annotationConfigs.underline !== undefined ? annotationConfigs.underline : DEFAULT_ANNOTATIONS.underline, + }, + }; + }); +} diff --git a/src/server.ts b/src/server.ts index 0da49de..90d8872 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import requestLogger from '@/common/middleware/requestLogger'; import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter'; import { excelGeneratorRouter } from './routes/excelGenerator/excelGeneratorRouter'; +import { notionDatabaseRouter } from './routes/notionDatabase/notionDatabaseRouter'; import { powerpointGeneratorRouter } from './routes/powerpointGenerator/powerpointGeneratorRouter'; import { webPageReaderRouter } from './routes/webPageReader/webPageReaderRouter'; import { wordGeneratorRouter } from './routes/wordGenerator/wordGeneratorRouter'; @@ -42,6 +43,8 @@ app.use('/web-page-reader', webPageReaderRouter); app.use('/powerpoint-generator', powerpointGeneratorRouter); app.use('/word-generator', wordGeneratorRouter); app.use('/excel-generator', excelGeneratorRouter); +app.use('/notion-database', notionDatabaseRouter); + // Swagger UI app.use(openAPIRouter);