├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── Makefile ├── Procfile ├── _env ├── app.json ├── docker-compose.yaml ├── jest.config.js ├── manifest.yml ├── package.json ├── prisma ├── migrations │ ├── 20210709093901_init │ │ └── migration.sql │ ├── 20210709135649_2nd │ │ └── migration.sql │ ├── 20210710105418_3rd │ │ └── migration.sql │ ├── 20210712095954_5th │ │ └── migration.sql │ ├── 20210712133146_change_datetime_type │ │ └── migration.sql │ ├── 20210714021121_make_notion_id_defact_id_add_user_table │ │ └── migration.sql │ ├── 20210715060252_ │ │ └── migration.sql │ ├── 20210716061241_add_name_database │ │ └── migration.sql │ ├── 20210726100503_remove_1st_integrated │ │ └── migration.sql │ ├── 20210910010843_ │ │ └── migration.sql │ ├── 20211109052637_add_url_column_on_database │ │ └── migration.sql │ ├── 20211129091808_add_properties_column_as_json_type │ │ └── migration.sql │ ├── 20211208051756_ │ │ └── migration.sql │ ├── 20211208070545_ │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── readme.md ├── src ├── @types │ ├── global.d.ts │ └── notion-api-types.d.ts ├── Config.ts ├── Scheduler.ts ├── Slack.ts ├── errors │ ├── NotionError.ts │ ├── SentryError.ts │ └── index.ts ├── index.ts ├── model │ ├── entity │ │ ├── Database.ts │ │ ├── Entity.ts │ │ ├── Page.ts │ │ ├── User.ts │ │ └── index.ts │ └── valueObject │ │ ├── DatabaseId.ts │ │ ├── NameProperty.ts │ │ ├── PrimitiveValueObject.ts │ │ ├── UserId.ts │ │ ├── ValueObject.ts │ │ ├── index.ts │ │ ├── notion │ │ ├── blocks │ │ │ ├── Annotations.tsx │ │ │ ├── BulletedListItem.tsx │ │ │ └── RichText.tsx │ │ ├── databaseProperties │ │ │ ├── Properties.ts │ │ │ ├── VisibleProperties.ts │ │ │ ├── default │ │ │ │ ├── BaseProperty.ts │ │ │ │ ├── CheckboxProperty.ts │ │ │ │ ├── DateProperty.ts │ │ │ │ ├── MultiSelectProperty.ts │ │ │ │ ├── NumberProperty.ts │ │ │ │ ├── PeopleProperty.ts │ │ │ │ ├── SelectProperty.ts │ │ │ │ ├── TextProperty.ts │ │ │ │ ├── TitleProperty.ts │ │ │ │ ├── URLProperty.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── subset │ │ │ │ ├── UserBlock.ts │ │ │ │ └── index.ts │ │ └── index.ts │ │ └── slack │ │ ├── BlockKit.tsx │ │ ├── Header.tsx │ │ ├── MainBlocks.tsx │ │ ├── NotionContentBlock.tsx │ │ └── notion │ │ ├── Properties.tsx │ │ └── properties │ │ ├── DateProperty.tsx │ │ ├── MultiSelectProperty.tsx │ │ └── SelectProperty.tsx ├── repository │ ├── NotionRepository.ts │ ├── PrismaDatabaseRepository.ts │ ├── PrismaPageRepository.ts │ └── index.ts └── utils │ ├── array.ts │ ├── index.ts │ ├── notion.ts │ ├── parser.ts │ ├── prisma.ts │ └── trim.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | globals: { 13 | Atomics: "readonly", 14 | SharedArrayBuffer: "readonly", 15 | }, 16 | parser: "@typescript-eslint/parser", 17 | parserOptions: { 18 | sourceType: "module", 19 | }, 20 | plugins: ["@typescript-eslint"], 21 | rules: { 22 | "@typescript-eslint/explicit-function-return-type": "off", 23 | "@typescript-eslint/explicit-module-boundary-types": "off", 24 | "@typescript-eslint/no-namespace": "off" 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build-and-test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - run: npm run build 28 | - run: npm run lint 29 | # TODO: pass jest 30 | # - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | yarn-error.log 4 | db 5 | data 6 | dist 7 | out 8 | installer.sh 9 | .vscode/ 10 | !.vscode/launch.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "semi": true, 4 | "arrowParens": "always", 5 | "parser": "typescript" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch TypeScript", 8 | "program": "${workspaceFolder}/dist/index.js", 9 | "preLaunchTask": "Transpile TypeScript", 10 | "runtimeArgs": ["-r", 11 | "${workspaceFolder}/node_modules/ts-node/register", 12 | "-r", 13 | "${workspaceFolder}/node_modules/tsconfig-paths/register" 14 | ], 15 | "args": ["${workspaceFolder}/src/index.ts"], 16 | "cwd": "${workspaceFolder}", 17 | "console": "integratedTerminal", 18 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Transpile TypeScript", 6 | "type": "typescript", 7 | "tsconfig": "tsconfig.json", 8 | "problemMatcher": ["$tsc"], 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 75asa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker compose up -d 3 | down: 4 | docker compose down -v 5 | ps: 6 | docker compose ps -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: yarn start 2 | -------------------------------------------------------------------------------- /_env: -------------------------------------------------------------------------------- 1 | # Notion 2 | NOTION_KEY=secret_XXXXX 3 | NOTION_VISIBLE_PROPS=Tags,Contributors 4 | # Notion multi exist Props 5 | NOTION_NAME_PROP= 6 | NOTION_CREATED_AT_PROP= 7 | NOTION_LAST_EDITED_BY_PROP= 8 | NOTION_IS_PUBLISHED_PROP= 9 | 10 | # Slack 11 | SLACK_BOT_TOKEN= 12 | SLACK_CHANNEL_NAMES=t_nipppo,nippo_sand_1 13 | 14 | # Sentry 15 | SENTRY_DSN= 16 | 17 | # Job 18 | JOB_INTERVAL_SECONDS=30 19 | 20 | # Prisma DB 21 | # This text is inserted by `prisma init`: 22 | # Environment variables declared in this file are automatically made available to Prisma. 23 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables 24 | 25 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQL Server and SQLite. 26 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 27 | DATABASE_URL="postgresql://USER:PASS@HOST:PORT/DB_NAME" 28 | 29 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-database-crawler", 3 | "description": "Slack x Notion", 4 | "repository": "https://github.com/75asa/notion-database-crawler", 5 | "keywords": [ 6 | "Slack", 7 | "Slack API", 8 | "TypeScript", 9 | "Heroku", 10 | "Notion", 11 | "Notion API" 12 | ], 13 | "env": { 14 | "NOTION_KEY": { 15 | "description": "Notion KEY FYI: https://developers.notion.com/docs/getting-started", 16 | "value": "secret_xxxx" 17 | }, 18 | "SLACK_BOT_TOKEN": { 19 | "description": "Slack App Bot Token - https://api.slack.com/apps", 20 | "value": "xoxb-************-************-************************" 21 | }, 22 | "SLACK_CHANNEL_NAMES": { 23 | "description": "the channel daily reports are posted, if you wanna use multiple channels, separate them with a comma e.g `general,notifications`", 24 | "value": "t_nippo" 25 | }, 26 | "JOB_INTERVAL_SECONDS": { 27 | "description": "job's default interval", 28 | "value": "30" 29 | }, 30 | "NOTION_VISIBLE_PROPS": { 31 | "description": "If you wanna display multiple properties on a Slack, separate them with a comma e.g `title,description,Date`", 32 | "value": "", 33 | "required": false 34 | }, 35 | "NOTION_NAME_PROP": { 36 | "description": "`title` type prop name. default Name", 37 | "value": "Name", 38 | "required": false 39 | }, 40 | "NOTION_CREATED_AT_PROP": { 41 | "description": "`created_at` type prop name at Notion. default CreatedAt", 42 | "value": "CreatedAt", 43 | "required": false 44 | }, 45 | "NOTION_LAST_EDITED_BY_PROP": { 46 | "description": "`last_edited_by` type prop name at Notion. default LastEditedBy", 47 | "value": "LastEditedBy", 48 | "required": false 49 | }, 50 | "NOTION_IS_PUBLISHED_PROP": { 51 | "description": "`checkbox` type prop name to notify Slack at Notion. default IsPublished", 52 | "value": "IsPublished", 53 | "required": false 54 | }, 55 | "YARN_PRODUCTION": { 56 | "description": "for TypeScript build", 57 | "value": "false" 58 | } 59 | }, 60 | "image": "heroku/nodejs", 61 | "addons": [ 62 | "papertrail", 63 | "heroku-postgresql", 64 | "sentry" 65 | ], 66 | "scripts": { 67 | "postdeploy": "yarn migrate:deploy" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgres:13 6 | container_name: "postgres-notion-database-crawler" 7 | restart: always 8 | environment: 9 | POSTGRES_USER: notion #optional 10 | POSTGRES_PASSWORD: nippo #required 11 | TZ: "Asia/Tokyo" 12 | ports: 13 | - 5432:5432 14 | volumes: 15 | - ./data:/var/lib/postgresql/data 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | testMatch: [ 4 | "**/__tests__/**/*.+(ts|tsx|js)", 5 | "**/?(*.)+(spec|test).+(ts|tsx|js)", 6 | ], 7 | transform: { 8 | "^.+\\.(ts|tsx)$": "ts-jest", 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | _metadata: 2 | major_version: 1 3 | minor_version: 1 4 | display_information: 5 | name: DatabaseCrawler 6 | features: 7 | app_home: 8 | home_tab_enabled: false 9 | messages_tab_enabled: true 10 | messages_tab_read_only_enabled: true 11 | bot_user: 12 | display_name: DatabaseCrawler 13 | always_online: false 14 | oauth_config: 15 | scopes: 16 | bot: 17 | - chat:write 18 | - chat:write.customize 19 | settings: 20 | org_deploy_enabled: false 21 | socket_mode_enabled: false 22 | token_rotation_enabled: false 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-database-crawler", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "homepage": "https://github.com/75asa/notion-database-crawler#readme", 7 | "bugs": { 8 | "url": "https://github.com/75asa/notion-database-crawler/issues" 9 | }, 10 | "license": "MIT", 11 | "author": "75asa", 12 | "main": "dist/index.js", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/75asa/notion-database-crawler.git" 16 | }, 17 | "scripts": { 18 | "ts-node": "ts-node", 19 | "test": "jest", 20 | "dev": "ts-node -r tsconfig-paths/register --files src/index.ts", 21 | "dev:watch": "ts-node-dev --respawn -r tsconfig-paths/register --files src/index.ts", 22 | "start": "node dist/index.js", 23 | "lint": "eslint --ext .js,.ts --ignore-path .gitignore .", 24 | "lint-fix": "yarn lint --fix && prettier --write \"./{__tests__,src}/**/*.{js,ts}\"", 25 | "build": "tsc -p . --project tsconfig.json && tsc-alias -p tsconfig.json", 26 | "postinstall": "yarn build", 27 | "generate": "yarn prisma generate", 28 | "migrate": "yarn prisma migrate dev", 29 | "migrate:reset": "yarn prisma migrate reset", 30 | "migrate:deploy": "yarn prisma migrate deploy", 31 | "deploy": "tsc . && node dist/index.js" 32 | }, 33 | "dependencies": { 34 | "@notionhq/client": "^0.4.9", 35 | "@prisma/client": "3.0.2", 36 | "@sentry/node": "^6.13.3", 37 | "@sentry/tracing": "^6.13.3", 38 | "@slack/web-api": "^6.4.0", 39 | "dayjs": "^1.10.6", 40 | "dotenv": "^10.0.0", 41 | "jsx-slack": "^4.3.0", 42 | "shallow-equal-object": "^1.1.1", 43 | "toad-scheduler": "^1.5.0", 44 | "tsc-alias": "^1.4.1" 45 | }, 46 | "devDependencies": { 47 | "@types/jest": "^27.0.2", 48 | "@types/node": "^16.9.1", 49 | "@typescript-eslint/eslint-plugin": "^4.31.0", 50 | "@typescript-eslint/parser": "^4.31.0", 51 | "eslint": "^7.32.0", 52 | "eslint-config-prettier": "^8.3.0", 53 | "jest": "^27.2.3", 54 | "prettier": "^2.4.1", 55 | "prisma": "3.0.2", 56 | "ts-jest": "^27.0.5", 57 | "ts-node": "^10.2.1", 58 | "ts-node-dev": "^1.1.8", 59 | "tsconfig-paths": "^3.12.0", 60 | "typescript": "^4.4.2" 61 | }, 62 | "engines": { 63 | "yarn": "1.*", 64 | "node": ">=16.x" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /prisma/migrations/20210709093901_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Cluster" ( 3 | "id" TEXT NOT NULL, 4 | "lastFetchedAt" TIMESTAMPTZ NOT NULL, 5 | "firstIntegratedAt" DATE NOT NULL, 6 | "databaseId" TEXT, 7 | 8 | PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Database" ( 13 | "id" TEXT NOT NULL, 14 | "notionId" TEXT NOT NULL, 15 | "createdAt" TIMESTAMP(3) NOT NULL, 16 | "lastEditedAt" TIMESTAMP(3) NOT NULL, 17 | "size" INTEGER NOT NULL, 18 | 19 | PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Page" ( 24 | "id" TEXT NOT NULL, 25 | "databaseId" TEXT, 26 | "notionId" TEXT NOT NULL, 27 | "createdAt" TIMESTAMP(3) NOT NULL, 28 | "url" TEXT NOT NULL, 29 | 30 | PRIMARY KEY ("id") 31 | ); 32 | 33 | -- CreateIndex 34 | CREATE UNIQUE INDEX "Cluster_databaseId_unique" ON "Cluster"("databaseId"); 35 | 36 | -- CreateIndex 37 | CREATE UNIQUE INDEX "Database.notionId_unique" ON "Database"("notionId"); 38 | 39 | -- CreateIndex 40 | CREATE UNIQUE INDEX "Page.notionId_unique" ON "Page"("notionId"); 41 | 42 | -- AddForeignKey 43 | ALTER TABLE "Cluster" ADD FOREIGN KEY ("databaseId") REFERENCES "Database"("id") ON DELETE SET NULL ON UPDATE CASCADE; 44 | 45 | -- AddForeignKey 46 | ALTER TABLE "Page" ADD FOREIGN KEY ("databaseId") REFERENCES "Database"("id") ON DELETE SET NULL ON UPDATE CASCADE; 47 | -------------------------------------------------------------------------------- /prisma/migrations/20210709135649_2nd/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Changed the type of `firstIntegratedAt` on the `Cluster` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Cluster" DROP COLUMN "firstIntegratedAt", 9 | ADD COLUMN "firstIntegratedAt" TIMETZ NOT NULL; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20210710105418_3rd/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Cluster` table. If the table is not empty, all the data it contains will be lost. 5 | - Added the required column `firstIntegratedAt` to the `Database` table without a default value. This is not possible if the table is not empty. 6 | - Added the required column `lastFetchedAt` to the `Database` table without a default value. This is not possible if the table is not empty. 7 | - Made the column `databaseId` on table `Page` required. This step will fail if there are existing NULL values in that column. 8 | 9 | */ 10 | -- DropForeignKey 11 | ALTER TABLE "Cluster" DROP CONSTRAINT "Cluster_databaseId_fkey"; 12 | 13 | -- AlterTable 14 | ALTER TABLE "Database" ADD COLUMN "firstIntegratedAt" TIMETZ NOT NULL, 15 | ADD COLUMN "lastFetchedAt" TIMESTAMPTZ NOT NULL, 16 | ALTER COLUMN "size" SET DEFAULT 0; 17 | 18 | -- AlterTable 19 | ALTER TABLE "Page" ALTER COLUMN "databaseId" SET NOT NULL; 20 | 21 | -- DropTable 22 | DROP TABLE "Cluster"; 23 | -------------------------------------------------------------------------------- /prisma/migrations/20210712095954_5th/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Changed the type of `firstIntegratedAt` on the `Database` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Database" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ, 9 | ALTER COLUMN "lastEditedAt" SET DATA TYPE TIMESTAMPTZ, 10 | DROP COLUMN "firstIntegratedAt", 11 | ADD COLUMN "firstIntegratedAt" TIMESTAMPTZ NOT NULL; 12 | 13 | -- AlterTable 14 | ALTER TABLE "Page" ADD COLUMN "name" TEXT, 15 | ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20210712133146_change_datetime_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Database" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), 3 | ALTER COLUMN "lastEditedAt" SET DATA TYPE TIMESTAMP(3), 4 | ALTER COLUMN "lastFetchedAt" SET DATA TYPE TIMESTAMP(3), 5 | ALTER COLUMN "firstIntegratedAt" SET DATA TYPE TIMESTAMP(3); 6 | 7 | -- AlterTable 8 | ALTER TABLE "Page" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20210714021121_make_notion_id_defact_id_add_user_table/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `Database` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `notionId` on the `Database` table. All the data in the column will be lost. 6 | - The primary key for the `Page` table will be changed. If it partially fails, the table could be left without primary key constraint. 7 | - You are about to drop the column `databaseId` on the `Page` table. All the data in the column will be lost. 8 | - You are about to drop the column `notionId` on the `Page` table. All the data in the column will be lost. 9 | - A unique constraint covering the columns `[id]` on the table `Database` will be added. If there are existing duplicate values, this will fail. 10 | - A unique constraint covering the columns `[id]` on the table `Page` will be added. If there are existing duplicate values, this will fail. 11 | - Added the required column `userId` to the `Page` table without a default value. This is not possible if the table is not empty. 12 | 13 | */ 14 | -- DropForeignKey 15 | ALTER TABLE "Page" DROP CONSTRAINT "Page_databaseId_fkey"; 16 | 17 | -- DropIndex 18 | DROP INDEX "Database.notionId_unique"; 19 | 20 | -- DropIndex 21 | DROP INDEX "Page.notionId_unique"; 22 | 23 | -- AlterTable 24 | ALTER TABLE "Database" DROP CONSTRAINT "Database_pkey", 25 | DROP COLUMN "notionId"; 26 | 27 | -- AlterTable 28 | ALTER TABLE "Page" DROP CONSTRAINT "Page_pkey", 29 | DROP COLUMN "databaseId", 30 | DROP COLUMN "notionId", 31 | ADD COLUMN "userId" TEXT NOT NULL; 32 | 33 | -- CreateTable 34 | CREATE TABLE "User" ( 35 | "id" TEXT NOT NULL, 36 | "name" TEXT NOT NULL, 37 | "avatarURL" TEXT NOT NULL, 38 | "email" TEXT NOT NULL 39 | ); 40 | 41 | -- CreateIndex 42 | CREATE UNIQUE INDEX "User.id_unique" ON "User"("id"); 43 | 44 | -- CreateIndex 45 | CREATE UNIQUE INDEX "Database.id_unique" ON "Database"("id"); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "Page.id_unique" ON "Page"("id"); 49 | 50 | -- AddForeignKey 51 | ALTER TABLE "Page" ADD FOREIGN KEY ("id") REFERENCES "Database"("id") ON DELETE CASCADE ON UPDATE CASCADE; 52 | 53 | -- AddForeignKey 54 | ALTER TABLE "Page" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 55 | -------------------------------------------------------------------------------- /prisma/migrations/20210715060252_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `databaseId` to the `Page` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "Page" DROP CONSTRAINT "Page_id_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "Page" ADD COLUMN "databaseId" TEXT NOT NULL, 12 | ALTER COLUMN "userId" DROP NOT NULL; 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "Page" ADD FOREIGN KEY ("databaseId") REFERENCES "Database"("id") ON DELETE CASCADE ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20210716061241_add_name_database/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Database" ADD COLUMN "name" TEXT; 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20210726100503_remove_1st_integrated/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `firstIntegratedAt` on the `Database` table. All the data in the column will be lost. 5 | - Made the column `name` on table `Database` required. This step will fail if there are existing NULL values in that column. 6 | - Made the column `name` on table `Page` required. This step will fail if there are existing NULL values in that column. 7 | - Made the column `userId` on table `Page` required. This step will fail if there are existing NULL values in that column. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Database" DROP COLUMN "firstIntegratedAt", 12 | ALTER COLUMN "name" SET NOT NULL; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Page" ALTER COLUMN "name" SET NOT NULL, 16 | ALTER COLUMN "userId" SET NOT NULL; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20210910010843_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `lastFetchedAt` on the `Database` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Database" DROP COLUMN "lastFetchedAt"; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20211109052637_add_url_column_on_database/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `url` to the `Database` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "Page" DROP CONSTRAINT "Page_databaseId_fkey"; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "Page" DROP CONSTRAINT "Page_userId_fkey"; 12 | 13 | -- AlterTable 14 | ALTER TABLE "Database" ADD COLUMN "url" TEXT NOT NULL; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "Page" ADD CONSTRAINT "Page_databaseId_fkey" FOREIGN KEY ("databaseId") REFERENCES "Database"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "Page" ADD CONSTRAINT "Page_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 21 | 22 | -- RenameIndex 23 | ALTER INDEX "Database.id_unique" RENAME TO "Database_id_key"; 24 | 25 | -- RenameIndex 26 | ALTER INDEX "Page.id_unique" RENAME TO "Page_id_key"; 27 | 28 | -- RenameIndex 29 | ALTER INDEX "User.id_unique" RENAME TO "User_id_key"; 30 | -------------------------------------------------------------------------------- /prisma/migrations/20211129091808_add_properties_column_as_json_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Page" ADD COLUMN "properties" JSONB; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20211208051756_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `avatarURL` on the `User` table. All the data in the column will be lost. 5 | - Added the required column `dbCreatedAt` to the `Database` table without a default value. This is not possible if the table is not empty. 6 | - Added the required column `pageCreatedAt` to the `Page` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `avatarUrl` to the `User` table without a default value. This is not possible if the table is not empty. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Database" ADD COLUMN "dbCreatedAt" TIMESTAMP(3) NOT NULL, 12 | ALTER COLUMN "createdAt" DROP NOT NULL, 13 | ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; 14 | 15 | -- AlterTable 16 | ALTER TABLE "Page" ADD COLUMN "pageCreatedAt" TIMESTAMP(3) NOT NULL, 17 | ALTER COLUMN "createdAt" DROP NOT NULL, 18 | ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; 19 | 20 | -- AlterTable 21 | ALTER TABLE "User" DROP COLUMN "avatarURL", 22 | ADD COLUMN "avatarUrl" TEXT NOT NULL, 23 | ADD COLUMN "updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP; 24 | -------------------------------------------------------------------------------- /prisma/migrations/20211208070545_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `createdAt` on table `Database` required. This step will fail if there are existing NULL values in that column. 5 | - Made the column `createdAt` on table `Page` required. This step will fail if there are existing NULL values in that column. 6 | - Made the column `updatedAt` on table `User` required. This step will fail if there are existing NULL values in that column. 7 | 8 | */ 9 | -- AlterTable 10 | ALTER TABLE "Database" ALTER COLUMN "createdAt" SET NOT NULL; 11 | 12 | -- AlterTable 13 | ALTER TABLE "Page" ALTER COLUMN "createdAt" SET NOT NULL; 14 | 15 | -- AlterTable 16 | ALTER TABLE "User" ALTER COLUMN "updatedAt" SET NOT NULL; 17 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Database { 14 | id String @unique 15 | name String 16 | dbCreatedAt DateTime 17 | lastEditedAt DateTime 18 | url String 19 | pages Page[] 20 | size Int @default(0) 21 | createdAt DateTime @default(now()) 22 | } 23 | 24 | model Page { 25 | id String @unique 26 | name String 27 | pageCreatedAt DateTime 28 | url String 29 | properties Json? 30 | Database Database @relation(fields: [databaseId], references: [id]) 31 | databaseId String 32 | CreatedBy User @relation(fields: [userId], references: [id]) 33 | userId String 34 | createdAt DateTime @default(now()) 35 | } 36 | 37 | model User { 38 | id String @unique 39 | name String 40 | avatarUrl String 41 | email String? 42 | Page Page[] 43 | updatedAt DateTime @default(now()) 44 | } 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # notion-database-crawler 2 | 3 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/75asa/notion-database-crawler/tree/main) 4 | 5 | # What is this ? 6 | 7 | This is Notion Database crawler. 8 | if found new page, notify slack channel ! 9 | 10 | # How to use ? 11 | 12 | 1. create new app from manifest file; 13 | 1. paste this `manifest.yml` and install workspace 14 | 1. add slack app your channel 15 | 1. go to [Developers・Beta: Getting start](https://developers.notion.com/docs/getting-started) 16 | 1. create integration 17 | 1. invite integration to a database page you wanna watch 18 | 1. click Heroku deploy button at tha top 19 | 1. enter required config values 20 | 1. go to api.slack.com FYI: https://api.slack.com/apps 21 | 22 | 23 | 24 | # Note 25 | - You can choose multiple Slack channels to notify a new post. if you wanna, set `SLACK_CHANNEL_NAMES` like `[general, notifications]` 26 | - If you wanna display some Notion page properties, you can set `NOTION_VISIBLE_PROPS` like `[title,description,created_time,updated_time]` 27 | 28 | [![Image from Gyazo](https://i.gyazo.com/fddf34585969655be8827347c3e796a8.gif)](https://gyazo.com/fddf34585969655be8827347c3e796a8) 29 | 30 | # How to use Docker 31 | 32 | ## commands 33 | 34 | - `$ docker compose up -d` 35 | - `$ docker compose down -v` 36 | - `$ docker compose ps` 37 | 38 | # How to backup on Postgres 39 | 40 | - `$ heroku pg:backups:capture --remote heroku-prd` 41 | - `$ curl -o latest.dump (shell heroku pg:backups public-url --remote heroku-prd)` 42 | - `$ docker exec -i postgres-notion-database-crawler pg_restore --verbose --clean -U notion --no-acl --no-owner -d notion < latest.dump` 43 | 44 | 45 | # ⚠️ Caution 46 | 47 | Notion API is still [beta] 48 | Currently using [v0.4.9](https://github.com/makenotion/notion-sdk-js/releases/tag/v0.4.9) 49 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | readonly NODE_ENV: "development" | "production" | "test"; 6 | readonly PUBLIC_URL: string; 7 | readonly SLACK_BOT_TOKEN: string; 8 | readonly SLACK_CHANNEL_NAME: string; 9 | readonly NOTION_KEY: string; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/@types/notion-api-types.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ListBlockChildrenResponse, 3 | SearchResponse, 4 | QueryDatabaseResponse, 5 | } from "@notionhq/client/build/src/api-endpoints"; 6 | 7 | /** Database */ 8 | export type SearchResult = SearchResponse["results"][number]; 9 | 10 | /** Property **/ 11 | export type PostResult = QueryDatabaseResponse["results"][number]; 12 | export type PropertyValueMap = PostResult["properties"]; 13 | export type PropertyValue = PropertyValueMap[string]; 14 | 15 | export type PropertyValueType = PropertyValue["type"]; 16 | 17 | export type ExtractedPropertyValue = Extract< 18 | PropertyValue, 19 | { type: TType } 20 | >; 21 | 22 | export type PropertyValueTitle = ExtractedPropertyValue<"title">; 23 | export type PropertyValueCheckbox = ExtractedPropertyValue<"checkbox">; 24 | export type PropertyValueRichText = ExtractedPropertyValue<"rich_text">; 25 | export type PropertyValueNumber = ExtractedPropertyValue<"number">; 26 | export type PropertyValueUrl = ExtractedPropertyValue<"url">; 27 | export type PropertyValueSelect = ExtractedPropertyValue<"select">; 28 | export type PropertyValueMultiSelect = ExtractedPropertyValue<"multi_select">; 29 | export type PropertyValuePeople = ExtractedPropertyValue<"people">; 30 | export type PropertyValueEmail = ExtractedPropertyValue<"email">; 31 | export type PropertyValuePhoneNumber = ExtractedPropertyValue<"phone_number">; 32 | export type PropertyValueDate = ExtractedPropertyValue<"date">; 33 | export type PropertyValueFiles = ExtractedPropertyValue<"files">; 34 | export type PropertyValueFormula = ExtractedPropertyValue<"formula">; 35 | export type PropertyValueRelation = ExtractedPropertyValue<"relation">; 36 | export type PropertyValueRollup = ExtractedPropertyValue<"rollup">; 37 | export type PropertyValueCreatedTime = ExtractedPropertyValue<"created_time">; 38 | export type PropertyValueCreatedBy = ExtractedPropertyValue<"created_by">; 39 | export type PropertyValueEditedTime = 40 | ExtractedPropertyValue<"last_edited_time">; 41 | export type PropertyValueEditedBy = ExtractedPropertyValue<"last_edited_by">; 42 | 43 | /** People **/ 44 | export type PropertyValueUser = Extract< 45 | PropertyValuePeople["people"][number], 46 | { type: string } 47 | >; 48 | export type PropertyValueUserType = PropertyValueUser["type"]; 49 | 50 | export type PropertyValueUserPerson = Extract< 51 | PropertyValueUser, 52 | { type: "person" } 53 | >; 54 | export type PropertyValueUserBot = Extract; 55 | 56 | export type PropertyValueUserPersonOrBot = 57 | | PropertyValueUserPerson 58 | | PropertyValueUserBot; 59 | export type PeopleValue = PropertyValuePeople["people"]; 60 | 61 | /** Block **/ 62 | export type Block = ListBlockChildrenResponse["results"][number]; 63 | 64 | export type BlockType = Block["type"]; 65 | 66 | export type ExtractedBlockType = Extract< 67 | Block, 68 | { type: TType } 69 | >; 70 | 71 | export type Blocks = Block[]; 72 | 73 | export type ParagraphBlock = ExtractedBlockType<"paragraph">; 74 | 75 | export type HeadingOneBlock = ExtractedBlockType<"heading_1">; 76 | export type HeadingTwoBlock = ExtractedBlockType<"heading_2">; 77 | export type HeadingThreeBlock = ExtractedBlockType<"heading_3">; 78 | 79 | export type HeadingBlock = 80 | | HeadingOneBlock 81 | | HeadingTwoBlock 82 | | HeadingThreeBlock; 83 | 84 | export type BulletedListItemBlock = ExtractedBlockType<"bulleted_list_item">; 85 | export type NumberedListItemBlock = ExtractedBlockType<"numbered_list_item">; 86 | 87 | export type QuoteBlock = ExtractedBlockType<"quote">; 88 | export type EquationBlock = ExtractedBlockType<"equation">; 89 | export type CodeBlock = ExtractedBlockType<"code">; 90 | export type CalloutBlock = ExtractedBlockType<"callout">; 91 | export type ToDoBlock = ExtractedBlockType<"to_do">; 92 | export type BookmarkBlock = ExtractedBlockType<"bookmark">; 93 | export type ToggleBlock = ExtractedBlockType<"toggle">; 94 | 95 | export type ChildPageBlock = ExtractedBlockType<"child_page">; 96 | export type ChildDatabaseBlock = ExtractedBlockType<"child_database">; 97 | 98 | export type EmbedBlock = ExtractedBlockType<"embed">; 99 | export type ImageBlock = ExtractedBlockType<"image">; 100 | export type VideoBlock = ExtractedBlockType<"video">; 101 | export type PDFBlock = ExtractedBlockType<"pdf">; 102 | export type FileBlock = ExtractedBlockType<"file">; 103 | export type AudioBlock = ExtractedBlockType<"audio">; 104 | 105 | export type TocBlock = ExtractedBlockType<"table_of_contents">; 106 | export type DividerBlock = ExtractedBlockType<"divider">; 107 | 108 | export type UnsupportedBlock = ExtractedBlockType<"unsupported">; 109 | 110 | /** RichText **/ 111 | export type RichText = ParagraphBlock["paragraph"]["text"][number]; 112 | 113 | export type Annotations = RichText["annotations"]; 114 | 115 | export type RichTextType = RichText["type"]; 116 | 117 | export type ExtractedRichText = Extract< 118 | RichText, 119 | { type: TType } 120 | >; 121 | 122 | export type RichTextText = ExtractedRichText<"text">; 123 | 124 | export type RichTextMention = ExtractedRichText<"mention">; 125 | export type RichTextEquation = ExtractedRichText<"equation">; 126 | 127 | /** File **/ 128 | export type File = ImageBlock["image"]; 129 | 130 | export type FileType = File["type"]; 131 | 132 | export type ExtractedFile = Extract< 133 | File, 134 | { type: TType } 135 | >; 136 | 137 | export type ExternalFileWithCaption = Omit< 138 | ExtractedFile<"external">, 139 | "caption" 140 | > & { caption?: ExtractedFile<"external">["caption"] }; 141 | export type FileWithCaption = Omit, "caption"> & { 142 | caption?: ExtractedFile<"file">["caption"]; 143 | }; 144 | 145 | /** Callout */ 146 | export type CalloutIcon = CalloutBlock["callout"]["icon"]; 147 | 148 | // FIXME: "type" is not a valid property name 149 | // export type CalloutIconType = CalloutIcon["type"]; 150 | 151 | // export type ExtractedCalloutIcon = Extract< 152 | // CalloutIcon, 153 | // { type: TType } 154 | // >; 155 | 156 | // export type CalloutIconEmoji = ExtractedCalloutIcon<"emoji">; 157 | // export type CalloutIconExternal = ExtractedCalloutIcon<"external">; 158 | // export type CalloutIconFile = ExtractedCalloutIcon<"file">; 159 | -------------------------------------------------------------------------------- /src/Config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | 3 | const config = dotenv.config().parsed; 4 | 5 | if (config) { 6 | for (const key in config) { 7 | process.env[key] = config[key]; 8 | } 9 | } 10 | 11 | export namespace Config { 12 | export namespace Slack { 13 | export const BOT_TOKEN = process.env.SLACK_BOT_TOKEN; 14 | export const CHANNEL_NAMES = process.env.SLACK_CHANNEL_NAMES 15 | ? process.env.SLACK_CHANNEL_NAMES.split(",") 16 | : []; 17 | } 18 | export namespace Notion { 19 | export const KEY = process.env.NOTION_KEY; 20 | export const IGNORE_KEYWORDS = [/^Copy of.*/, /.*のコピー$/, /.+\s[1-9]$/]; 21 | export namespace Props { 22 | export const NAME = process.env.NOTION_NAME_PROP || "Name"; 23 | export const CREATED_AT = 24 | process.env.NOTION_CREATED_AT_PROP || "CreatedAt"; 25 | export const CREATED_BY = 26 | process.env.NOTION_CREATED_BY_PROP || "CreatedBy"; 27 | export const IS_PUBLISHED = 28 | process.env.NOTION_IS_PUBLISHED_PROP || "IsPublished"; 29 | } 30 | export const VISIBLE_PROPS = process.env.NOTION_VISIBLE_PROPS 31 | ? process.env.NOTION_VISIBLE_PROPS.split(",") 32 | : []; 33 | } 34 | export namespace Sentry { 35 | export const DSN = process.env.SENTRY_DSN; 36 | } 37 | export const JOB_INTERVAL_SECONDS = 38 | Number(process.env.JOB_INTERVAL_SECONDS) || 60; 39 | } 40 | -------------------------------------------------------------------------------- /src/Scheduler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncTask, 3 | SimpleIntervalJob, 4 | SimpleIntervalSchedule, 5 | ToadScheduler, 6 | } from "toad-scheduler"; 7 | 8 | export class Scheduler { 9 | private scheduler: ToadScheduler; 10 | private task: AsyncTask; 11 | constructor(func: () => Promise) { 12 | this.scheduler = new ToadScheduler(); 13 | this.task = new AsyncTask( 14 | "run main", 15 | () => func(), 16 | (err: Error) => { 17 | throw err; 18 | } 19 | ); 20 | } 21 | 22 | setInterval(simpleIntervalSchedule: SimpleIntervalSchedule) { 23 | const job = new SimpleIntervalJob(simpleIntervalSchedule, this.task); 24 | this.scheduler.addSimpleIntervalJob(job); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Slack.ts: -------------------------------------------------------------------------------- 1 | import { ChatPostMessageArguments, WebClient } from "@slack/web-api"; 2 | import JSXSlack from "jsx-slack"; 3 | import { Page, Database, User } from "~/model/entity"; 4 | import { MainBlocks } from "~/model/valueObject/slack/MainBlocks"; 5 | 6 | interface SlackConstructorArgs { 7 | BOT_TOKEN?: string; 8 | CHANNEL_NAMES: string[]; 9 | } 10 | 11 | export class Slack { 12 | #client; 13 | #channels: string[]; 14 | constructor(args: SlackConstructorArgs) { 15 | const { CHANNEL_NAMES, BOT_TOKEN } = args; 16 | if (!CHANNEL_NAMES.length) throw new Error("no channel name"); 17 | if (!BOT_TOKEN) throw new Error("no bot token"); 18 | this.#channels = CHANNEL_NAMES; 19 | this.#client = new WebClient(BOT_TOKEN); 20 | } 21 | 22 | #buildMessage(input: { page: Page; database: Database }) { 23 | const { database, page } = input; 24 | const { url, name } = page; 25 | return `${database.name} に新しいページ: <${url}|${name}> が投稿されました`; 26 | } 27 | 28 | async postMessage(input: { page: Page; database: Database; user: User }) { 29 | const { database, page, user } = input; 30 | const text = this.#buildMessage({ page, database }); 31 | // Block kit 32 | const block = MainBlocks({ database, page }); 33 | // console.dir({ block }, { depth: null }); 34 | const translatedBlocks = JSXSlack(block); 35 | 36 | const msgOptions: ChatPostMessageArguments[] = this.#channels.map( 37 | (channel) => { 38 | return { 39 | channel, 40 | text, 41 | username: user.name, 42 | icon_url: user.avatarURL, 43 | // icon_url: user.avatarURL, TODO: user.resizedURL ?? user.avatarURL のようにしたい 44 | unfurl_links: true, 45 | blocks: translatedBlocks, 46 | }; 47 | } 48 | ); 49 | 50 | console.dir({ msgOptions }, { depth: null }); 51 | 52 | try { 53 | await Promise.all( 54 | msgOptions.map(async (option) => this.#client.chat.postMessage(option)) 55 | ); 56 | } catch (e) { 57 | if (e instanceof Error) throw e; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/errors/NotionError.ts: -------------------------------------------------------------------------------- 1 | export class NotionError extends Error { 2 | constructor(private code: string, message: string) { 3 | super(message); 4 | this.name = new.target.name; 5 | } 6 | is502Error() { 7 | return ( 8 | this.code === "notionhq_client_response_error" && 9 | this.message === "Request to Notion API failed with status: 502" 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/errors/SentryError.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | 3 | import { Config } from "~/Config"; 4 | 5 | const { DSN } = Config.Sentry; 6 | 7 | Sentry.init({ 8 | dsn: DSN, 9 | // Set tracesSampleRate to 1.0 to capture 100% 10 | // of transactions for performance monitoring. 11 | // We recommend adjusting this value in production 12 | tracesSampleRate: 1.0, 13 | }); 14 | 15 | type SentryData = any; 16 | 17 | export class SentryError extends Error { 18 | #TYPE = "error"; 19 | #CATEGORY = "data"; 20 | #DENY_LIST = [/email/gi, /name/gi, /address/gi]; 21 | constructor(message: string, data: SentryData) { 22 | super(message); 23 | 24 | if (Error.captureStackTrace) { 25 | Error.captureStackTrace(this, SentryError); 26 | } 27 | 28 | this.name = "SentryError"; 29 | 30 | Sentry.addBreadcrumb({ 31 | category: this.#CATEGORY, 32 | message, 33 | data: this.#redactSensitiveInformation(data), 34 | type: this.#TYPE, 35 | level: Sentry.Severity.Debug, 36 | }); 37 | 38 | Sentry.captureException(message); 39 | } 40 | 41 | #redactSensitiveInformation(data: SentryData) { 42 | // if (!(data instanceof Object)) return {}; 43 | // if (!(data instanceof Object) || !(data instanceof Array)) return {}; 44 | // if (typeof data !== "object") return {}; 45 | const keys = Object.keys(data); 46 | const safeData: { [index: string]: any } = {}; 47 | 48 | for (const key of keys) { 49 | if (!Array.isArray(data[key]) && typeof data[key] === "object") { 50 | // recursively check deep nested children 51 | safeData[key] = this.#redactSensitiveInformation(data[key]); 52 | } else if (this.#DENY_LIST.some((regex) => regex.test(key))) { 53 | // redacted the data 54 | safeData[key] = "[REDACTED]"; 55 | } else { 56 | // assign data to object to send to Sentry 57 | safeData[key] = data[key]; 58 | } 59 | } 60 | return safeData; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/errors/NotionError"; 2 | export * from "~/errors/SentryError"; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { Config } from "~/Config"; 3 | import { 4 | NotionRepository, 5 | PrismaDatabaseRepository, 6 | PrismaPageRepository, 7 | } from "~/repository"; 8 | import { Scheduler } from "~/Scheduler"; 9 | import { Slack } from "~/Slack"; 10 | 11 | const prisma = new PrismaClient(); 12 | 13 | const main = async () => { 14 | console.log("main: start"); 15 | await prisma.$connect(); 16 | const notionRepo = new NotionRepository(Config.Notion.KEY); 17 | // integration が取得可能な prisma database 18 | const allDatabases = await notionRepo.getAllDatabase(); 19 | // database に紐づいてる Page (from Notion) 20 | await Promise.all( 21 | allDatabases.map(async (database) => { 22 | const databaseRepo = new PrismaDatabaseRepository(prisma); 23 | const storedDatabase = await databaseRepo.find(database.id); 24 | const isFirstTime = storedDatabase == null; 25 | const allContents = await notionRepo.getAllContentsFromDatabase( 26 | database.id 27 | ); 28 | 29 | database.size = allContents.length; 30 | // Database に Page [] があり、 DB に保存してない場合 31 | if (isFirstTime) { 32 | // 初期登録 33 | await databaseRepo.create(database); 34 | if (!database.size) return; 35 | const pageRepo = new PrismaPageRepository(prisma); 36 | for (const { page, user } of allContents) { 37 | await pageRepo.create(page, user); 38 | } 39 | return; 40 | } 41 | if (storedDatabase == null) return; 42 | // database に紐づいたページを取得 43 | const storedPages = storedDatabase.pages; 44 | 45 | // 2回目以降なので差分を比較 46 | const unstoredPages = allContents.filter((content) => { 47 | const hadStored = storedPages.some((storedPage) => { 48 | return storedPage.id === content.page.id; 49 | }); 50 | // DBに一つでも同じIDがあれば保存済みなので false を返す 51 | return hadStored ? false : true; 52 | }); 53 | // 差分がない場合は Database のみ更新 54 | if (!unstoredPages.length) { 55 | await databaseRepo.update(database); 56 | return; 57 | } 58 | 59 | database.size += unstoredPages.length; 60 | const pageRepo = new PrismaPageRepository(prisma); 61 | 62 | for (const { user, page } of unstoredPages) { 63 | await pageRepo.create(page, user); 64 | // TODO: impl all blocks on a page 65 | // const notionBlocks = await notionRepo.getAllBlocksFromPage(page.id); 66 | const slackClient = new Slack(Config.Slack); 67 | // slackClient.setBlocks(blocks); 68 | // Slack 通知 69 | await slackClient.postMessage({ 70 | database, 71 | page, 72 | user, 73 | }); 74 | } 75 | // Database 更新 76 | await databaseRepo.update(database); 77 | return; 78 | }) 79 | ); 80 | console.log("main: end"); 81 | }; 82 | 83 | const job = new Scheduler(main); 84 | job.setInterval({ seconds: Config.JOB_INTERVAL_SECONDS }); 85 | -------------------------------------------------------------------------------- /src/model/entity/Database.ts: -------------------------------------------------------------------------------- 1 | import { Database as DatabaseProps } from ".prisma/client"; 2 | import { SearchResult } from "~/@types/notion-api-types"; 3 | import { Entity } from "~/model/entity/Entity"; 4 | import { getName, parseDate } from "~/utils"; 5 | 6 | export class Database extends Entity { 7 | static create(props: SearchResult): Database { 8 | if (props.object !== "database") throw new Error("Invalid object type"); 9 | const { id, title, created_time, last_edited_time, url, icon, cover } = 10 | props; 11 | const name = getName(title); 12 | return new Database({ 13 | id, 14 | name, 15 | dbCreatedAt: parseDate(created_time), 16 | lastEditedAt: parseDate(last_edited_time), 17 | url, 18 | size: 0, 19 | createdAt: new Date(), 20 | }); 21 | } 22 | 23 | get id() { 24 | return this._id; 25 | } 26 | 27 | get name() { 28 | return this.props.name; 29 | } 30 | 31 | get size() { 32 | return this.props.size; 33 | } 34 | 35 | set size(size: number) { 36 | this.props.size = size; 37 | } 38 | 39 | get url() { 40 | return this.props.url; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/model/entity/Entity.ts: -------------------------------------------------------------------------------- 1 | const isEntity = (v: unknown): v is Entity => { 2 | return v instanceof Entity; 3 | }; 4 | 5 | interface IDIncludeObject { 6 | id: string; 7 | } 8 | 9 | export abstract class Entity { 10 | protected readonly _id: string; 11 | protected readonly props: T; 12 | 13 | constructor(props: T) { 14 | this._id = props.id; 15 | this.props = props; 16 | } 17 | 18 | public equals(object?: Entity): boolean { 19 | if (object == null || object == undefined) return false; 20 | 21 | if (this === object) return false; 22 | 23 | if (!isEntity(object)) return false; 24 | 25 | return this._id === object._id; 26 | } 27 | 28 | public allProps(): T { 29 | return { ...this.props }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/model/entity/Page.ts: -------------------------------------------------------------------------------- 1 | import { Page as PageProps, Prisma } from ".prisma/client"; 2 | import { PostResult } from "~/@types/notion-api-types"; 3 | import { Config } from "~/Config"; 4 | import { Entity } from "~/model/entity/Entity"; 5 | import { 6 | DatabaseId, 7 | UserId, 8 | Properties, 9 | TitleProperty, 10 | CheckboxProperty, 11 | } from "~/model/valueObject"; 12 | import { parseDate } from "~/utils"; 13 | 14 | const { Props, IGNORE_KEYWORDS } = Config.Notion; 15 | const { NAME, CREATED_BY, IS_PUBLISHED } = Props; 16 | 17 | interface CustomPageProps extends PageProps { 18 | properties: Prisma.JsonObject; 19 | } 20 | export class Page extends Entity { 21 | static create(props: PostResult): Page { 22 | const { properties, id, created_time, url } = props; 23 | const name = TitleProperty.create(properties[NAME]).value; 24 | const ignoreTitle = IGNORE_KEYWORDS.some((keyword) => name.match(keyword)); 25 | const isPublished = CheckboxProperty.create(properties[IS_PUBLISHED]).value; 26 | const value = { 27 | id, 28 | name: ignoreTitle || !isPublished ? "" : name, 29 | pageCreatedAt: parseDate(created_time), 30 | url, 31 | properties: Properties.create(properties).props, 32 | databaseId: DatabaseId.create(props).value, 33 | userId: UserId.create(properties[CREATED_BY]).value, 34 | createdAt: new Date(), 35 | }; 36 | return new Page(value); 37 | } 38 | 39 | get id() { 40 | return this._id; 41 | } 42 | 43 | get name() { 44 | return this.props.name; 45 | } 46 | 47 | get url() { 48 | return this.props.url; 49 | } 50 | 51 | get properties() { 52 | return this.props.properties; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/model/entity/User.ts: -------------------------------------------------------------------------------- 1 | import { User as UserProps } from ".prisma/client"; 2 | import { 3 | PropertyValue, 4 | PropertyValueCreatedBy, 5 | } from "~/@types/notion-api-types"; 6 | import { Entity } from "~/model/entity/Entity"; 7 | import { isDetectiveType } from "~/utils"; 8 | 9 | export class User extends Entity { 10 | static create(props: PropertyValue): User { 11 | if (!isDetectiveType(props)) { 12 | throw new Error( 13 | `User.create: props must be a LastEditedByPropertyValue \n${JSON.stringify( 14 | props 15 | )}` 16 | ); 17 | } 18 | const notionUser = props.created_by; 19 | if (!("type" in notionUser)) { 20 | console.warn( 21 | `User.create: props.created_by must have a type\n Actual: ${JSON.stringify( 22 | props 23 | )}` 24 | ); 25 | return new User({ 26 | id: notionUser.id, 27 | name: "", 28 | avatarUrl: "", 29 | email: "", 30 | updatedAt: new Date(), 31 | }); 32 | } 33 | const { id, name, avatar_url, type } = notionUser; 34 | let email = null; 35 | if (type === "person") { 36 | const { person } = notionUser; 37 | if (person) email = person.email; 38 | } 39 | return new User({ 40 | id, 41 | name: name || "", 42 | avatarUrl: avatar_url || "", 43 | email, 44 | updatedAt: new Date(), 45 | }); 46 | } 47 | 48 | get id(): string { 49 | return this._id; 50 | } 51 | 52 | get name(): string { 53 | return this.props.name; 54 | } 55 | 56 | get avatarURL(): string { 57 | return this.props.avatarUrl; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/model/entity/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/model/entity/Page"; 2 | export * from "~/model/entity/User"; 3 | export * from "~/model/entity/Database"; 4 | -------------------------------------------------------------------------------- /src/model/valueObject/DatabaseId.ts: -------------------------------------------------------------------------------- 1 | import { PostResult } from "~/@types/notion-api-types"; 2 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 3 | 4 | export class DatabaseId extends PrimitiveValueObject { 5 | static create(props: PostResult): DatabaseId { 6 | const { parent } = props; 7 | if (parent.type !== "database_id") { 8 | throw new Error("DatabaseId.create: parent must be a database_id"); 9 | } 10 | return new DatabaseId(parent.database_id); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/model/valueObject/NameProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue, PropertyValueTitle } from "~/@types/notion-api-types"; 2 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 3 | import { getName } from "~/utils"; 4 | 5 | const isTitlePropertyValue = ( 6 | propValue: PropertyValue 7 | ): propValue is PropertyValueTitle => { 8 | // TODO: propValue.title === RichText[] も入れたい 9 | return (propValue as PropertyValueTitle).type === "title"; 10 | }; 11 | 12 | export class NameProperty extends PrimitiveValueObject { 13 | static create(propValue: PropertyValue): NameProperty { 14 | if (!isTitlePropertyValue(propValue)) { 15 | throw new Error( 16 | `Invalid NameProperty propValue: ${console.dir(propValue)}` 17 | ); 18 | } 19 | return new NameProperty(getName(propValue.title) || "Untitled"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/model/valueObject/PrimitiveValueObject.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "~/model/valueObject/ValueObject"; 2 | 3 | export abstract class PrimitiveValueObject extends ValueObject { 4 | get value(): T { 5 | return this.props; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/model/valueObject/UserId.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue } from "~/@types/notion-api-types"; 2 | import { User } from "~/model/entity"; 3 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 4 | 5 | export class UserId extends PrimitiveValueObject { 6 | static create(props: PropertyValue) { 7 | const user = User.create(props); 8 | return new UserId(user.id); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/model/valueObject/ValueObject.ts: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from "shallow-equal-object"; 2 | 3 | interface ValueObjectProps { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | [index: string]: any; 6 | } 7 | 8 | /** 9 | * @desc ValueObjects are objects that we determine their 10 | * equality through their structrual property. 11 | */ 12 | 13 | export abstract class ValueObject { 14 | public readonly props: T; 15 | 16 | constructor(props: T) { 17 | this.props = Object.freeze(props); 18 | } 19 | 20 | public equals(vo?: ValueObject): boolean { 21 | if (vo === null || vo === undefined) { 22 | return false; 23 | } 24 | if (vo.props === undefined) { 25 | return false; 26 | } 27 | return shallowEqual(this.props, vo.props); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/model/valueObject/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/model/valueObject/DatabaseId"; 2 | export * from "~/model/valueObject/notion"; 3 | export * from "~/model/valueObject/UserId"; 4 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/blocks/Annotations.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RichTextText, 3 | Annotations as AnnotationsType, 4 | } from "~/@types/notion-api-types"; 5 | 6 | interface AnnotationsProps { 7 | children: RichTextText; 8 | } 9 | export const Annotations = ({ children }: AnnotationsProps) => { 10 | const content = children.text.content; 11 | const { annotations, text } = children; 12 | let blocks = text.link ? ( 13 | {content} 14 | ) : ( 15 |

{content}

16 | ); 17 | 18 | for (const annotation in annotations) { 19 | if (!annotations[annotation as keyof AnnotationsType]) continue; 20 | switch (annotation) { 21 | case "bold": 22 | blocks = {blocks}; 23 | break; 24 | case "italic": 25 | blocks = {blocks}; 26 | break; 27 | case "strikethrough": 28 | blocks = {blocks}; 29 | break; 30 | case "code": 31 | blocks = {blocks}; 32 | break; 33 | case "default": 34 | // underline, color 35 | break; 36 | } 37 | } 38 | 39 | return blocks; 40 | }; 41 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/blocks/BulletedListItem.tsx: -------------------------------------------------------------------------------- 1 | import JSXSlack, { Section } from "jsx-slack"; 2 | import { BulletedListItemBlock } from "~/@types/notion-api-types"; 3 | import { RichTextText } from "~/model/valueObject/notion/blocks/RichText"; 4 | 5 | interface BulletedListItemProps { 6 | children: BulletedListItemBlock; 7 | } 8 | 9 | export const BulletedListItem = (props: BulletedListItemProps) => { 10 | const bulletedListItems = props.children.bulleted_list_item.text 11 | .map((text) => { 12 | switch (text.type) { 13 | case "text": 14 | text; 15 | return {text}; 16 | case "equation": 17 | text; 18 | break; 19 | case "mention": 20 | text; 21 | break; 22 | } 23 | }) 24 | .filter( 25 | (item): item is Exclude => item !== undefined 26 | ); 27 | 28 | return ( 29 |
30 |
    31 | {/* {JSXSlack.Children.toArray(bulletedListItems).map((item) => ( 32 |
  • {item}
  • 33 | ))} */} 34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/blocks/RichText.tsx: -------------------------------------------------------------------------------- 1 | import { RichTextText as RichTextTextType } from "~/@types/notion-api-types"; 2 | import { Annotations } from "~/model/valueObject/notion/blocks/Annotations"; 3 | 4 | interface RichTextTextProps { 5 | children: RichTextTextType; 6 | } 7 | 8 | export const RichTextText = ({ children }: RichTextTextProps) => { 9 | return {children}; 10 | }; 11 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/Properties.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { PropertyValueMap } from "~/@types/notion-api-types"; 3 | import { ValueObject } from "~/model/valueObject/ValueObject"; 4 | import { Config } from "~/Config"; 5 | 6 | const { VISIBLE_PROPS } = Config.Notion; 7 | 8 | const isPrismaJsonObject = (input: unknown): input is Prisma.JsonObject => { 9 | return typeof input === "object" && input !== null && !Array.isArray(input); 10 | }; 11 | 12 | export class Properties extends ValueObject { 13 | static create(propValues: PropertyValueMap): Properties { 14 | if (!isPrismaJsonObject(propValues)) throw new Error("Invalid propValues"); 15 | const selectedJson: Prisma.JsonObject = {}; 16 | for (const VISIBLE_PROP of VISIBLE_PROPS) { 17 | const value = propValues[VISIBLE_PROP]; 18 | if (!value) continue; 19 | selectedJson[VISIBLE_PROP] = value; 20 | } 21 | return new Properties(selectedJson); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/VisibleProperties.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from ".prisma/client"; 2 | import { Config } from "~/Config"; 3 | import { 4 | VisiblePropsTypes, 5 | MultiSelectProperty, 6 | DateProperty, 7 | SelectProperty, 8 | } from "~/model/valueObject"; 9 | import { ValueObject } from "~/model/valueObject/ValueObject"; 10 | import { isKeyValueObject, isPropertyValue } from "~/utils"; 11 | 12 | const { VISIBLE_PROPS } = Config.Notion; 13 | 14 | const parse = (propValues: Prisma.JsonValue) => { 15 | if ( 16 | !propValues || 17 | typeof propValues !== "object" || 18 | Array.isArray(propValues) 19 | ) { 20 | throw new Error("propValues must be an object"); 21 | } 22 | const result = []; 23 | for (const key in Object.keys(propValues)) { 24 | if (!key) continue; 25 | const value = propValues[key]; 26 | if (!isKeyValueObject(value)) continue; 27 | value as { [key: string]: unknown }; 28 | result.push({ key, value }); 29 | } 30 | return result; 31 | }; 32 | 33 | export class VisibleProperties extends ValueObject { 34 | static create(propValues: Prisma.JsonObject): VisibleProperties { 35 | const props: VisiblePropsTypes[] = []; 36 | const parsedProps = parse(propValues); 37 | for (const { key, value } of parsedProps) { 38 | if (!VISIBLE_PROPS.includes(key)) continue; 39 | if (!isPropertyValue(value)) continue; 40 | switch (value.type) { 41 | case "multi_select": { 42 | props.push(MultiSelectProperty.create({ key, value })); 43 | break; 44 | } 45 | case "date": { 46 | props.push(DateProperty.create({ key, value })); 47 | break; 48 | } 49 | case "select": { 50 | props.push(SelectProperty.create({ key, value })); 51 | break; 52 | } 53 | default: 54 | break; 55 | } 56 | } 57 | return new VisibleProperties(props); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/BaseProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue } from "~/@types/notion-api-types"; 2 | 3 | export interface BasePropertyFactoryArgs { 4 | key: string; 5 | value: PropertyValue; 6 | } 7 | 8 | export interface BasePropertyProps { 9 | key: string; 10 | value: T; 11 | } 12 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/CheckboxProperty.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PropertyValue, 3 | PropertyValueCheckbox, 4 | } from "~/@types/notion-api-types"; 5 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 6 | import { isDetectiveType } from "~/utils"; 7 | 8 | export class CheckboxProperty extends PrimitiveValueObject { 9 | static create(propValue: PropertyValue): CheckboxProperty { 10 | if (!isDetectiveType(propValue)) { 11 | throw new Error( 12 | `Invalid CheckboxProperty propValue: ${console.dir(propValue)}` 13 | ); 14 | } 15 | return new CheckboxProperty(propValue.checkbox); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/DateProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValueDate } from "~/@types/notion-api-types"; 2 | import { 3 | BasePropertyProps, 4 | BasePropertyFactoryArgs, 5 | } from "~/model/valueObject/notion/databaseProperties/default/BaseProperty"; 6 | import { ValueObject } from "~/model/valueObject/ValueObject"; 7 | import { isDetectiveType, parseDate } from "~/utils"; 8 | 9 | interface DatePropertyProps extends BasePropertyProps { 10 | date: Date; 11 | } 12 | 13 | export class DateProperty extends ValueObject { 14 | static create({ key, value }: BasePropertyFactoryArgs): DateProperty { 15 | if (!isDetectiveType(value)) { 16 | throw new Error( 17 | `Invalid DateProperty propValue: ${JSON.stringify(value)}` 18 | ); 19 | } 20 | const term = value.date; 21 | if (!term) throw new Error("Invalid SelectProperty propValue"); 22 | return new DateProperty({ 23 | key, 24 | value, 25 | date: parseDate(term.start), 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/MultiSelectProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValueMultiSelect } from "~/@types/notion-api-types"; 2 | import { 3 | BasePropertyProps, 4 | BasePropertyFactoryArgs, 5 | } from "~/model/valueObject/notion/databaseProperties/default/BaseProperty"; 6 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 7 | import { isDetectiveType } from "~/utils"; 8 | 9 | interface MultiSelectPropertyProps 10 | extends BasePropertyProps { 11 | optionNames: string[]; 12 | } 13 | 14 | export class MultiSelectProperty extends PrimitiveValueObject { 15 | static create({ key, value }: BasePropertyFactoryArgs): MultiSelectProperty { 16 | if (!isDetectiveType(value)) { 17 | throw new Error( 18 | `Invalid URLProperty propValue: ${JSON.stringify(value)}` 19 | ); 20 | } 21 | const optionNames = value.multi_select 22 | .map((item) => { 23 | if (item.name) return item.name as string; 24 | }) 25 | .filter( 26 | (item): item is Exclude => item !== undefined 27 | ); 28 | return new MultiSelectProperty({ 29 | key, 30 | value: value, 31 | optionNames, 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/NumberProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue, PropertyValueNumber } from "~/@types/notion-api-types"; 2 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 3 | import { isDetectiveType } from "~/utils"; 4 | export class NumberProperty extends PrimitiveValueObject { 5 | static create(propValue: PropertyValue): NumberProperty { 6 | if (!isDetectiveType(propValue)) { 7 | throw new Error( 8 | `Invalid NumberPropertyValue: ${JSON.stringify(propValue)}` 9 | ); 10 | } 11 | const { number } = propValue; 12 | return new NumberProperty(number ?? 0); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/PeopleProperty.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PropertyValue, 3 | PropertyValuePeople, 4 | PropertyValueUserPersonOrBot, 5 | } from "~/@types/notion-api-types"; 6 | import { UserBlock } from "~/model/valueObject"; 7 | import { ValueObject } from "~/model/valueObject/ValueObject"; 8 | import { isDetectiveType, extractUserOrBotFromPeoples } from "~/utils"; 9 | 10 | export class PeopleProperty extends ValueObject { 11 | static create(propValue: PropertyValue): PeopleProperty { 12 | if (!isDetectiveType(propValue)) { 13 | throw new Error( 14 | `Invalid PeoplePropertyValue: ${JSON.stringify(propValue)}` 15 | ); 16 | } 17 | const peoples = extractUserOrBotFromPeoples(propValue.people); 18 | 19 | const peopleList = peoples.map((people: PropertyValueUserPersonOrBot) => { 20 | return UserBlock.create(people); 21 | }); 22 | return new PeopleProperty(peopleList); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/SelectProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValueSelect } from "~/@types/notion-api-types"; 2 | import { 3 | BasePropertyProps, 4 | BasePropertyFactoryArgs, 5 | } from "~/model/valueObject/notion/databaseProperties/default/BaseProperty"; 6 | import { ValueObject } from "~/model/valueObject/ValueObject"; 7 | import { isDetectiveType } from "~/utils"; 8 | 9 | interface SelectPropertyProps extends BasePropertyProps { 10 | option: string | undefined; 11 | } 12 | 13 | export class SelectProperty extends ValueObject { 14 | static create({ key, value }: BasePropertyFactoryArgs): SelectProperty { 15 | if (!isDetectiveType(value)) { 16 | throw new Error( 17 | `Invalid SelectProperty propValue: ${JSON.stringify(value)}` 18 | ); 19 | } 20 | const optionName = value.select?.name; 21 | return new SelectProperty({ key, value: value, option: optionName }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/TextProperty.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PropertyValue, 3 | PropertyValueRichText, 4 | } from "~/@types/notion-api-types"; 5 | import { UserBlock } from "~/model/valueObject/notion/databaseProperties/subset"; 6 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 7 | import { isDetectiveType, extractUserOrBotFromPeoples } from "~/utils"; 8 | 9 | export class TextProperty extends PrimitiveValueObject { 10 | static create(propValue: PropertyValue): TextProperty { 11 | if (!isDetectiveType(propValue)) { 12 | throw new Error( 13 | `Invalid TextPropertyValue: ${JSON.stringify(propValue)}` 14 | ); 15 | } 16 | 17 | const richText = propValue.rich_text; 18 | const reducedText = richText.reduce((acc, cur) => { 19 | const { plain_text, annotations, href, type } = cur; 20 | switch (type) { 21 | case "equation": { 22 | acc += plain_text; 23 | return acc; 24 | } 25 | case "mention": { 26 | const { type } = cur.mention; 27 | switch (type) { 28 | case "database": { 29 | cur.mention.database.id; 30 | break; 31 | } 32 | case "user": { 33 | if (cur.mention.type === "user") { 34 | const peoples = extractUserOrBotFromPeoples([cur.mention.user]); 35 | const user = UserBlock.create(peoples[0]); 36 | } 37 | break; 38 | } 39 | case "date": { 40 | break; 41 | } 42 | case "page": { 43 | break; 44 | } 45 | } 46 | 47 | acc += `@${plain_text}`; 48 | return acc; 49 | } 50 | case "text": { 51 | const { plain_text, annotations } = cur; 52 | acc += plain_text; 53 | return acc; 54 | } 55 | } 56 | }, ""); 57 | if (!reducedText) throw new Error("Invalid TextPropertyValue"); 58 | return new TextProperty(reducedText); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/TitleProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue, PropertyValueTitle } from "~/@types/notion-api-types"; 2 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 3 | import { isDetectiveType, getName } from "~/utils"; 4 | 5 | export class TitleProperty extends PrimitiveValueObject { 6 | static create(propValue: PropertyValue): TitleProperty { 7 | if (!isDetectiveType(propValue)) { 8 | throw new Error( 9 | `Invalid NameProperty propValue: ${console.dir(propValue)}` 10 | ); 11 | } 12 | return new TitleProperty(getName(propValue.title) || "Untitled"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/URLProperty.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValue, PropertyValueUrl } from "~/@types/notion-api-types"; 2 | import { PrimitiveValueObject } from "~/model/valueObject/PrimitiveValueObject"; 3 | import { isDetectiveType } from "~/utils"; 4 | 5 | export class URLProperty extends PrimitiveValueObject { 6 | static create(propValue: PropertyValue): URLProperty { 7 | if (!isDetectiveType(propValue)) { 8 | throw new Error( 9 | `Invalid URLProperty propValue: ${JSON.stringify(propValue)}` 10 | ); 11 | } 12 | const { url } = propValue; 13 | if (!url) throw new Error("URLProperty propValue is missing url"); 14 | return new URLProperty(url); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/default/index.ts: -------------------------------------------------------------------------------- 1 | import { DateProperty } from "~/model/valueObject/notion/databaseProperties/default/DateProperty"; 2 | import { MultiSelectProperty } from "~/model/valueObject/notion/databaseProperties/default/MultiSelectProperty"; 3 | import { NumberProperty } from "~/model/valueObject/notion/databaseProperties/default/NumberProperty"; 4 | import { PeopleProperty } from "~/model/valueObject/notion/databaseProperties/default/PeopleProperty"; 5 | import { SelectProperty } from "~/model/valueObject/notion/databaseProperties/default/SelectProperty"; 6 | import { TextProperty } from "~/model/valueObject/notion/databaseProperties/default/TextProperty"; 7 | import { TitleProperty } from "~/model/valueObject/notion/databaseProperties/default/TitleProperty"; 8 | import { URLProperty } from "~/model/valueObject/notion/databaseProperties/default/URLProperty"; 9 | import { CheckboxProperty } from "~/model/valueObject/notion/databaseProperties/default/CheckboxProperty"; 10 | 11 | export type VisiblePropsTypes = 12 | | DateProperty 13 | | MultiSelectProperty 14 | | TitleProperty 15 | | NumberProperty 16 | | PeopleProperty 17 | | SelectProperty 18 | | TextProperty 19 | | CheckboxProperty 20 | | URLProperty; 21 | 22 | export * from "~/model/valueObject/notion/databaseProperties/default/DateProperty"; 23 | export * from "~/model/valueObject/notion/databaseProperties/default/MultiSelectProperty"; 24 | export * from "~/model/valueObject/notion/databaseProperties/default/NumberProperty"; 25 | export * from "~/model/valueObject/notion/databaseProperties/default/PeopleProperty"; 26 | export * from "~/model/valueObject/notion/databaseProperties/default/SelectProperty"; 27 | export * from "~/model/valueObject/notion/databaseProperties/default/TextProperty"; 28 | export * from "~/model/valueObject/notion/databaseProperties/default/TitleProperty"; 29 | export * from "~/model/valueObject/notion/databaseProperties/default/URLProperty"; 30 | export * from "~/model/valueObject/notion/databaseProperties/default/CheckboxProperty"; 31 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/model/valueObject/notion/databaseProperties/default"; 2 | export * from "~/model/valueObject/notion/databaseProperties/Properties"; 3 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/subset/UserBlock.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValueUser } from "~/@types/notion-api-types"; 2 | import { ValueObject } from "~/model/valueObject/ValueObject"; 3 | 4 | export interface UserBlockProps { 5 | name: string; 6 | icon: string; 7 | } 8 | 9 | export class UserBlock extends ValueObject { 10 | private constructor(props: UserBlockProps) { 11 | super(props); 12 | } 13 | static create(propValue: PropertyValueUser): UserBlock { 14 | const { name, avatar_url } = propValue; 15 | if (!name) { 16 | throw new Error("UserBlock: name is missing"); 17 | } 18 | return new UserBlock({ 19 | name, 20 | icon: avatar_url || "", 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/databaseProperties/subset/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/model/valueObject/notion/databaseProperties/subset/UserBlock"; 2 | -------------------------------------------------------------------------------- /src/model/valueObject/notion/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/model/valueObject/notion/databaseProperties"; 2 | export * from "~/model/valueObject/notion/databaseProperties/subset"; 3 | export * from "~/model/valueObject/notion/databaseProperties/Properties"; 4 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/BlockKit.tsx: -------------------------------------------------------------------------------- 1 | // import JSXSlack from "jsx-slack"; 2 | // import { Page } from "~/model/entity"; 3 | // import { VisibleProperties } from "~/model/valueObject"; 4 | // import { ValueObject } from "~/model/valueObject/ValueObject"; 5 | 6 | // interface BlockKitProps { 7 | // element: JSXSlack.JSX.Element; 8 | // } 9 | 10 | // interface BlockKitCreateArgs { 11 | // page: Page; 12 | // } 13 | 14 | // export class BlockKit extends ValueObject { 15 | // static create({ page }: BlockKitCreateArgs): BlockKit { 16 | // // TODO: rawProperties 17 | // const { properties } = page; 18 | // const visibleProperties = VisibleProperties.create(properties).props; 19 | // new BlockKit({ element: properties }); 20 | // } 21 | 22 | // get element(): JSXSlack.JSX.Element { 23 | // return this.props.element; 24 | // } 25 | // } 26 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Section, Mrkdwn } from "jsx-slack"; 2 | import { Database, Page } from "~/model/entity"; 3 | 4 | interface HeaderProps { 5 | database: Database; 6 | page: Page; 7 | } 8 | 9 | export const HeaderBlock = ({ database, page }: HeaderProps) => { 10 | const { name, url } = page; 11 | return ( 12 | <> 13 |
14 | 15 |

16 | {database.name} に新しいページ: {name}{" "} 17 | が投稿されました 18 |

19 |
20 |
21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/MainBlocks.tsx: -------------------------------------------------------------------------------- 1 | import { Blocks, Mrkdwn, Section, Divider } from "jsx-slack"; 2 | import { Database, Page } from "~/model/entity"; 3 | import { HeaderBlock } from "~/model/valueObject/slack/Header"; 4 | import { Properties } from "~/model/valueObject/slack/notion/Properties"; 5 | 6 | interface MainBlocksProps { 7 | database: Database; 8 | page: Page; 9 | } 10 | 11 | export const MainBlocks = ({ database, page }: MainBlocksProps) => { 12 | return ( 13 | 14 | 15 | 16 | {/* NOTE: 2つのリンクを載せると unfurler が機能しない */} 17 | {/*
18 | 19 | posted at {database.name} 20 | 21 |
*/} 22 | 23 | {/* TODO: content block */} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/NotionContentBlock.tsx: -------------------------------------------------------------------------------- 1 | import JSXSlack, { Blocks, Divider, Header } from "jsx-slack"; 2 | import { Block } from "~/@types/notion-api-types"; 3 | import { Page, User } from "~/model/entity"; 4 | import { BulletedListItem } from "~/model/valueObject/notion/blocks/BulletedListItem"; 5 | import { ValueObject } from "~/model/valueObject/ValueObject"; 6 | 7 | interface ContentBlockProps { 8 | blocks: Block[]; 9 | elements: JSXSlack.JSX.Element[]; 10 | } 11 | 12 | export class NotionContentBlock extends ValueObject { 13 | static create(blocks: Block[]) { 14 | const jsxElements: JSXSlack.JSX.Element[] = []; 15 | for (const block of blocks) { 16 | switch (block.type) { 17 | case "bulleted_list_item": 18 | jsxElements.push({block}); 19 | break; 20 | case "child_page": 21 | break; 22 | case "heading_1": 23 | break; 24 | case "heading_2": 25 | break; 26 | case "heading_3": 27 | break; 28 | case "numbered_list_item": 29 | break; 30 | case "paragraph": 31 | break; 32 | case "to_do": 33 | break; 34 | case "toggle": 35 | break; 36 | case "unsupported": 37 | break; 38 | } 39 | } 40 | return new NotionContentBlock({ blocks, elements: jsxElements }); 41 | } 42 | 43 | get elements(): JSXSlack.JSX.Element[] { 44 | return this.props.elements; 45 | } 46 | 47 | makeBlock(arg: { page: Page; databaseName: string; user: User }) { 48 | return ( 49 | 50 |
51 | {arg.databaseName} に新しいページ:{" "} 52 | {arg.page.name} 53 | が投稿されました 54 |
55 | 56 | {...this.elements} 57 |
58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/notion/Properties.tsx: -------------------------------------------------------------------------------- 1 | import { Prisma } from ".prisma/client"; 2 | import { isPropertyValue, parsePrismaJsonObject } from "~/utils"; 3 | import { Config } from "~/Config"; 4 | import JSXSlack, { Field, Section } from "jsx-slack"; 5 | import { MultiSelectProperty } from "~/model/valueObject/slack/notion/properties/MultiSelectProperty"; 6 | import { DateProperty } from "~/model/valueObject/slack/notion/properties/DateProperty"; 7 | import { SelectProperty } from "~/model/valueObject/slack/notion/properties/SelectProperty"; 8 | 9 | const { VISIBLE_PROPS } = Config.Notion; 10 | 11 | interface PropertiesProps { 12 | properties: Prisma.JsonObject; 13 | } 14 | 15 | export const Properties = ({ properties }: PropertiesProps) => { 16 | const parsed = parsePrismaJsonObject(properties); 17 | const element: JSXSlack.JSX.Element[] = []; 18 | 19 | for (const VISIBLE_PROP of VISIBLE_PROPS) { 20 | const parsedProp = parsed.find((p) => p.key === VISIBLE_PROP); 21 | if (!parsedProp) continue; 22 | const { key, value } = parsedProp; 23 | if (!isPropertyValue(value)) continue; 24 | switch (value.type) { 25 | case "multi_select": { 26 | element.push(); 27 | break; 28 | } 29 | case "date": { 30 | element.push(); 31 | break; 32 | } 33 | case "select": { 34 | element.push(); 35 | break; 36 | } 37 | default: 38 | break; 39 | } 40 | } 41 | 42 | if (!element.length) return <>; 43 | 44 | return ( 45 | <> 46 |
47 |

48 | Properties 49 |

50 | {element.map((item) => ( 51 | {item} 52 | ))} 53 |
54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/notion/properties/DateProperty.tsx: -------------------------------------------------------------------------------- 1 | import { PropertyValueDate } from "~/@types/notion-api-types"; 2 | 3 | interface DatePropertyProps { 4 | key: string; 5 | property: PropertyValueDate; 6 | } 7 | 8 | export const DateProperty = ({ key, property }: DatePropertyProps) => { 9 | return ( 10 | <> 11 |

12 | {key}: {property.date?.start ?? ""} 13 |

14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/notion/properties/MultiSelectProperty.tsx: -------------------------------------------------------------------------------- 1 | import { PropertyValueMultiSelect } from "~/@types/notion-api-types"; 2 | 3 | interface MultiSelectPropertyProps { 4 | key: string; 5 | property: PropertyValueMultiSelect; 6 | } 7 | 8 | export const MultiSelectProperty = ({ 9 | key, 10 | property, 11 | }: MultiSelectPropertyProps) => { 12 | return ( 13 | <> 14 |

15 | {key}: {property.multi_select.map((v) => v.name).join(", ")} 16 |

17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/model/valueObject/slack/notion/properties/SelectProperty.tsx: -------------------------------------------------------------------------------- 1 | import { PropertyValueSelect } from "~/@types/notion-api-types"; 2 | 3 | interface SelectPropertyProps { 4 | key: string; 5 | property: PropertyValueSelect; 6 | } 7 | 8 | export const SelectProperty = ({ key, property }: SelectPropertyProps) => { 9 | return ( 10 | <> 11 |

12 | {key}: {property.select?.name ?? ""} 13 |

14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/repository/NotionRepository.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@notionhq/client/build/src"; 2 | import { 3 | ListBlockChildrenParameters, 4 | QueryDatabaseResponse, 5 | } from "@notionhq/client/build/src/api-endpoints"; 6 | import { RequestParameters } from "@notionhq/client/build/src/Client"; 7 | import { PostResult, Block } from "~/@types/notion-api-types"; 8 | import { Config } from "~/Config"; 9 | import { NotionError } from "~/errors"; 10 | import { Database, Page, User } from "~/model/entity"; 11 | 12 | const { Props } = Config.Notion; 13 | 14 | const MUST_EXIST_PROPS = Object.keys(Props).map( 15 | (key) => Props[key as keyof typeof Props] as keyof typeof Props 16 | ); 17 | 18 | export class NotionRepository { 19 | #client; 20 | #pagesAndUsers: { page: Page; user: User }[] = []; 21 | constructor(authKey: string) { 22 | this.#client = new Client({ auth: authKey }); 23 | this.#pagesAndUsers = []; 24 | } 25 | 26 | // integration が取得可能な database を取得 27 | async getAllDatabase() { 28 | let searched; 29 | try { 30 | searched = await this.#client.search({ 31 | filter: { value: "database", property: "object" }, 32 | }); 33 | } catch (e) { 34 | if (e instanceof Error) throw e; 35 | if (!searched) throw new Error("searched is null"); 36 | } 37 | 38 | return searched.results 39 | .filter( 40 | (data): data is Exclude => 41 | data.object === "database" 42 | ) 43 | .filter((database) => { 44 | return MUST_EXIST_PROPS.every((MUST_EXIST_PROP) => { 45 | return Object.keys(database.properties).includes(MUST_EXIST_PROP); 46 | }); 47 | }) 48 | .map((database) => { 49 | return Database.create(database); 50 | }); 51 | } 52 | 53 | async #getPages(databaseId: string, cursor?: string) { 54 | const requestPayload: RequestParameters = { 55 | path: `databases/${databaseId}/query`, 56 | method: "post", 57 | body: { 58 | filter: { 59 | and: [ 60 | { 61 | property: Props.IS_PUBLISHED, 62 | checkbox: { 63 | equals: true, 64 | }, 65 | }, 66 | ], 67 | }, 68 | }, 69 | }; 70 | 71 | if (cursor) requestPayload.body = { start_cursor: cursor }; 72 | let pages = null; 73 | try { 74 | pages = (await this.#client.request( 75 | requestPayload 76 | )) as QueryDatabaseResponse; 77 | } catch (error) { 78 | console.dir({ error }, { depth: null }); 79 | if (error instanceof NotionError) { 80 | if (error.is502Error()) return; 81 | } 82 | if (error instanceof Error) throw error; 83 | if (!pages) throw new Error(`pages is null: ${error}`); 84 | } 85 | 86 | await Promise.all( 87 | pages.results.map(async (rawPage) => { 88 | if (rawPage.archived) return; 89 | const page = Page.create(rawPage); 90 | const user = User.create(rawPage.properties[Props.CREATED_BY]); 91 | if (!page.name || !user.name) return; 92 | this.#pagesAndUsers.push({ page, user }); 93 | }) 94 | ); 95 | 96 | if (!pages.has_more) return; 97 | 98 | await this.#getPages(databaseId, pages.next_cursor ?? undefined); 99 | } 100 | 101 | async getAllContentsFromDatabase(databaseId: string) { 102 | await this.#getPages(databaseId); 103 | return this.#pagesAndUsers; 104 | } 105 | 106 | async getAllBlocksFromPage(pageId: string) { 107 | const allBlocks: Block[] = []; 108 | 109 | const getBlocks = async (cursor?: string) => { 110 | let blocks = null; 111 | const blocksChildrenListParameters: ListBlockChildrenParameters = { 112 | block_id: pageId, 113 | }; 114 | if (cursor) blocksChildrenListParameters.start_cursor = cursor; 115 | try { 116 | blocks = await this.#client.blocks.children.list( 117 | blocksChildrenListParameters 118 | ); 119 | } catch (e) { 120 | if (e instanceof Error) throw e; 121 | if (!blocks) throw new Error("blocks is null"); 122 | } 123 | if (!blocks.results.length) return; 124 | allBlocks.push(...blocks.results); 125 | if (blocks.has_more) { 126 | await getBlocks(blocks.next_cursor ?? undefined); 127 | } 128 | }; 129 | await getBlocks(); 130 | 131 | return allBlocks; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/repository/PrismaDatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrismaClient, 3 | Database as PrismaDatabase, 4 | Page as PrismaPage, 5 | User as PrismaUser, 6 | } from "@prisma/client"; 7 | import { Database } from "~/model/entity/Database"; 8 | 9 | interface IDatabaseRepository { 10 | find(databaseId: string): Promise< 11 | | (PrismaDatabase & { 12 | pages: (PrismaPage & { 13 | CreatedBy: PrismaUser; 14 | })[]; 15 | }) 16 | | null 17 | | undefined 18 | >; 19 | create(database: Database): Promise; 20 | update(database: Database): Promise; 21 | } 22 | 23 | export class PrismaDatabaseRepository implements IDatabaseRepository { 24 | constructor(private prisma: PrismaClient) {} 25 | 26 | async find(databaseId: string) { 27 | try { 28 | return await this.prisma.database.findUnique({ 29 | where: { 30 | id: databaseId, 31 | }, 32 | include: { 33 | pages: { 34 | include: { 35 | CreatedBy: true, 36 | }, 37 | }, 38 | }, 39 | }); 40 | } catch (e) { 41 | if (e instanceof Error) throw e; 42 | } 43 | } 44 | 45 | async create(database: Database) { 46 | try { 47 | await this.prisma.database.create({ 48 | data: { ...database.allProps() }, 49 | }); 50 | } catch (e) { 51 | if (e instanceof Error) throw e; 52 | } 53 | } 54 | 55 | async update(database: Database) { 56 | try { 57 | await this.prisma.database.update({ 58 | where: { id: database.id }, 59 | data: database.allProps(), 60 | }); 61 | } catch (e) { 62 | if (e instanceof Error) throw e; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/repository/PrismaPageRepository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from ".prisma/client"; 2 | import { Page, User } from "~/model/entity"; 3 | 4 | interface IPageRepository { 5 | create(page: Page, user: User): Promise; 6 | } 7 | 8 | export class PrismaPageRepository implements IPageRepository { 9 | constructor(private prisma: PrismaClient) {} 10 | 11 | async create(page: Page, user: User) { 12 | const { userId, databaseId, ...refinedPage } = page.allProps(); 13 | try { 14 | await this.prisma.page.create({ 15 | data: { 16 | ...refinedPage, 17 | Database: { 18 | connect: { 19 | id: databaseId, 20 | }, 21 | }, 22 | CreatedBy: { 23 | connectOrCreate: { 24 | where: { 25 | id: userId, 26 | }, 27 | create: user.allProps(), 28 | }, 29 | }, 30 | }, 31 | include: { 32 | Database: true, 33 | CreatedBy: true, 34 | }, 35 | }); 36 | } catch (e) { 37 | console.error({ page, user }); 38 | if (e instanceof Error) throw e; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/repository/NotionRepository" 2 | export * from "~/repository/PrismaDatabaseRepository" 3 | export * from "~/repository/PrismaPageRepository" 4 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const chunk = (targetArray: T, size: number): T[] => { 2 | return targetArray.reduce((accArray, _, index) => { 3 | return index % size 4 | ? accArray 5 | : [...accArray, targetArray.slice(index, index + size)]; 6 | }, [] as T[][]); 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/utils/parser"; 2 | export * from "~/utils/notion"; 3 | export * from "~/utils/prisma"; 4 | export * from "~/utils/array"; 5 | // export * from "./trim"; 6 | -------------------------------------------------------------------------------- /src/utils/notion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RichText, 3 | PropertyValue, 4 | PeopleValue, 5 | PropertyValueUserPerson, 6 | PropertyValueUserBot, 7 | } from "~/@types/notion-api-types"; 8 | 9 | export const getName = (titleList: RichText[]) => { 10 | return titleList.reduce((acc, cur) => { 11 | if (!("plain_text" in cur)) return acc; 12 | return (acc += (acc.length ? " " : "") + cur.plain_text); 13 | }, ""); 14 | }; 15 | 16 | export const isDetectiveType = ( 17 | propValue: PropertyValue 18 | ): propValue is T => { 19 | const propertyType = (propValue as T).type; 20 | return (propValue as T).type === propertyType; 21 | }; 22 | 23 | export const extractUserOrBotFromPeoples = (peopleValues: PeopleValue) => { 24 | return peopleValues 25 | .map((people) => { 26 | if ("type" in people) { 27 | return people as PropertyValueUserPerson | PropertyValueUserBot; 28 | } 29 | }) 30 | .filter( 31 | (item): item is Exclude => item !== undefined 32 | ); 33 | }; 34 | 35 | export const isPropertyValue = (input: unknown): input is PropertyValue => { 36 | return input instanceof Object && "type" in input && "id" in input; 37 | }; 38 | 39 | export const isKeyValueObject = ( 40 | input: unknown 41 | ): input is { [key: string]: unknown } => { 42 | return input instanceof Object && Object.keys(input).length === 1; 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/parser.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | export const parseISO8601 = (date: Date) => { 4 | return dayjs(date).format(); 5 | }; 6 | 7 | export const parseDate = (isoString: string) => { 8 | return dayjs(isoString).toDate(); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | export const isPrismaJsonObject = ( 4 | input: unknown 5 | ): input is Prisma.JsonObject => { 6 | return typeof input === "object" && input !== null && !Array.isArray(input); 7 | }; 8 | 9 | export const parsePrismaJsonObject = (propValues: Prisma.JsonValue) => { 10 | if ( 11 | !propValues || 12 | typeof propValues !== "object" || 13 | Array.isArray(propValues) 14 | ) { 15 | throw new Error("propValues must be an object"); 16 | } 17 | const result = []; 18 | for (const key in propValues) { 19 | if (!key) continue; 20 | const value = propValues[key]; 21 | if (!value) continue; 22 | if (!isPrismaJsonObject(value)) continue; 23 | result.push({ key, value }); 24 | } 25 | return result; 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils/trim.ts: -------------------------------------------------------------------------------- 1 | // export const trimUndefined = ( 2 | // item: unknown 3 | // ): item is Exclude => item !== typeof T; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "jsx-slack", 7 | "moduleResolution": "node", 8 | "lib": ["es2018", "dom"], 9 | "esModuleInterop": true, 10 | // "rootDir": "./src", 11 | "outDir": "./dist", 12 | "sourceMap": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "strict": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "baseUrl": "./", 19 | "typeRoots": ["notion-api-types"], 20 | "paths": { 21 | "~/*": ["src/*"] 22 | }, 23 | "types": ["@types/node"] 24 | }, 25 | "exclude": ["node_modules", "dist", "**/*.spec.ts"], 26 | "include": ["src/**/*"] 27 | } 28 | --------------------------------------------------------------------------------