├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── launch.json ├── LICENSE.txt ├── README.md ├── astro.config.mjs ├── components.json ├── diagram.png ├── drizzle.config.ts ├── drizzle ├── 0000_opposite_pyro.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── functions ├── _middleware.ts ├── api │ ├── databases │ │ ├── index.ts │ │ └── validate │ │ │ └── [dbid].ts │ ├── google-tasks.ts │ ├── has-token.ts │ ├── notion-tasks.ts │ ├── sync.ts │ ├── tasklists.ts │ └── user.ts ├── env.d.ts ├── google-auth │ ├── callback.ts │ └── index.ts ├── notion-auth │ ├── callback.ts │ └── index.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── robots.txt └── site.webmanifest ├── schema.sql ├── src ├── components │ ├── AuthErrors.tsx │ ├── Button.tsx │ ├── ConnectGoogle.tsx │ ├── ConnectNotion.tsx │ ├── ConnectSuccess.tsx │ ├── Error.tsx │ ├── Hero │ │ ├── Hero.astro │ │ ├── ModalConfirm.tsx │ │ └── hero.png │ ├── InitialSync.tsx │ ├── Main.tsx │ ├── Modal.tsx │ ├── Warning.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ └── toast │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.tsx ├── constants.ts ├── env.d.ts ├── functions-helpers │ ├── auth-data.ts │ ├── db-api.ts │ ├── google-api.ts │ ├── notion-api.ts │ └── server-error.ts ├── global.css ├── helpers │ ├── api.ts │ ├── decodeJWTTokens.ts │ └── parseRequestCookies.ts ├── hooks │ ├── useActualNotionDbId.ts │ └── useIsGoogleSetupComplete.ts ├── images │ └── logo.png ├── layouts │ └── Layout.astro ├── lib │ └── utils.ts ├── pages │ ├── index.astro │ ├── privacy-policy.astro │ └── terms-of-use.astro ├── schema.ts └── src │ └── env.d.ts ├── tailwind.config.mjs ├── tsconfig.json └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | output.json 4 | 5 | # wrangler project 6 | _worker.bundle 7 | .dev.vars 8 | .wrangler/ 9 | 10 | functions/* 11 | !functions/google-auth/ 12 | !functions/google-auth/* 13 | !functions/notion-auth/ 14 | !functions/notion-auth/* 15 | !functions/api/ 16 | !functions/api/* 17 | !functions/env.d.ts 18 | !functions/tsconfig.json 19 | !functions/_middleware.ts 20 | 21 | # generated types 22 | .astro/ 23 | 24 | # dependencies 25 | node_modules/ 26 | 27 | # logs 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | pnpm-debug.log* 32 | 33 | # environment variables 34 | .env 35 | .env.production 36 | 37 | # macOS-specific files 38 | .DS_Store 39 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true, 6 | "plugins": [ 7 | "prettier-plugin-astro", 8 | "prettier-plugin-tailwindcss", 9 | "prettier-plugin-sql" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": "constants.ts", 14 | "options": { 15 | "printWidth": 120 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexey Antipov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion-Google Tasks Sync Website 2 | 3 | ## Overview 4 | 5 | Notion-Google Tasks Sync is a seamless service allowing users to synchronize their Notion and Google Tasks effortlessly. This repository contains the code for the [front-end website](https://notion-google-tasks-sync.com), which facilitates user authorization and initial synchronization setup. Built with the Astro framework and hosted on Cloudflare Pages, the website is the starting point for users to integrate their Notion and Google Tasks. 6 | 7 | [aantipov/notion-google-tasks-worker](https://github.com/aantipov/notion-google-tasks-worker) is a companion repository for the cron job to sync tasks regularly in the background. 8 | 9 | ![Website + Worker diagram](./diagram.png) 10 | 11 | ## Features 12 | 13 | - **App Authorization**: Users grant permission to the app to access their Notion and Google Tasks on their behalf. This is essential for enabling the synchronization between the two services. 14 | - **Initial Synchronization**: Step-by-step process guiding users through the initial sync of tasks. 15 | - **Secure & Private**: Utilizes Cloudflare D1 for secure data storage, ensuring user data safety. 16 | - **Minimalistic Design**: A user-friendly interface for an effortless setup experience. 17 | 18 | ## Getting Started 19 | 20 | ### Prerequisites 21 | 22 | - Node.js 23 | - Cloudflare account 24 | 25 | ### Installation 26 | 27 | 1. Clone the repository: 28 | 29 | ```bash 30 | git clone https://github.com/aantipov/notion-google-tasks-website.git 31 | ``` 32 | 33 | 2. Install dependencies: 34 | 35 | ```bash 36 | pnpm install 37 | ``` 38 | 39 | 3. Configure environment variables for Cloudflare and API access. 40 | 41 | ### Running Locally 42 | 43 | Run the following command to start the development server: 44 | 45 | ```bash 46 | pnpm run dev 47 | ``` 48 | 49 | ## Deployment 50 | 51 | This project is deployed on Cloudflare Pages. Follow Cloudflare's documentation for deploying Astro projects to set up continuous deployment. 52 | 53 | ## License 54 | 55 | This project is licensed under the MIT License - see the LICENSE file for details. 56 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, passthroughImageService } from 'astro/config'; 2 | import tailwind from '@astrojs/tailwind'; 3 | import react from '@astrojs/react'; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | site: 'https://notion-google-tasks-sync.com', 8 | output: 'static', 9 | image: { 10 | service: passthroughImageService(), // quality of images is bad with the default service 11 | }, 12 | // adapter: cloudflare({ 13 | // mode: 'directory', 14 | // routes: { 15 | // strategy: 'auto', 16 | // include: ['/google-auth/*', '/notion-auth/*', '/api/*'], 17 | // }, 18 | // runtime: { 19 | // mode: 'local', 20 | // type: 'pages', 21 | // bindings: { DB: { type: 'd1' } }, 22 | // }, 23 | // }), 24 | integrations: [ 25 | tailwind({ 26 | applyBaseStyles: false, // is needed because of the shadcn/ui 27 | }), 28 | react(), 29 | ], 30 | vite: { 31 | build: { 32 | minify: false, 33 | sourcemap: true, 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.mjs", 8 | "css": "./src/global.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/diagram.png -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit'; 2 | export default defineConfig({ 3 | schema: './src/schema.ts', 4 | out: './drizzle', 5 | driver: 'd1', 6 | verbose: true, 7 | strict: true, 8 | }); 9 | -------------------------------------------------------------------------------- /drizzle/0000_opposite_pyro.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `sync_stats` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `email` text NOT NULL, 4 | `created` integer NOT NULL, 5 | `updated` integer NOT NULL, 6 | `deleted` integer NOT NULL, 7 | `total` integer NOT NULL, 8 | `system` text NOT NULL, 9 | `created_at` integer NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | CREATE TABLE `users` ( 13 | `email` text PRIMARY KEY NOT NULL, 14 | `g_token` text NOT NULL, 15 | `n_token` text, 16 | `tasklist_id` text, 17 | `database_id` text, 18 | `mapping` text, 19 | `last_synced` integer, 20 | `setup_completion_prompt_sent` integer, 21 | `setup_completion_prompt_sent_date` integer, 22 | `sync_error` text, 23 | `created` integer NOT NULL, 24 | `modified` integer NOT NULL 25 | ); 26 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "d4ec84e4-3aad-4ea4-a9ff-96297ebaa9c5", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "sync_stats": { 8 | "name": "sync_stats", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "created": { 25 | "name": "created", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "updated": { 32 | "name": "updated", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "deleted": { 39 | "name": "deleted", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "total": { 46 | "name": "total", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | }, 52 | "system": { 53 | "name": "system", 54 | "type": "text", 55 | "primaryKey": false, 56 | "notNull": true, 57 | "autoincrement": false 58 | }, 59 | "created_at": { 60 | "name": "created_at", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": true, 64 | "autoincrement": false 65 | } 66 | }, 67 | "indexes": {}, 68 | "foreignKeys": {}, 69 | "compositePrimaryKeys": {}, 70 | "uniqueConstraints": {} 71 | }, 72 | "users": { 73 | "name": "users", 74 | "columns": { 75 | "email": { 76 | "name": "email", 77 | "type": "text", 78 | "primaryKey": true, 79 | "notNull": true, 80 | "autoincrement": false 81 | }, 82 | "g_token": { 83 | "name": "g_token", 84 | "type": "text", 85 | "primaryKey": false, 86 | "notNull": true, 87 | "autoincrement": false 88 | }, 89 | "n_token": { 90 | "name": "n_token", 91 | "type": "text", 92 | "primaryKey": false, 93 | "notNull": false, 94 | "autoincrement": false 95 | }, 96 | "tasklist_id": { 97 | "name": "tasklist_id", 98 | "type": "text", 99 | "primaryKey": false, 100 | "notNull": false, 101 | "autoincrement": false 102 | }, 103 | "database_id": { 104 | "name": "database_id", 105 | "type": "text", 106 | "primaryKey": false, 107 | "notNull": false, 108 | "autoincrement": false 109 | }, 110 | "mapping": { 111 | "name": "mapping", 112 | "type": "text", 113 | "primaryKey": false, 114 | "notNull": false, 115 | "autoincrement": false 116 | }, 117 | "last_synced": { 118 | "name": "last_synced", 119 | "type": "integer", 120 | "primaryKey": false, 121 | "notNull": false, 122 | "autoincrement": false 123 | }, 124 | "setup_completion_prompt_sent": { 125 | "name": "setup_completion_prompt_sent", 126 | "type": "integer", 127 | "primaryKey": false, 128 | "notNull": false, 129 | "autoincrement": false 130 | }, 131 | "setup_completion_prompt_sent_date": { 132 | "name": "setup_completion_prompt_sent_date", 133 | "type": "integer", 134 | "primaryKey": false, 135 | "notNull": false, 136 | "autoincrement": false 137 | }, 138 | "sync_error": { 139 | "name": "sync_error", 140 | "type": "text", 141 | "primaryKey": false, 142 | "notNull": false, 143 | "autoincrement": false 144 | }, 145 | "created": { 146 | "name": "created", 147 | "type": "integer", 148 | "primaryKey": false, 149 | "notNull": true, 150 | "autoincrement": false 151 | }, 152 | "modified": { 153 | "name": "modified", 154 | "type": "integer", 155 | "primaryKey": false, 156 | "notNull": true, 157 | "autoincrement": false 158 | } 159 | }, 160 | "indexes": {}, 161 | "foreignKeys": {}, 162 | "compositePrimaryKeys": {}, 163 | "uniqueConstraints": {} 164 | } 165 | }, 166 | "enums": {}, 167 | "_meta": { 168 | "schemas": {}, 169 | "tables": {}, 170 | "columns": {} 171 | } 172 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1708527336481, 9 | "tag": "0000_opposite_pyro", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /functions/_middleware.ts: -------------------------------------------------------------------------------- 1 | import sentryPlugin from '@cloudflare/pages-plugin-sentry'; 2 | import type { PluginData } from '@cloudflare/pages-plugin-sentry'; 3 | import { parseRequestCookies } from '@/helpers/parseRequestCookies'; 4 | import { decodeJWTToken } from '@/helpers/decodeJWTTokens'; 5 | import { ServerError } from '@/functions-helpers/server-error'; 6 | import { DELETE_GTOKEN_COOKIE } from '@/constants'; 7 | // https://developers.cloudflare.com/pages/functions/plugins/sentry/ 8 | // https://github.com/cloudflare/pages-plugins/blob/main/packages/sentry/functions/_middleware.ts 9 | 10 | const authentication: PagesFunction = async ({ 11 | request, 12 | env, 13 | next, 14 | data, 15 | }) => { 16 | const authPaths = [ 17 | '/notion-auth/callback', 18 | '/api/has-token', 19 | '/api/user', 20 | '/api/tasklists', 21 | '/api/google-tasks', 22 | '/api/notion-tasks', 23 | '/api/sync', 24 | '/api/databases/validate', 25 | '/api/databases', 26 | ]; 27 | 28 | const url = new URL(request.url); 29 | 30 | if (!authPaths.some((path) => url.pathname.startsWith(path))) { 31 | return next(); 32 | } 33 | 34 | const { gJWTToken } = parseRequestCookies(request); 35 | const gToken = await decodeJWTToken(gJWTToken, env.JWT_SECRET); 36 | 37 | if (!gToken) { 38 | if (!url.pathname.startsWith('/api/has-token')) { 39 | // Don't capture the exception for /api/has-token - it's used to check if the user is authenticated 40 | data.sentry.captureException(new Error('Invalid Google token')); 41 | } 42 | return new Response('Invalid Google token', { 43 | status: 401, 44 | headers: [['Set-Cookie', DELETE_GTOKEN_COOKIE]], 45 | }); 46 | } 47 | 48 | data.sentry.setUser({ email: gToken.user.email }); 49 | // @ts-ignore 50 | data.gToken = gToken; 51 | 52 | return next(); 53 | }; 54 | 55 | export const onRequest: PagesFunction[] = [ 56 | // Capture errors re-thrown by Sentry Plugin 57 | async (context) => { 58 | if (context.env.ENVIRONMENT === 'development') { 59 | return await context.next(); 60 | } 61 | 62 | try { 63 | return await context.next(); 64 | } catch (err: any) { 65 | const errMessage = 66 | err instanceof ServerError ? err?.message : 'Server Error'; 67 | 68 | console.error(errMessage); 69 | return new Response(errMessage, { status: 500 }); 70 | } 71 | }, 72 | // Initialize a Sentry Plugin to capture any errors (it re-throws them) 73 | (context) => { 74 | return sentryPlugin({ 75 | dsn: context.env.SENTRY_DSN, 76 | enabled: context.env.ENVIRONMENT !== 'development', 77 | })(context); 78 | }, 79 | 80 | authentication, 81 | 82 | (ctx) => { 83 | const url = new URL(ctx.request.url); 84 | const path = url.pathname.startsWith('/api/databases/validate') 85 | ? '/api/databases/validate/***' 86 | : url.pathname; 87 | ctx.data.sentry.setTag('ct.request.path', path); 88 | return ctx.next(); 89 | }, 90 | ]; 91 | -------------------------------------------------------------------------------- /functions/api/databases/index.ts: -------------------------------------------------------------------------------- 1 | import * as notionApi from '@/functions-helpers/notion-api'; 2 | import { ServerError } from '@/functions-helpers/server-error'; 3 | import { drizzle } from 'drizzle-orm/d1'; 4 | import { users, type UserRawT } from '@/schema'; 5 | import { eq } from 'drizzle-orm'; 6 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 7 | 8 | /** 9 | * Get user Notion's databases list 10 | */ 11 | export const onRequestGet: PagesFunction = async ({ 12 | env, 13 | data, 14 | }) => { 15 | const userEmail = data.gToken.user.email.toLowerCase(); 16 | const db = drizzle(env.DB, { logger: true }); 17 | let userData: UserRawT; 18 | 19 | try { 20 | [userData] = await db 21 | .select() 22 | .from(users) 23 | .where(eq(users.email, userEmail)) 24 | .limit(1); 25 | } catch (error) { 26 | throw new ServerError('Failed to fetch user data', error); 27 | } 28 | 29 | if (!userData?.nToken) { 30 | return new Response('Notion is not connected', { status: 400 }); 31 | } 32 | 33 | const databases = await notionApi.fetchDatabases( 34 | userData.nToken.access_token, 35 | ); 36 | 37 | return new Response(JSON.stringify(databases), { 38 | status: 200, 39 | headers: { 'Content-Type': 'application/json' }, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /functions/api/databases/validate/[dbid].ts: -------------------------------------------------------------------------------- 1 | import * as notionApi from '@/functions-helpers/notion-api'; 2 | import { ServerError } from '@/functions-helpers/server-error'; 3 | import { drizzle } from 'drizzle-orm/d1'; 4 | import { users, type UserRawT } from '@/schema'; 5 | import { eq } from 'drizzle-orm'; 6 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 7 | 8 | /** 9 | * Validate selected Notion database' schema 10 | * This function should be invoked at /api/databases/validate/:dbid 11 | */ 12 | export const onRequestGet: PagesFunction = async ({ 13 | env, 14 | params, 15 | data, 16 | }) => { 17 | if (!params.dbid || typeof params.dbid !== 'string') { 18 | return new Response('Missing database ID', { status: 400 }); 19 | } 20 | const { dbid } = params; 21 | 22 | const userEmail = data.gToken.user.email.toLowerCase(); 23 | const db = drizzle(env.DB, { logger: true }); 24 | let userData: UserRawT; 25 | 26 | try { 27 | [userData] = await db 28 | .select() 29 | .from(users) 30 | .where(eq(users.email, userEmail)) 31 | .limit(1); 32 | } catch (error) { 33 | throw new ServerError('Failed to fetch user data', error); 34 | } 35 | 36 | if (!userData?.nToken) { 37 | return new Response('Notion is not connected', { status: 400 }); 38 | } 39 | 40 | let nDBSchema: notionApi.DBSchemaT; 41 | try { 42 | nDBSchema = await notionApi.fetchDatabaseSchema( 43 | dbid, 44 | userData.nToken.access_token, 45 | ); 46 | } catch (error) { 47 | throw new ServerError('Failed to fetch database schema', error); 48 | } 49 | 50 | return Response.json(notionApi.validateDbBSchema(nDBSchema)); 51 | }; 52 | -------------------------------------------------------------------------------- /functions/api/google-tasks.ts: -------------------------------------------------------------------------------- 1 | import * as googleApi from '@/functions-helpers/google-api'; 2 | import { ServerError } from '@/functions-helpers/server-error'; 3 | import { DELETE_GTOKEN_COOKIE } from '@/constants'; 4 | import { users, type UserRawT } from '@/schema'; 5 | import { drizzle } from 'drizzle-orm/d1'; 6 | import { eq } from 'drizzle-orm'; 7 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 8 | 9 | /** 10 | * Get user's tasklists from Google Tasks 11 | */ 12 | export const onRequestGet: PagesFunction = async ({ 13 | env, 14 | data, 15 | }) => { 16 | const userEmail = data.gToken.user.email.toLowerCase(); 17 | const db = drizzle(env.DB, { logger: true }); 18 | let userData: UserRawT; 19 | 20 | try { 21 | [userData] = await db 22 | .select() 23 | .from(users) 24 | .where(eq(users.email, userEmail)) 25 | .limit(1); 26 | 27 | if (!userData?.tasklistId) { 28 | return new Response('No tasklist selected', { status: 400 }); 29 | } 30 | 31 | const res = await googleApi.fetchOpenTasks( 32 | userData.tasklistId, 33 | data.gToken.access_token, 34 | ); 35 | 36 | return Response.json(res); 37 | } catch (error) { 38 | // @ts-ignore 39 | if (error?.code === 401) { 40 | return new Response('Invalid token', { 41 | status: 401, 42 | headers: [['Set-Cookie', DELETE_GTOKEN_COOKIE]], 43 | }); 44 | } 45 | throw new ServerError('Failed to fetch google tasks', error); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /functions/api/has-token.ts: -------------------------------------------------------------------------------- 1 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 2 | 3 | /** 4 | * If cookie has token, return 200 5 | * Otherwise, return 401 (Unauthorizeda) using _middleware 6 | */ 7 | export const onRequestGet: PagesFunction = async () => { 8 | return Response.json({ message: 'Yes, sir' }); 9 | }; 10 | -------------------------------------------------------------------------------- /functions/api/notion-tasks.ts: -------------------------------------------------------------------------------- 1 | import * as notionApi from '@/functions-helpers/notion-api'; 2 | import { ServerError } from '@/functions-helpers/server-error'; 3 | import { eq } from 'drizzle-orm'; 4 | import { drizzle } from 'drizzle-orm/d1'; 5 | import { users, type UserRawT } from '@/schema'; 6 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 7 | 8 | export interface NPropsMapT { 9 | title: { id: string; name: string; type: 'title' }; 10 | status: { id: string; name: string; type: 'status' }; 11 | due: { id: string; name: string; type: 'date' }; 12 | lastEdited: { id: string; name: string; type: 'last_edited_time' }; 13 | lastEditedBy: { id: string; name: string; type: 'last_edited_by' }; 14 | } 15 | 16 | /** 17 | * Get user notion's dabaseses list 18 | */ 19 | export const onRequestGet: PagesFunction = async ({ 20 | env, 21 | data, 22 | }) => { 23 | const userEmail = data.gToken.user.email.toLowerCase(); 24 | const db = drizzle(env.DB, { logger: true }); 25 | let userData: UserRawT; 26 | 27 | try { 28 | [userData] = await db 29 | .select() 30 | .from(users) 31 | .where(eq(users.email, userEmail)) 32 | .limit(1); 33 | if (!userData) { 34 | throw new Error('User not found'); 35 | } 36 | } catch (error) { 37 | throw new ServerError('Failed to fetch user data', error); 38 | } 39 | 40 | const { nToken, databaseId } = userData; 41 | 42 | if (!databaseId || !nToken) { 43 | return new Response('Notion is not connected or database is not selected', { 44 | status: 400, 45 | }); 46 | } 47 | const nDBSchema = await notionApi.fetchDatabaseSchema( 48 | databaseId, 49 | nToken.access_token, 50 | ); 51 | 52 | // TODO: ensure the user's selected database has all the required properties 53 | // TODO: ensure Status prop has proper values 54 | const nPropsMap = { 55 | title: Object.values(nDBSchema.properties).find((p) => p.type === 'title'), 56 | status: Object.values(nDBSchema.properties).find( 57 | (p) => p.type === 'status', 58 | ), 59 | due: Object.values(nDBSchema.properties).find((p) => p.type === 'date'), 60 | lastEdited: Object.values(nDBSchema.properties).find( 61 | (p) => p.type === 'last_edited_time', 62 | ), 63 | lastEditedBy: Object.values(nDBSchema.properties).find( 64 | (p) => p.type === 'last_edited_by', 65 | ), 66 | } as NPropsMapT; 67 | 68 | const tasks = await notionApi.fetchOpenTasks( 69 | databaseId, 70 | nPropsMap, 71 | nToken.access_token, 72 | ); 73 | 74 | return Response.json(tasks); 75 | }; 76 | -------------------------------------------------------------------------------- /functions/api/sync.ts: -------------------------------------------------------------------------------- 1 | import * as notionApi from '@/functions-helpers/notion-api'; 2 | import * as googleApi from '@/functions-helpers/google-api'; 3 | import { ServerError } from '@/functions-helpers/server-error'; 4 | import { drizzle } from 'drizzle-orm/d1'; 5 | import { users, type UserRawT, type UserT } from '@/schema'; 6 | import { eq } from 'drizzle-orm'; 7 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 8 | 9 | interface MailjetResponseT { 10 | Messages: { 11 | Status: 'success' | 'error'; 12 | Errors: any[]; 13 | To: { Email: string }[]; 14 | }[]; 15 | } 16 | 17 | interface NPropsMapT { 18 | title: { id: string; name: string; type: 'title' }; 19 | status: { id: string; name: string; type: 'status' }; 20 | due: { id: string; name: string; type: 'date' }; 21 | lastEdited: { id: string; name: string; type: 'last_edited_time' }; 22 | lastEditedBy: { id: string; name: string; type: 'last_edited_by' }; 23 | } 24 | 25 | /** 26 | * Make initial sync and store mapping between Notion and Google tasks 27 | */ 28 | export const onRequestPost: PagesFunction = async ({ 29 | env, 30 | data, 31 | waitUntil, 32 | }) => { 33 | const { gToken } = data; 34 | const email = gToken.user.email; 35 | const db = drizzle(env.DB, { logger: true }); 36 | let userData: UserRawT; 37 | try { 38 | [userData] = await db 39 | .select() 40 | .from(users) 41 | .where(eq(users.email, email)) 42 | .limit(1); 43 | } catch (error) { 44 | throw new ServerError('Failed to fetch user data', error); 45 | } 46 | 47 | const { nToken, databaseId, tasklistId } = userData; 48 | 49 | if (!databaseId || !nToken || !tasklistId) { 50 | return new Response('Notion is not connected or database is not selected', { 51 | status: 400, 52 | }); 53 | } 54 | const nDBSchema = await notionApi.fetchDatabaseSchema( 55 | databaseId, 56 | nToken.access_token, 57 | ); 58 | 59 | // TODO: ensure the user's selected database has all the required properties 60 | // TODO: ensure Status prop has proper values 61 | const nPropsMap = { 62 | title: Object.values(nDBSchema.properties).find((p) => p.type === 'title'), 63 | status: Object.values(nDBSchema.properties).find( 64 | (p) => p.type === 'status', 65 | ), 66 | due: Object.values(nDBSchema.properties).find((p) => p.type === 'date'), 67 | lastEdited: Object.values(nDBSchema.properties).find( 68 | (p) => p.type === 'last_edited_time', 69 | ), 70 | lastEditedBy: Object.values(nDBSchema.properties).find( 71 | (p) => p.type === 'last_edited_by', 72 | ), 73 | } as NPropsMapT; 74 | 75 | const { items: nTasks } = await notionApi.fetchOpenTasks( 76 | databaseId, 77 | nPropsMap, 78 | nToken.access_token, 79 | ); 80 | const { items: gTasks } = await googleApi.fetchOpenTasks( 81 | tasklistId, 82 | gToken.access_token, 83 | ); 84 | 85 | const nIdTuples = await notionApi.createAllTasks( 86 | gTasks, 87 | databaseId, 88 | nPropsMap, 89 | nToken.access_token, 90 | ); 91 | 92 | const gIdTuples = await googleApi.createAllTasks( 93 | nTasks, 94 | tasklistId, 95 | gToken.access_token, 96 | ); 97 | 98 | let updUserData: UserRawT; 99 | 100 | try { 101 | [updUserData] = await db 102 | .update(users) 103 | .set({ 104 | mapping: [...nIdTuples, ...gIdTuples], 105 | lastSynced: new Date(), 106 | modified: new Date(), 107 | }) 108 | .where(eq(users.email, email)) 109 | .returning(); 110 | 111 | waitUntil(sendCongratsEmail(email, env)); 112 | } catch (error) { 113 | throw new ServerError('Failed to update user data', error); 114 | } 115 | 116 | return new Response(JSON.stringify(getSafeUserData(updUserData)), { 117 | status: 200, 118 | headers: { 'Content-Type': 'application/json' }, 119 | }); 120 | }; 121 | 122 | function getSafeUserData(user: UserRawT): UserT { 123 | const { gToken: _, nToken: __, mapping: ___, ...safeUserData } = user; 124 | return { ...safeUserData, nConnected: !!user.nToken }; 125 | } 126 | 127 | async function sendCongratsEmail(email: string, env: CFEnvT): Promise { 128 | // Use Mailjet API to send emails 129 | // https://dev.mailjet.com/email/guides/send-api-v31/ 130 | const mailjetUrl = 'https://api.mailjet.com/v3.1/send'; 131 | const emailData = { 132 | Globals: { 133 | CustomCampaign: 'Congrats on Initial Sync', 134 | TemplateID: Number(env.MAILJET_TEMPLATE_ID), 135 | }, 136 | Messages: [{ To: [{ Email: email }] }], 137 | }; 138 | let response; 139 | try { 140 | response = await fetch(mailjetUrl, { 141 | method: 'POST', 142 | headers: { 143 | Authorization: 144 | 'Basic ' + btoa(`${env.MAILJET_API_KEY}:${env.MAILJET_SECRET_KEY}`), 145 | 'Content-Type': 'application/json', 146 | }, 147 | body: JSON.stringify(emailData), 148 | }); 149 | } catch (error) { 150 | throw new ServerError('Failed to send congrats email', error); 151 | } 152 | if (!response.ok) { 153 | console.error( 154 | `Mailjet API error: ${response.status} ${response.statusText}`, 155 | ); 156 | throw new ServerError( 157 | `Mailjet API error: ${response.status} ${response.statusText}`, 158 | ); 159 | } 160 | const responseJson = (await response.json()) as MailjetResponseT; 161 | 162 | if (responseJson.Messages.some((msg) => msg.Status !== 'success')) { 163 | console.error('Mailjet Send error', JSON.stringify(responseJson, null, 2)); 164 | throw new ServerError( 165 | `Mailjet Send error: ${responseJson.Messages[0].Errors}`, 166 | ); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /functions/api/tasklists.ts: -------------------------------------------------------------------------------- 1 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 2 | import { DELETE_GTOKEN_COOKIE } from '@/constants'; 3 | 4 | const TASKS_LISTS_URL = 5 | 'https://tasks.googleapis.com/tasks/v1/users/@me/lists?maxResults=100'; 6 | 7 | /** 8 | * Get user's tasklists from Google Tasks 9 | */ 10 | export const onRequestGet: PagesFunction = async ({ 11 | data, 12 | }) => { 13 | const resp = await fetch(TASKS_LISTS_URL, { 14 | method: 'GET', 15 | headers: { 16 | Authorization: `Bearer ${data.gToken.access_token}`, 17 | accept: 'application/json', 18 | }, 19 | }); 20 | 21 | if (!resp.ok) { 22 | if (resp.status === 401) { 23 | const newResponse = new Response(resp.body, resp); 24 | 25 | newResponse.headers.set('Set-Cookie', DELETE_GTOKEN_COOKIE); 26 | 27 | return newResponse; 28 | } 29 | } 30 | 31 | return resp; 32 | }; 33 | -------------------------------------------------------------------------------- /functions/api/user.ts: -------------------------------------------------------------------------------- 1 | import { ServerError } from '@/functions-helpers/server-error'; 2 | import { DELETE_GTOKEN_COOKIE, GOOGLE_SCOPES_ARRAY } from '@/constants'; 3 | import * as googleApi from '@/functions-helpers/google-api'; 4 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 5 | import { drizzle } from 'drizzle-orm/d1'; 6 | import { users, type UserRawT, type UserT } from '@/schema'; 7 | import { eq } from 'drizzle-orm'; 8 | 9 | /** 10 | * Get user data from DB if it's there 11 | * Otherwise create a new user and return it 12 | * The returned user data is safe to be sent to the client (no sensitive data) 13 | */ 14 | export const onRequestGet: PagesFunction = async ({ 15 | env, 16 | data, 17 | }) => { 18 | const { gToken } = data; 19 | 20 | if (!validateScopes(gToken.scope.split(' '))) { 21 | return new Response('Invalid token scopes', { status: 403 }); 22 | } 23 | 24 | // Check gToken is still valid/active 25 | try { 26 | await googleApi.fetchUserInfo(gToken.access_token); 27 | } catch (error) { 28 | // @ts-ignore 29 | if (error?.code === 401) { 30 | return new Response('Invalid token', { 31 | status: 401, 32 | headers: [['Set-Cookie', DELETE_GTOKEN_COOKIE]], 33 | }); 34 | } else { 35 | throw new ServerError('Failed to fetch user info', error); 36 | } 37 | } 38 | 39 | const userEmail = gToken.user.email.toLowerCase(); 40 | const db = drizzle(env.DB, { logger: true }); 41 | let userData: UserRawT; 42 | 43 | try { 44 | [userData] = await db 45 | .select() 46 | .from(users) 47 | .where(eq(users.email, userEmail)) 48 | .limit(1); 49 | } catch (error) { 50 | throw new ServerError('Failed to fetch user data', error); 51 | } 52 | 53 | // Create a new user if not exists in DB 54 | if (!userData) { 55 | const gTokenWithNoAccessToken = { 56 | user: gToken.user, 57 | refresh_token: gToken.refresh_token, 58 | }; 59 | try { 60 | let newUser: UserRawT = { 61 | email: userEmail, 62 | gToken: gTokenWithNoAccessToken as googleApi.GTokenResponseT, 63 | created: new Date(), 64 | modified: new Date(), 65 | }; 66 | // Reassign the newsly created user to userData 67 | [userData] = await db.insert(users).values(newUser).returning(); 68 | } catch (error) { 69 | throw new ServerError('Failed to create new user', error); 70 | } 71 | } 72 | 73 | const headers = new Headers(); 74 | headers.append('Content-Type', 'application/json'); 75 | 76 | return new Response(JSON.stringify(getSafeUserData(userData)), { 77 | status: 200, 78 | headers, 79 | }); 80 | }; 81 | 82 | /** 83 | * Store user-selected Google tasklist id in DB 84 | */ 85 | export const onRequestPost: PagesFunction = async ({ 86 | env, 87 | request, 88 | data, 89 | }) => { 90 | const { tasklistId, databaseId } = (await request.json()) as { 91 | tasklistId?: string; 92 | databaseId?: string; 93 | }; 94 | 95 | if (!tasklistId && !databaseId) { 96 | return new Response('Invalid request', { status: 400 }); 97 | } 98 | 99 | const email = data.gToken.user.email; 100 | const db = drizzle(env.DB, { logger: true }); 101 | let userData: UserRawT; 102 | 103 | try { 104 | [userData] = await db 105 | .update(users) 106 | .set({ 107 | ...(!!tasklistId && { tasklistId }), 108 | ...(!!databaseId && { databaseId }), 109 | modified: new Date(), 110 | }) 111 | .where(eq(users.email, email)) 112 | .returning(); 113 | } catch (error) { 114 | throw new ServerError('Failed to update user data', error); 115 | } 116 | 117 | return Response.json(getSafeUserData(userData)); 118 | }; 119 | 120 | export const onRequestDelete: PagesFunction = async ({ 121 | env, 122 | request, 123 | data, 124 | }) => { 125 | const { email } = (await request.json()) as { email: string }; 126 | 127 | if (!email) { 128 | return new Response('Invalid request', { status: 400 }); 129 | } 130 | 131 | const tokenEmail = data.gToken.user.email; 132 | if (email !== tokenEmail) { 133 | return new Response('Invalid request', { status: 400 }); 134 | } 135 | 136 | const db = drizzle(env.DB, { logger: true }); 137 | 138 | try { 139 | const res = await db.delete(users).where(eq(users.email, email)); 140 | if (!res.success) { 141 | throw new Error(res.error); 142 | } 143 | } catch (error) { 144 | throw new ServerError('Failed to update user data', error); 145 | } 146 | 147 | return new Response('User deleted', { 148 | status: 200, 149 | headers: [['Set-Cookie', DELETE_GTOKEN_COOKIE]], 150 | }); 151 | }; 152 | 153 | function validateScopes(userScopes: string[]): boolean { 154 | const requiredScopes = GOOGLE_SCOPES_ARRAY; 155 | return requiredScopes.every((scope) => userScopes.includes(scope)); 156 | } 157 | 158 | function getSafeUserData(user: UserRawT): UserT { 159 | const { gToken: _, nToken: __, mapping: ___, ...safeUserData } = user; 160 | return { ...safeUserData, nConnected: !!user.nToken }; 161 | } 162 | -------------------------------------------------------------------------------- /functions/env.d.ts: -------------------------------------------------------------------------------- 1 | // There is a copy of this type in the root directory 2 | interface CFEnvT { 3 | GOOGLE_CLIENT_ID: string; 4 | GOOGLE_CLIENT_SECRET: string; 5 | GOOGLE_REDIRECT_URI: string; 6 | NOTION_CLIENT_ID: string; 7 | NOTION_CLIENT_SECRET: string; 8 | NOTION_REDIRECT_URI: string; 9 | JWT_SECRET: string; 10 | DB: D1Database; 11 | SENTRY_DSN: string; 12 | MAILJET_API_KEY: string; 13 | MAILJET_SECRET_KEY: string; 14 | MAILJET_TEMPLATE_ID: string; 15 | ENVIRONMENT: 'development' | 'production'; 16 | } 17 | -------------------------------------------------------------------------------- /functions/google-auth/callback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google OAuth2 callback endpoint 3 | * Google redirects user to this endpoint after they provide consent 4 | */ 5 | 6 | import jwt from '@tsndr/cloudflare-worker-jwt'; 7 | import type { PluginData } from '@cloudflare/pages-plugin-sentry'; 8 | import * as googleApi from '@/functions-helpers/google-api'; 9 | 10 | export const onRequestGet: PagesFunction = async ({ 11 | request, 12 | env, 13 | data, 14 | }) => { 15 | const url = new URL(request.url); 16 | const authCode = url.searchParams.get('code'); 17 | const authError = url.searchParams.get('error'); 18 | 19 | // Possible Error values: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 20 | if (authError) { 21 | data.sentry.captureException(new Error('google-auth-cb: ' + authError)); 22 | if (authError === 'access_denied') { 23 | return new Response(null, { 24 | status: 302, 25 | statusText: 'Found', 26 | headers: { 27 | Location: '/', 28 | 'Set-Cookie': `auth-error=gaccess_denied; Path=/; Max-Age=20;`, 29 | }, 30 | }); 31 | } 32 | 33 | return new Response(null, { 34 | status: 302, 35 | statusText: 'Found', 36 | headers: { 37 | Location: '/', 38 | 'Set-Cookie': `auth-error=gaccess_error; Path=/; Max-Age=20;`, 39 | }, 40 | }); 41 | } 42 | 43 | if (!authCode) { 44 | data.sentry.captureException( 45 | new Error('google-auth-cb: missing auth code'), 46 | ); 47 | 48 | return new Response(null, { 49 | status: 302, 50 | statusText: 'Found', 51 | headers: { 52 | Location: '/', 53 | 'Set-Cookie': `auth-error=gaccess_error; Path=/; Max-Age=20;`, 54 | }, 55 | }); 56 | } 57 | 58 | let jwtToken; 59 | try { 60 | // Exchange auth code for access token 61 | const tokenData = await googleApi.fetchToken(authCode, env); 62 | 63 | data.sentry.addBreadcrumb({ 64 | message: 'Google token fetched successfully', 65 | level: 'info', 66 | }); 67 | 68 | // Create JWT token for stateless auth and set in cookie 69 | // TODO: set expiration time? 70 | jwtToken = await jwt.sign(tokenData, env.JWT_SECRET); 71 | } catch (error) { 72 | data.sentry.captureException(error); 73 | return new Response(null, { 74 | status: 302, 75 | statusText: 'Found', 76 | headers: { 77 | Location: '/', 78 | 'Set-Cookie': `auth-error=gaccess_error; Path=/; Max-Age=20;`, 79 | }, 80 | }); 81 | } 82 | 83 | return new Response(null, { 84 | status: 302, 85 | statusText: 'Found', 86 | headers: { 87 | Location: '/#start-sync', 88 | // set cookie with expiration in 1 hour 89 | 'Set-Cookie': `gtoken=${jwtToken}; HttpOnly; Secure; Path=/; Max-Age=3600;`, 90 | }, 91 | }); 92 | }; 93 | -------------------------------------------------------------------------------- /functions/google-auth/index.ts: -------------------------------------------------------------------------------- 1 | import { GOOGLE_AUTH_URI, GOOGLE_SCOPES } from '@/constants'; 2 | 3 | // Redirect user to Google Auth server to provide consent and get token 4 | export const onRequestGet: PagesFunction = ({ env }) => { 5 | const googleAuthUrl = new URL(GOOGLE_AUTH_URI); 6 | googleAuthUrl.searchParams.set('scope', GOOGLE_SCOPES); 7 | googleAuthUrl.searchParams.set('response_type', 'code'); 8 | googleAuthUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID); 9 | googleAuthUrl.searchParams.set('redirect_uri', env.GOOGLE_REDIRECT_URI); 10 | googleAuthUrl.searchParams.set('access_type', 'offline'); // to get refresh token 11 | googleAuthUrl.searchParams.set('prompt', 'consent'); // TODO: check if this is needed. https://developers.google.com/identity/protocols/oauth2/web-server#incrementalAuth 12 | // TODO: consider using state param to prevent CSRF: https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1 13 | 14 | return Response.redirect(googleAuthUrl.toString(), 302); 15 | }; 16 | -------------------------------------------------------------------------------- /functions/notion-auth/callback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google OAuth2 callback endpoint 3 | * Google redirects user to this endpoint after they provide consent 4 | */ 5 | 6 | import type { PluginData } from '@cloudflare/pages-plugin-sentry'; 7 | import type { AuthDataT } from '@/functions-helpers/auth-data'; 8 | import * as notionApi from '@/functions-helpers/notion-api'; 9 | import * as dbApi from '@/functions-helpers/db-api'; 10 | 11 | export const onRequestGet: PagesFunction< 12 | CFEnvT, 13 | any, 14 | PluginData & AuthDataT 15 | > = async ({ request, env, data }) => { 16 | const url = new URL(request.url); 17 | const authCode = url.searchParams.get('code'); 18 | const authError = url.searchParams.get('error'); 19 | 20 | // Possible Error values: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 21 | if (authError) { 22 | data.sentry.captureException(new Error('notion-auth-cb: ' + authError)); 23 | if (authError === 'access_denied') { 24 | return new Response(null, { 25 | status: 302, 26 | statusText: 'Found', 27 | headers: { 28 | Location: '/', 29 | 'Set-Cookie': `auth-error=naccess_denied; Path=/; Max-Age=20;`, 30 | }, 31 | }); 32 | } 33 | 34 | return new Response(null, { 35 | status: 302, 36 | statusText: 'Found', 37 | headers: { 38 | Location: '/', 39 | 'Set-Cookie': `auth-error=naccess_error; Path=/; Max-Age=20;`, 40 | }, 41 | }); 42 | } 43 | 44 | if (!authCode) { 45 | data.sentry.captureException( 46 | new Error('notion-auth-cb: missing auth code'), 47 | ); 48 | return new Response(null, { 49 | status: 302, 50 | statusText: 'Found', 51 | headers: { 52 | Location: '/', 53 | 'Set-Cookie': `auth-error=naccess_error; Path=/; Max-Age=20;`, 54 | }, 55 | }); 56 | } 57 | 58 | try { 59 | // Exchange auth code for access token 60 | const tokenData = await notionApi.fetchToken(authCode, env, data.sentry); 61 | 62 | data.sentry.addBreadcrumb({ 63 | message: 'Notion token fetched successfully', 64 | level: 'info', 65 | // strip access_token from the data 66 | data: { ...tokenData, access_token: '***' }, 67 | }); 68 | 69 | await dbApi.storeNotionToken(data.gToken.user.email, tokenData, env); 70 | } catch (error) { 71 | data.sentry.captureException(error); 72 | return new Response(null, { 73 | status: 302, 74 | statusText: 'Found', 75 | headers: { 76 | Location: '/', 77 | 'Set-Cookie': `auth-error=naccess_error; Path=/; Max-Age=20;`, 78 | }, 79 | }); 80 | } 81 | 82 | return Response.redirect(url.origin + '/#start-sync', 302); 83 | }; 84 | -------------------------------------------------------------------------------- /functions/notion-auth/index.ts: -------------------------------------------------------------------------------- 1 | import { NOTION_AUTH_URI } from '@/constants'; 2 | 3 | export const onRequestGet: PagesFunction = ({ env }) => { 4 | // Redirect user to Notion Auth server to provide consent and get token 5 | const notionAuthUrl = new URL(NOTION_AUTH_URI); 6 | notionAuthUrl.searchParams.set('response_type', 'code'); 7 | notionAuthUrl.searchParams.set('owner', 'user'); 8 | notionAuthUrl.searchParams.set('client_id', env.NOTION_CLIENT_ID); 9 | notionAuthUrl.searchParams.set('redirect_uri', env.NOTION_REDIRECT_URI); 10 | 11 | return Response.redirect(notionAuthUrl.toString(), 302); 12 | }; 13 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "noEmit": true, 5 | "module": "esnext", 6 | "target": "esnext", 7 | "lib": ["esnext"], 8 | "types": ["@cloudflare/workers-types"], 9 | "paths": { 10 | "@/*": ["../src/*"], 11 | "@helpers/*": ["../src/helpers/*"], 12 | }, 13 | "strict": true, 14 | "moduleResolution": "node", 15 | "verbatimModuleSyntax": true, 16 | }, 17 | "exclude": ["node_modules", "dist", "test"], 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-gtasks", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "astro": "astro", 8 | "dev": "astro dev --port 3000", 9 | "functions:dev": "astro build && wrangler pages dev ./dist --port=3000 --d1=DB --compatibility-flags=nodejs_compat", 10 | "build": "astro check && astro build", 11 | "sentry:ui": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload ./dist --project=notion-gtasks-website-ui", 12 | "deploy": "PUBLIC_ENV=production pnpm run build && wrangler pages deploy ./dist", 13 | "deploy:prod": "PUBLIC_ENV=production pnpm run build && pnpm run sentry:ui && wrangler pages deploy ./dist --branch=master", 14 | "_functions:build": "wrangler pages functions build ./functions --compatibility-flags=nodejs_compat", 15 | "wrangler": "wrangler", 16 | "db:migr:gen": "drizzle-kit generate:sqlite", 17 | "db:migr:drop": "drizzle-kit drop", 18 | "db:migr:check": "drizzle-kit check:sqlite", 19 | "db:drizzle-metadata-upgrade": "drizzle-kit up:sqlite", 20 | "db:local": "wrangler d1 execute notion-gtasks --local", 21 | "db:local:all": "wrangler d1 execute notion-gtasks --local --command='SELECT * FROM users'", 22 | "db:prod": "wrangler d1 execute notion-gtasks", 23 | "db:prod:all": "wrangler d1 execute notion-gtasks --command='SELECT * FROM users'", 24 | "db:prod:active-count": "wrangler d1 execute notion-gtasks --command='SELECT COUNT(*) FROM users WHERE sync_error IS NULL AND last_synced IS NOT NULL'", 25 | "db:prod:failed-sync": "wrangler d1 execute notion-gtasks --command='SELECT COUNT(*) FROM users WHERE sync_error IS NOT NULL AND last_synced IS NOT NULL'", 26 | "upgrade": "pnpx npm-upgrade", 27 | "tail": "wrangler pages deployment tail" 28 | }, 29 | "dependencies": { 30 | "@astrojs/check": "^0.5.4", 31 | "@astrojs/react": "^3.0.10", 32 | "@astrojs/tailwind": "^5.1.0", 33 | "@astrojs/ts-plugin": "^1.5.2", 34 | "@cloudflare/pages-plugin-sentry": "^1.1.1", 35 | "@notionhq/client": "^2.2.13", 36 | "@radix-ui/react-alert-dialog": "^1.0.5", 37 | "@radix-ui/react-dialog": "^1.0.5", 38 | "@radix-ui/react-icons": "^1.3.0", 39 | "@radix-ui/react-slot": "^1.0.2", 40 | "@radix-ui/react-toast": "^1.1.5", 41 | "@sentry/browser": "^7.100.1", 42 | "@tanstack/react-query": "^5.8.4", 43 | "@types/react": "^18.2.37", 44 | "@types/react-dom": "^18.2.15", 45 | "astro": "^4.4.0", 46 | "class-variance-authority": "^0.7.0", 47 | "clsx": "^2.1.0", 48 | "drizzle-orm": "^0.29.0", 49 | "lucide-react": "^0.312.0", 50 | "react": "^18.2.0", 51 | "react-dom": "^18.2.0", 52 | "sharp": "^0.33.2", 53 | "tailwind-merge": "^2.2.0", 54 | "tailwindcss": "^3.3.5", 55 | "tailwindcss-animate": "^1.0.7", 56 | "typescript": "^5.2.2", 57 | "zod": "^3.22.4" 58 | }, 59 | "devDependencies": { 60 | "@cloudflare/workers-types": "^4.20231025.0", 61 | "@iconify/react": "^4.1.1", 62 | "@sentry/cli": "^2.28.0", 63 | "@tsndr/cloudflare-worker-jwt": "^2.3.2", 64 | "drizzle-kit": "^0.20.4", 65 | "prettier": "^3.2.4", 66 | "prettier-plugin-astro": "^0.12.3", 67 | "prettier-plugin-sql": "^0.18.0", 68 | "prettier-plugin-tailwindcss": "^0.5.11", 69 | "wrangler": "^3.16.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api/ 3 | Disallow: /google-auth 4 | Disallow: /notion-auth 5 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | 3 | CREATE TABLE 4 | IF NOT EXISTS users ( 5 | `email` text PRIMARY KEY NOT NULL, 6 | `g_token` text NOT NULL, 7 | `n_token` text, 8 | `tasklist_id` text, 9 | `database_id` text, 10 | `mapping` text, 11 | `last_synced` integer, 12 | `created` integer NOT NULL, 13 | `modified` integer NOT NULL 14 | ) 15 | -------------------------------------------------------------------------------- /src/components/AuthErrors.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@/components/ui/toast/use-toast'; 2 | import { useEffect } from 'react'; 3 | 4 | export default function AuthErrors() { 5 | const { toast } = useToast(); 6 | 7 | useEffect(() => { 8 | const authErrorCookie = document.cookie 9 | .split('; ') 10 | .find((row) => row.startsWith('auth-error')); 11 | 12 | if (authErrorCookie) { 13 | const error = authErrorCookie.split('=')[1]; 14 | document.cookie = 'auth-error=; Max-Age=0;'; 15 | if (error === 'gaccess_denied' || error === 'naccess_denied') { 16 | const isGoogle = error === 'gaccess_denied'; 17 | toast({ 18 | variant: 'destructive', 19 | title: isGoogle 20 | ? 'Google Connection Denied' 21 | : 'Notion Connection Denied', 22 | description: ( 23 |
24 | To use our synchronization service, you need to grant access to 25 | your Google Tasks and{' '} 26 | Notion database. 27 |
28 | ), 29 | }); 30 | } else if (error === 'gaccess_error' || error === 'naccess_error') { 31 | const isGoogle = error === 'gaccess_error'; 32 | toast({ 33 | variant: 'destructive', 34 | title: isGoogle 35 | ? 'Google Connection Error' 36 | : 'Notion Connection Error', 37 | description: ( 38 |
39 | To use our synchronization service, you need to grant access to 40 | both your Google Tasks and Notion database. 41 |
42 | Please try again. If the problem persists, contact us{' '} 43 | 48 | via email 49 | {' '} 50 | or{' '} 51 | 56 | create an issue 57 | {' '} 58 | on Github. 59 |
60 |
61 | ), 62 | }); 63 | } 64 | } 65 | }, []); 66 | 67 | return null; 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify/react'; 2 | 3 | export default function Button({ 4 | disabled = false, 5 | onClick = () => {}, 6 | children, 7 | loading = false, 8 | size = 'normal', 9 | fullWidth = false, 10 | }: { 11 | children: React.ReactNode; 12 | disabled?: boolean; 13 | loading?: boolean; 14 | onClick?: () => void; 15 | size?: 'normal' | 'small'; 16 | fullWidth?: boolean; 17 | }) { 18 | const padding = size === 'normal' ? 'py-2 px-4' : 'py-1 px-3'; 19 | const fullWidthClass = fullWidth ? 'w-full flex justify-center' : ''; 20 | const loadingItem = loading ? ( 21 | 25 | ) : null; 26 | if (disabled) { 27 | return ( 28 | 35 | ); 36 | } 37 | return ( 38 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ConnectGoogle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import * as Sentry from '@sentry/browser'; 3 | import { 4 | useUserQuery, 5 | useUserDeletion, 6 | useTasklistsQuery, 7 | useUserTasklistMutation, 8 | useHasTokenQuery, 9 | } from '@/helpers/api'; 10 | import Warning from './Warning'; 11 | import { Icon } from '@iconify/react'; 12 | import { Button } from '@/components/ui/button'; 13 | import { buttonVariants } from '@/components/ui/button'; 14 | import { Loader2 } from 'lucide-react'; 15 | import { useToast } from '@/components/ui/toast/use-toast'; 16 | import ModalConfirm from '@/components/Hero/ModalConfirm'; 17 | import clsx from 'clsx'; 18 | 19 | interface TaskListOptionProps { 20 | id: string; 21 | title: string; 22 | selected: boolean; 23 | onSelect: () => void; 24 | } 25 | 26 | export function TaskListOption(props: TaskListOptionProps) { 27 | const { id, title, selected, onSelect } = props; 28 | 29 | return ( 30 |
31 | 39 | 42 |
43 | ); 44 | } 45 | 46 | export function Step({ 47 | state, 48 | isLoading = false, 49 | children, 50 | }: { 51 | state: 'not-connected' | 'ready-to-connect' | 'in-progress' | 'connected'; 52 | isLoading?: boolean; 53 | children?: React.ReactNode; 54 | }) { 55 | if (state === 'not-connected') { 56 | return ( 57 |
58 | 59 | Step 1. 60 | Connect Google Tasks 61 | 62 | {isLoading && ( 63 | 67 | )} 68 |
69 | ); 70 | } 71 | 72 | if (state === 'ready-to-connect') { 73 | return ( 74 | 78 | Step 1. 79 | Connect Google Tasks 80 | 81 | ); 82 | } 83 | 84 | if (state === 'connected') { 85 | return ( 86 |
87 |
88 | Step 1. 89 | Google Tasks connected 90 |
91 | {children} 92 |
93 | ); 94 | } 95 | 96 | // In progress state 97 | return ( 98 |
99 |
100 | Step 1. 101 | Google Tasks connection 102 |
103 | {children} 104 |
105 | ); 106 | } 107 | 108 | export default function ConnectGoogle() { 109 | const { isSuccess: hasToken } = useHasTokenQuery(); 110 | const userQ = useUserQuery(hasToken); 111 | const userTasklistM = useUserTasklistMutation(); 112 | const userD = useUserDeletion(); 113 | const tasklistsQ = useTasklistsQuery(hasToken); 114 | const [userSelectedTasklistId, setUserSelectedTasklistId] = useState< 115 | string | null 116 | >(null); 117 | const [userWantDeleteAccount, setUserWantDeleteAccount] = 118 | useState(false); 119 | const { toast } = useToast(); 120 | 121 | const [userWantChangeTasklist, setUserWantChangeTasklist] = 122 | useState(false); 123 | const createGHIssue = ( 124 | <> 125 | 130 | create an issue 131 | {' '} 132 | on Github 133 | 134 | ); 135 | 136 | useEffect(() => { 137 | if (userQ.data?.email) { 138 | Sentry.setUser({ email: userQ.data.email }); 139 | } 140 | }, [userQ.data?.email]); 141 | 142 | // Save tasklist if there is only one && the saved one is different 143 | useEffect(() => { 144 | if ( 145 | tasklistsQ.data?.length === 1 && 146 | userQ.data && 147 | userQ.data.tasklistId !== tasklistsQ.data[0].id 148 | ) { 149 | userTasklistM.mutate(tasklistsQ.data[0].id); 150 | } 151 | }, [tasklistsQ.data, userQ.data?.tasklistId]); 152 | 153 | const selectedTaskList = (() => { 154 | if (!userQ.error && userQ.data?.tasklistId && tasklistsQ.data) { 155 | return tasklistsQ.data.find( 156 | (taskList) => taskList.id === userQ.data.tasklistId, 157 | ); 158 | } 159 | return null; 160 | })(); 161 | 162 | if (!hasToken) { 163 | return ; 164 | } 165 | 166 | if (userQ.isLoading || tasklistsQ.isLoading) { 167 | return ; 168 | } 169 | 170 | // @ts-ignore 171 | if (userQ.error && userQ.error?.code === 403) { 172 | return ( 173 |
174 | 175 |
176 | 177 | Oops! Required permissions are missing. Please click 'Connect Google 178 | Tasks' to grant full access for proper functionality. 179 | 180 |
181 |
182 | ); 183 | } 184 | 185 | // @ts-ignore 186 | if (userQ.error && userQ.error?.code === 401) { 187 | return ( 188 |
189 | 190 |
191 | 192 | Your session has expired. Please click "Connect Google Tasks" 193 | 194 |
195 |
196 | ); 197 | } 198 | 199 | if (userQ.error) { 200 | return ( 201 |
202 | 203 |
204 | 205 | Something went wrong. Try reload the page and provide the missing 206 | info. If the problem persists, please {createGHIssue} 207 | 208 |
209 |
210 | ); 211 | } 212 | 213 | // First time select task list case 214 | if (!userQ.error && userQ.data && !userQ.data.tasklistId && tasklistsQ.data) { 215 | return ( 216 | 217 |
218 | 219 | Multiple tasklists found. Choose the one you want to sync with 220 | Notion 221 | 222 |
223 | 224 |
225 | {tasklistsQ.data.map((gTaskList) => ( 226 | setUserSelectedTasklistId(gTaskList.id)} 232 | /> 233 | ))} 234 |
235 | 236 | {userSelectedTasklistId && ( 237 | 248 | )} 249 |
250 | ); 251 | } 252 | 253 | // Everthing is set up. Show "Edit" button 254 | if (!userQ.error && selectedTaskList && !userWantChangeTasklist) { 255 | return ( 256 | 257 |
258 |
259 | Connected account: 260 | {userQ.data?.email} 261 | {!userQ.data?.lastSynced && ( 262 | 268 | Edit 269 | 270 | )} 271 | 284 |
285 | 286 |
287 | Connected tasks list: " 288 | {selectedTaskList?.title}" 289 | {!userQ.data?.lastSynced && ( 290 | 300 | )} 301 |
302 | { 306 | userD.mutate( 307 | { email: userQ.data!.email }, 308 | { 309 | onSuccess: () => { 310 | toast({ 311 | variant: 'dark', 312 | description: `Account ${userQ.data?.email} was successfully deleted from our system. Reloading the page...`, 313 | }); 314 | setTimeout(() => { 315 | window.location.reload(); 316 | }, 3500); 317 | }, 318 | onSettled: () => { 319 | setUserWantDeleteAccount(false); 320 | }, 321 | }, 322 | ); 323 | }} 324 | > 325 |
326 |

327 | This action is irreversible. It will permanently delete your 328 | account from our service and remove all associated data from our 329 | servers. This includes your synchronization settings, tokens, 330 | and any task mappings. 331 |

332 |

333 | Please note: This will 334 | not delete or modify your original tasks in Notion and Google 335 | Tasks. Synchronization will be stopped immediately.{' '} 336 |

337 |

Are you sure you want to proceed?

338 |
339 |
340 |
341 |
342 | ); 343 | } 344 | 345 | // Change selected tasklist 346 | if ( 347 | !userQ.error && 348 | selectedTaskList && 349 | userWantChangeTasklist && 350 | tasklistsQ.data 351 | ) { 352 | return ( 353 | 354 |
355 | {tasklistsQ.data.map((gTaskList) => ( 356 | setUserSelectedTasklistId(gTaskList.id)} 362 | /> 363 | ))} 364 |
365 | 366 |
367 | 373 | 374 | {userSelectedTasklistId && ( 375 | 387 | )} 388 |
389 |
390 | ); 391 | } 392 | 393 | return ; 394 | } 395 | -------------------------------------------------------------------------------- /src/components/ConnectNotion.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useDBsQuery, 3 | useUserQuery, 4 | useUserNotionDBMutation, 5 | useDBValidateQuery, 6 | useHasTokenQuery, 7 | } from '@/helpers/api'; 8 | import { useEffect, useState } from 'react'; 9 | import clsx from 'clsx'; 10 | import { Loader2 } from 'lucide-react'; 11 | import { Button } from '@/components/ui/button'; 12 | import { buttonVariants } from '@/components/ui/button'; 13 | import Warning from './Warning'; 14 | import ErrorComponent from './Error'; 15 | import Modal from './Modal'; 16 | import type { 17 | DBSchemaFieldT, 18 | SchemaValidationResponseT, 19 | } from '@/functions-helpers/notion-api'; 20 | import { Icon } from '@iconify/react'; 21 | import useIsGoogleSetupComplete from '@/hooks/useIsGoogleSetupComplete'; 22 | import useActualNotionDbId from '@/hooks/useActualNotionDbId'; 23 | 24 | export function Step({ 25 | state, 26 | children, 27 | isLoading = false, 28 | }: { 29 | state: 'not-connected' | 'ready-to-sync' | 'in-progress' | 'connected'; 30 | isLoading?: boolean; 31 | children?: React.ReactNode; 32 | }) { 33 | if (state === 'not-connected') { 34 | return ( 35 |
36 |
37 | 38 | Step 2. 39 | Connect Notion Database 40 | 41 | {isLoading && ( 42 | 46 | )} 47 |
48 | {children} 49 |
50 | ); 51 | } 52 | 53 | if (state === 'ready-to-sync') { 54 | return ( 55 | 59 | 60 | Step 2. 61 | Connect Notion Database 62 | 63 | 64 | ); 65 | } 66 | 67 | if (state === 'in-progress') { 68 | return ( 69 |
70 |
71 | Step 2. 72 | Notion Database connection 73 |
74 | {children} 75 |
76 | ); 77 | } 78 | 79 | // Connected state 80 | return ( 81 |
82 |
83 | Step 2. 84 | Notion Database Connected 85 |
86 | {children} 87 |
88 | ); 89 | } 90 | 91 | export default function ConnectNotion() { 92 | const { isSuccess: hasToken } = useHasTokenQuery(); 93 | const [isModalOpen, setIsModalOpen] = useState(false); 94 | const userQ = useUserQuery(hasToken); 95 | const userDBM = useUserNotionDBMutation(); 96 | const isGoogleSetUp = useIsGoogleSetupComplete(hasToken); 97 | const isNotionAuthorized = !userQ.error && !!userQ.data?.nConnected; 98 | const dbsQ = useDBsQuery(isGoogleSetUp && isNotionAuthorized); 99 | const notionDbId = useActualNotionDbId(hasToken); 100 | const dbValidationQ = useDBValidateQuery(notionDbId); 101 | const createGHIssue = ( 102 | <> 103 | 108 | create an issue 109 | {' '} 110 | on Github 111 | 112 | ); 113 | 114 | // Auto select Notion database if only one is connected. 115 | // We don't check if it has valid schema at this point. 116 | // We validate later - we validate the one that is saved in the user record. For simplicity. 117 | useEffect(() => { 118 | if (dbsQ.data?.length === 1 && dbsQ.data[0].id !== userQ.data?.databaseId) { 119 | userDBM.mutate(dbsQ.data[0].id); 120 | } 121 | }, [dbsQ.data, userQ.data]); 122 | 123 | const selectedDBName = (() => { 124 | if (userQ.data?.databaseId && dbsQ.data) { 125 | return dbsQ.data.find((db) => db.id === userQ.data.databaseId)?.title; 126 | } 127 | return null; 128 | })(); 129 | 130 | if (!isGoogleSetUp) { 131 | return ; 132 | } 133 | 134 | if ( 135 | dbsQ.isLoading || 136 | (dbsQ.data?.length === 1 && dbsQ.data[0].id !== userQ.data?.databaseId) || 137 | userDBM.isPending || 138 | dbValidationQ.isLoading 139 | ) { 140 | return ; 141 | } 142 | 143 | if (userQ.isError || userDBM.isError || dbsQ.isError) { 144 | return ( 145 |
146 | 147 |
148 | 149 | Something wrong happened. Try reload the page and provide the 150 | missing info. If the problem persists, please {createGHIssue} 151 | 152 |
153 |
154 |
155 | ); 156 | } 157 | 158 | if (dbValidationQ.isError) { 159 | return ( 160 |
161 | 162 |
163 | 164 | Something wrong happened. Try choose a different Notion database 165 | or reload the page. If the problem persists, please{' '} 166 | {createGHIssue} 167 | 168 | 178 |
179 |
180 |
181 | ); 182 | } 183 | 184 | // Let user connect Notion 185 | if (!userQ.data?.nConnected) { 186 | return ; 187 | } 188 | 189 | // 1. Handle the cases of non-proper Notion connection 190 | // 1.1 User hasn't given access to any database 191 | if (dbsQ.data!.length === 0) { 192 | return ( 193 | 194 |
195 | 196 | You have not selected any Notion database. Please update Notion 197 | Connection and choose exactly one Notion Database that will be 198 | connected to Google Tasks 199 | 200 | 208 |
209 |
210 | ); 211 | } 212 | 213 | // 1.2 User has given access to more than one database 214 | if (dbsQ.data!.length > 1) { 215 | return ( 216 | 217 |
218 | 219 | You selected more than one database. Please update Notion Connection 220 | and choose exactly one Notion Database that will be connected to 221 | Google Tasks 222 | 223 | 231 |
232 |
233 | ); 234 | } 235 | 236 | // 1.3 User have access to one database (and it was automatically saved to DB) 237 | // which has wrong schema 238 | if (userQ.data.databaseId && !dbValidationQ.data?.success) { 239 | const dbValidationIssues = getDBValidationIssues(dbValidationQ.data!); 240 | if ( 241 | dbValidationIssues.missingFields.length === 0 && 242 | !dbValidationIssues.wrongStatusField 243 | ) { 244 | return ( 245 | 246 |
247 | Some Unexpected error. Please {createGHIssue} 248 |
249 |
250 | ); 251 | } 252 | return ( 253 | 254 |
255 | 256 |
257 | The database "{selectedDBName}" needs configuration 258 | changes: 259 |
260 | {dbValidationIssues.missingFields.length > 0 && ( 261 |
262 | - Missing fields: {/* */} 263 | {dbValidationIssues.missingFields.map((field, index) => ( 264 | <> 265 | "{field}" 266 | {index < dbValidationIssues.missingFields.length - 1 267 | ? ', ' 268 | : ''} 269 | 270 | ))} 271 |
272 | )} 273 | {dbValidationIssues.wrongStatusField && ( 274 |
275 | - "Status" field should have 276 | "Done" and " 277 | To Do" options. 278 |
279 | )} 280 | 281 |
Choose Your Action:
282 |
283 |
284 |
285 |
286 |
287 | change database configuration in Notion ( 288 | { 292 | e.preventDefault(); 293 | setIsModalOpen(true); 294 | }} 295 | > 296 | see How 297 | 298 | ) and then: 299 |
300 |
301 | 311 |
312 |
313 |
314 | 315 |
316 |
317 | 327 |
328 |
329 | 330 |
331 | Select the appropriate action to proceed with the synchronization 332 | process. 333 |
334 | 335 | 340 |
341 | 353 |
354 |
355 |
356 |
357 |
358 | ); 359 | } 360 | 361 | if (userQ.data?.databaseId && dbsQ.data) { 362 | return ( 363 |
364 | 365 |
366 |
367 | Connected database: " 368 | {selectedDBName}" 369 |
370 | 371 | {!userQ.data.lastSynced && ( 372 | 376 | Edit 377 | 378 | )} 379 |
380 |
381 |
382 | ); 383 | } 384 | 385 | return ( 386 | 387 | 388 | Wow 🙀 - you reached a non-existing state. That's unexpected. 389 | 390 | 391 | ); 392 | } 393 | 394 | function getDBValidationIssues(validationRes: SchemaValidationResponseT) { 395 | if (validationRes.success) { 396 | return { missingFields: [], wrongStatusField: false }; 397 | } 398 | const missingFields = validationRes.issues 399 | .filter((issue) => issue.message === 'Required') 400 | .map((issue) => { 401 | switch (issue.path[0] as DBSchemaFieldT) { 402 | case 'title': 403 | return 'Title'; 404 | case 'status': 405 | return 'Status'; 406 | case 'due': 407 | return 'Date'; 408 | case 'lastEdited': 409 | return 'Last Edited Time'; 410 | case 'lastEditedBy': 411 | return 'Last Edited By'; 412 | default: 413 | return 'Unknown field'; 414 | } 415 | }); 416 | const wrongStatusField = !!validationRes.issues.find( 417 | (issue) => issue.message === 'status_done_or_todo', 418 | ); 419 | return { missingFields, wrongStatusField }; 420 | } 421 | -------------------------------------------------------------------------------- /src/components/ConnectSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { useHasTokenQuery, useUserQuery } from '@/helpers/api'; 2 | import { Icon } from '@iconify/react'; 3 | 4 | export default function ConnectSuccess() { 5 | const { isSuccess: hasToken } = useHasTokenQuery(); 6 | const { isError, data } = useUserQuery(hasToken); 7 | 8 | if (!isError && data?.tasklistId && data.databaseId && data.lastSynced) { 9 | return ( 10 |
11 | 12 |
13 | Hooray! 🚀 You've successfully 14 | linked Notion and Google Tasks. Enjoy the smooth, real-time 15 | synchronization and take your productivity to new heights! 16 |
17 |
18 | ); 19 | } 20 | 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorComponent({ 2 | children, 3 | title, 4 | }: { 5 | children: React.ReactNode; 6 | title?: string; 7 | }) { 8 | return ( 9 |
13 | {title &&

{title}

} 14 |
{children}
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Hero/Hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from 'astro:assets'; 3 | import HeroImage from './hero.png'; 4 | import LogoImage from '@/images/logo.png'; 5 | const serviceName = 'Notion-Google Tasks Sync Service'; 6 | const syncTime = '5 minutes'; 7 | --- 8 | 9 |
10 |
11 |

14 | Sync Tasks Real-Time 15 |

16 |
Notion & Google Tasks
17 |
18 | A bird sitting on a nest of eggs. 19 |
20 |
21 |

22 | Bridge your Notion and Google Tasks with our
23 | smth 24 | {serviceName}. 25 |

26 | 27 |

28 | Experience the convenience of two-way, 29 | real-time syncing at no cost. 32 |

33 |
34 | 35 |
36 | Start Synching Now 41 |
42 |
43 | 44 |
45 |

48 | Features 49 |

50 |
51 |
52 |
Two-way 🔄
53 | Changes made in Google Tasks are reflected in Notion and vice versa. 54 |
55 |
56 |
Near Instant Sync ⚡️
57 |
Your data is synced every {syncTime}.
58 |
59 |
60 |
Secure 🔒
61 |
62 | Data is stored in encrypted Cloudflare D1 Storage, and we're compliant 63 | with Google's policies for handling user data. We store only the 64 | minimum required data (such as tasks IDs). We do not store the 65 | contents of your tasks. 66 |
67 |
68 |
69 |
Data Safety
70 |
71 | We care about your data and we never delete it. We mark deleted Google 72 | tasks as archived in Notion. We mark deleted or archived Notion tasks 73 | as completed in Google. 74 |
75 |
76 |
77 |
Free
78 |
79 | The service is provided completely free of charge. If you want to 80 | thank us, please do so 😊 by spreading the word. 81 |
82 |
83 |
84 |
85 |
86 | 87 | 95 | -------------------------------------------------------------------------------- /src/components/Hero/ModalConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogAction, 4 | AlertDialogCancel, 5 | AlertDialogContent, 6 | AlertDialogDescription, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogTitle, 10 | } from '@/components/ui/alert-dialog'; 11 | 12 | interface Props { 13 | isOpen: boolean; 14 | setIsOpen: (isOpen: boolean) => void; 15 | confirmCb: () => Promise; 16 | children: React.ReactNode; 17 | } 18 | 19 | export default function ModalConfirm({ 20 | isOpen, 21 | setIsOpen, 22 | confirmCb, 23 | children, 24 | }: Props) { 25 | return ( 26 | 27 | 28 | 29 | Are you absolutely sure? 30 | {children} 31 | 32 | 33 | Cancel 34 | confirmCb()}> 35 | Continue 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Hero/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/src/components/Hero/hero.png -------------------------------------------------------------------------------- /src/components/InitialSync.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useDBValidateQuery, 3 | useGTasksQuery, 4 | useHasTokenQuery, 5 | useNTasksQuery, 6 | useSyncMutation, 7 | useUserQuery, 8 | } from '@/helpers/api'; 9 | import { Icon } from '@iconify/react'; 10 | import Button from './Button'; 11 | import useActualNotionDbId from '@/hooks/useActualNotionDbId'; 12 | 13 | type SyncStateT = 'not-synced' | 'ready' | 'syncing' | 'synced'; 14 | 15 | export function Step({ 16 | syncState: state, 17 | onClick = () => {}, // is needed only for the 'ready' state 18 | isLoading = false, 19 | children, 20 | }: { 21 | syncState: SyncStateT; 22 | onClick?: () => void; 23 | isLoading?: boolean; 24 | children?: React.ReactNode; 25 | }) { 26 | if (state === 'not-synced') { 27 | return ( 28 |
29 | 30 | Step 3. 31 | Perform Initial Sync 32 | 33 | {isLoading && ( 34 | 38 | )} 39 |
40 | ); 41 | } 42 | 43 | if (state === 'ready' || state === 'syncing') { 44 | return ( 45 |
46 |
47 | Step 3. 48 | Initial Syncronization 49 |
50 | {children} 51 | 59 |
60 | ); 61 | } 62 | 63 | // Synced state 64 | return ( 65 |
66 |
67 | Step 3. 68 | Initial Syncronization Completed 69 |
70 |
71 | ); 72 | } 73 | 74 | export default function InitialSync() { 75 | const { isSuccess: hasToken } = useHasTokenQuery(); 76 | const userQ = useUserQuery(hasToken); 77 | const notionDbId = useActualNotionDbId(hasToken); 78 | const dbValidationQ = useDBValidateQuery(notionDbId); 79 | const isReadyToFetchTasks = 80 | !userQ.error && 81 | !!userQ.data?.databaseId && 82 | !!userQ.data?.tasklistId && 83 | !!dbValidationQ.data?.success; 84 | const syncM = useSyncMutation(); 85 | const gtasksQ = useGTasksQuery(isReadyToFetchTasks); 86 | const ntasksQ = useNTasksQuery(isReadyToFetchTasks); 87 | 88 | const readyToSync = 89 | !gtasksQ.error && !ntasksQ.error && !!gtasksQ.data && !!ntasksQ.data; 90 | 91 | const syncState = ((): SyncStateT => { 92 | if (userQ.data?.lastSynced) return 'synced'; 93 | if (syncM.status === 'pending') return 'syncing'; 94 | if (syncM.status === 'idle' && readyToSync) return 'ready'; 95 | return 'not-synced'; 96 | })(); 97 | 98 | const syncHandler = () => { 99 | if (syncState !== 'ready') return; 100 | syncM.mutate(); 101 | }; 102 | 103 | if (userQ.isError || dbValidationQ.isError) { 104 | return ; 105 | } 106 | 107 | if (gtasksQ.isError || ntasksQ.isError) { 108 | return ( 109 |
110 | 111 |
112 | Error while loading data. Try to realod the page 113 |
114 |
115 | ); 116 | } 117 | 118 | if (syncM.isError) { 119 | return ( 120 |
121 | 122 |
Error while syncing data
123 |
124 | ); 125 | } 126 | 127 | if (gtasksQ.isLoading || ntasksQ.isLoading) { 128 | return ; 129 | } 130 | 131 | if (syncState === 'synced') { 132 | return ; 133 | } 134 | 135 | // Ready to sync 136 | if (userQ.data && userQ.data.tasklistId && userQ.data.databaseId) { 137 | return ( 138 | 139 |
140 | Review the tasks to be synced initially between the two systems and 141 | click "Synchonize" to start the process. 142 |
143 |
144 |
145 |
146 | 147 | Notion 148 |
149 |
    150 | {ntasksQ.data?.map((task) =>
  • {task.title}
  • )} 151 |
152 |
153 |
154 | 158 |
159 |
160 |
161 | 162 | Google Tasks 163 |
164 |
    165 | {gtasksQ.data?.map((task) =>
  • {task.title}
  • )} 166 |
167 |
168 |
169 |
170 | ); 171 | } 172 | 173 | return ; 174 | } 175 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/browser'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { Toaster } from '@/components/ui/toast/toaster'; 4 | import ConnectGoogle from '@/components/ConnectGoogle'; 5 | import ConnectNotion from '@/components/ConnectNotion'; 6 | import InitialSync from './InitialSync'; 7 | import ConnectSuccess from './ConnectSuccess'; 8 | import { Component, type ErrorInfo, type ReactNode } from 'react'; 9 | import { AlertCircle } from 'lucide-react'; 10 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 11 | import { Button } from '@/components/ui/button'; 12 | import AuthErrors from './AuthErrors'; 13 | 14 | function AlertDestructive() { 15 | return ( 16 | 17 | 18 | Error 19 | 20 | An unexpected error occurred. 21 |
22 | 31 |
32 |
33 |
34 | ); 35 | } 36 | 37 | const queryClient = new QueryClient(); 38 | 39 | interface Props { 40 | children: ReactNode; 41 | fallback: ReactNode; 42 | } 43 | 44 | interface State { 45 | hasError: boolean; 46 | error?: Error; 47 | errorInfo?: ErrorInfo; 48 | } 49 | 50 | class ErrorBoundary extends Component { 51 | public state: State = { hasError: false }; 52 | 53 | public static getDerivedStateFromError(_: Error): State { 54 | return { hasError: true }; 55 | } 56 | 57 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 58 | this.setState({ error: error, errorInfo: errorInfo }); 59 | Sentry.captureException(error); 60 | } 61 | 62 | render() { 63 | if (this.state.hasError) { 64 | return this.props.fallback; 65 | } 66 | 67 | return this.props.children; 68 | } 69 | } 70 | 71 | export default function Main() { 72 | return ( 73 | }> 74 | 75 |
76 | }> 77 | 78 | 79 | 80 |
81 | }> 82 | 83 | 84 |
85 | 86 |
87 | }> 88 | 89 | 90 |
91 | 92 |
93 | }> 94 | 95 | 96 |
97 | 98 | 99 |
100 |
101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from '@/components/ui/dialog'; 8 | 9 | interface Props { 10 | setIsModalOpen: (isModalOpen: boolean) => void; 11 | isOpen: boolean; 12 | title: string; 13 | children: React.ReactNode; 14 | } 15 | 16 | export default function Modal({ 17 | setIsModalOpen, 18 | isOpen, 19 | title, 20 | children, 21 | }: Props) { 22 | return ( 23 | 24 | 25 | 26 | {title} 27 | {children} 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Warning.tsx: -------------------------------------------------------------------------------- 1 | export default function Warning({ 2 | children, 3 | title, 4 | }: { 5 | children: React.ReactNode; 6 | title?: string; 7 | }) { 8 | return ( 9 |
13 | {title &&

{title}

} 14 |
{children}
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )) 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 45 | 46 | const AlertDialogHeader = ({ 47 | className, 48 | ...props 49 | }: React.HTMLAttributes) => ( 50 |
57 | ) 58 | AlertDialogHeader.displayName = "AlertDialogHeader" 59 | 60 | const AlertDialogFooter = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | AlertDialogFooter.displayName = "AlertDialogFooter" 73 | 74 | const AlertDialogTitle = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )) 84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 85 | 86 | const AlertDialogDescription = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => ( 90 | 95 | )) 96 | AlertDialogDescription.displayName = 97 | AlertDialogPrimitive.Description.displayName 98 | 99 | const AlertDialogAction = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 110 | 111 | const AlertDialogCancel = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 124 | )) 125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 126 | 127 | export { 128 | AlertDialog, 129 | AlertDialogPortal, 130 | AlertDialogOverlay, 131 | AlertDialogTrigger, 132 | AlertDialogContent, 133 | AlertDialogHeader, 134 | AlertDialogFooter, 135 | AlertDialogTitle, 136 | AlertDialogDescription, 137 | AlertDialogAction, 138 | AlertDialogCancel, 139 | } 140 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 15 | outline: 16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 17 | secondary: 18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | ghost: 'hover:bg-accent hover:text-accent-foreground', 20 | link: 'text-primary underline-offset-4 hover:underline', 21 | cta: 'bg-blue-500 hover:bg-blue-600 text-white', 22 | }, 23 | size: { 24 | default: 'h-10 px-4 py-2 text-sm', 25 | sm: 'h-9 rounded-md px-3 text-sm', 26 | lg: 'h-12 rounded-md px-8 text-lg', 27 | icon: 'h-10 w-10 text-sm', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 3 | import { X } from 'lucide-react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ); 66 | DialogHeader.displayName = 'DialogHeader'; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ); 80 | DialogFooter.displayName = 'DialogFooter'; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/ui/toast/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ToastPrimitives from '@radix-ui/react-toast'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | import { X } from 'lucide-react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const ToastProvider = ToastPrimitives.Provider; 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 24 | 25 | const toastVariants = cva( 26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', 27 | { 28 | variants: { 29 | variant: { 30 | default: 'border bg-background text-foreground', 31 | destructive: 32 | 'destructive group border-destructive bg-destructive text-destructive-foreground', 33 | dark: 'border bg-gray-800 text-gray-100', 34 | }, 35 | }, 36 | defaultVariants: { 37 | variant: 'default', 38 | }, 39 | }, 40 | ); 41 | 42 | const Toast = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef & 45 | VariantProps 46 | >(({ className, variant, ...props }, ref) => { 47 | return ( 48 | 53 | ); 54 | }); 55 | Toast.displayName = ToastPrimitives.Root.displayName; 56 | 57 | const ToastAction = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 69 | )); 70 | ToastAction.displayName = ToastPrimitives.Action.displayName; 71 | 72 | const ToastClose = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >(({ className, ...props }, ref) => ( 76 | 85 | 86 | 87 | )); 88 | ToastClose.displayName = ToastPrimitives.Close.displayName; 89 | 90 | const ToastTitle = React.forwardRef< 91 | React.ElementRef, 92 | React.ComponentPropsWithoutRef 93 | >(({ className, ...props }, ref) => ( 94 | 99 | )); 100 | ToastTitle.displayName = ToastPrimitives.Title.displayName; 101 | 102 | const ToastDescription = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )); 112 | ToastDescription.displayName = ToastPrimitives.Description.displayName; 113 | 114 | type ToastProps = React.ComponentPropsWithoutRef; 115 | 116 | type ToastActionElement = React.ReactElement; 117 | 118 | export { 119 | type ToastProps, 120 | type ToastActionElement, 121 | ToastProvider, 122 | ToastViewport, 123 | Toast, 124 | ToastTitle, 125 | ToastDescription, 126 | ToastClose, 127 | ToastAction, 128 | }; 129 | -------------------------------------------------------------------------------- /src/components/ui/toast/toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from '@/components/ui/toast/toast'; 11 | import { useToast } from '@/components/ui/toast/use-toast'; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ui/toast/use-toast.tsx: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from 'react'; 3 | 4 | import type { 5 | ToastActionElement, 6 | ToastProps, 7 | } from '@/components/ui/toast/toast'; 8 | 9 | const TOAST_LIMIT = 1; 10 | const TOAST_REMOVE_DELAY = 1000000; 11 | 12 | type ToasterToast = ToastProps & { 13 | id: string; 14 | title?: React.ReactNode; 15 | description?: React.ReactNode; 16 | action?: ToastActionElement; 17 | }; 18 | 19 | const actionTypes = { 20 | ADD_TOAST: 'ADD_TOAST', 21 | UPDATE_TOAST: 'UPDATE_TOAST', 22 | DISMISS_TOAST: 'DISMISS_TOAST', 23 | REMOVE_TOAST: 'REMOVE_TOAST', 24 | } as const; 25 | 26 | let count = 0; 27 | 28 | function genId() { 29 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 30 | return count.toString(); 31 | } 32 | 33 | type ActionType = typeof actionTypes; 34 | 35 | type Action = 36 | | { 37 | type: ActionType['ADD_TOAST']; 38 | toast: ToasterToast; 39 | } 40 | | { 41 | type: ActionType['UPDATE_TOAST']; 42 | toast: Partial; 43 | } 44 | | { 45 | type: ActionType['DISMISS_TOAST']; 46 | toastId?: ToasterToast['id']; 47 | } 48 | | { 49 | type: ActionType['REMOVE_TOAST']; 50 | toastId?: ToasterToast['id']; 51 | }; 52 | 53 | interface State { 54 | toasts: ToasterToast[]; 55 | } 56 | 57 | const toastTimeouts = new Map>(); 58 | 59 | const addToRemoveQueue = (toastId: string) => { 60 | if (toastTimeouts.has(toastId)) { 61 | return; 62 | } 63 | 64 | const timeout = setTimeout(() => { 65 | toastTimeouts.delete(toastId); 66 | dispatch({ 67 | type: 'REMOVE_TOAST', 68 | toastId: toastId, 69 | }); 70 | }, TOAST_REMOVE_DELAY); 71 | 72 | toastTimeouts.set(toastId, timeout); 73 | }; 74 | 75 | export const reducer = (state: State, action: Action): State => { 76 | switch (action.type) { 77 | case 'ADD_TOAST': 78 | return { 79 | ...state, 80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 81 | }; 82 | 83 | case 'UPDATE_TOAST': 84 | return { 85 | ...state, 86 | toasts: state.toasts.map((t) => 87 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 88 | ), 89 | }; 90 | 91 | case 'DISMISS_TOAST': { 92 | const { toastId } = action; 93 | 94 | // ! Side effects ! - This could be extracted into a dismissToast() action, 95 | // but I'll keep it here for simplicity 96 | if (toastId) { 97 | addToRemoveQueue(toastId); 98 | } else { 99 | state.toasts.forEach((toast) => { 100 | addToRemoveQueue(toast.id); 101 | }); 102 | } 103 | 104 | return { 105 | ...state, 106 | toasts: state.toasts.map((t) => 107 | t.id === toastId || toastId === undefined 108 | ? { 109 | ...t, 110 | open: false, 111 | } 112 | : t, 113 | ), 114 | }; 115 | } 116 | case 'REMOVE_TOAST': 117 | if (action.toastId === undefined) { 118 | return { 119 | ...state, 120 | toasts: [], 121 | }; 122 | } 123 | return { 124 | ...state, 125 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 126 | }; 127 | } 128 | }; 129 | 130 | const listeners: Array<(state: State) => void> = []; 131 | 132 | let memoryState: State = { toasts: [] }; 133 | 134 | function dispatch(action: Action) { 135 | memoryState = reducer(memoryState, action); 136 | listeners.forEach((listener) => { 137 | listener(memoryState); 138 | }); 139 | } 140 | 141 | type Toast = Omit; 142 | 143 | function toast({ ...props }: Toast) { 144 | const id = genId(); 145 | 146 | const update = (props: ToasterToast) => 147 | dispatch({ 148 | type: 'UPDATE_TOAST', 149 | toast: { ...props, id }, 150 | }); 151 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }); 152 | 153 | dispatch({ 154 | type: 'ADD_TOAST', 155 | toast: { 156 | ...props, 157 | id, 158 | open: true, 159 | onOpenChange: (open) => { 160 | if (!open) dismiss(); 161 | }, 162 | }, 163 | }); 164 | 165 | return { 166 | id: id, 167 | dismiss, 168 | update, 169 | }; 170 | } 171 | 172 | function useToast() { 173 | const [state, setState] = React.useState(memoryState); 174 | 175 | React.useEffect(() => { 176 | listeners.push(setState); 177 | return () => { 178 | const index = listeners.indexOf(setState); 179 | if (index > -1) { 180 | listeners.splice(index, 1); 181 | } 182 | }; 183 | }, [state]); 184 | 185 | return { 186 | ...state, 187 | toast, 188 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), 189 | }; 190 | } 191 | 192 | export { useToast, toast }; 193 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth'; 2 | export const GOOGLE_SCOPES = 'https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/userinfo.email'; 3 | export const GOOGLE_SCOPES_ARRAY = GOOGLE_SCOPES.split(' '); 4 | export const GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token'; 5 | export const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo'; 6 | export const NOTION_AUTH_URI = 'https://api.notion.com/v1/oauth/authorize'; 7 | // export const GOOGLE_TASKS_LISTS_URL = 8 | // "https://tasks.googleapis.com/tasks/v1/users/@me/lists"; 9 | // // TODO: make the max tasks limit smaller before going to production 10 | export const GOOGLE_MAX_TASKS = 100; // Google Tasks API returns max 100 tasks per request. Default is 20 11 | // export const COMPLETION_MAP_TIMEOUT_DAYS = 7; // Days since a task was completed in Google after which it the mapping should be removed (to keep only active tasks there) 12 | export const NOTION_RATE_LIMIT = 3; // 3 requests per second 13 | export const DELETE_GTOKEN_COOKIE = 'gtoken=; HttpOnly; Secure; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;'; 14 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // There is a copy of this type in the /functions directory 3 | type CFEnvT = { 4 | GOOGLE_CLIENT_ID: string; 5 | GOOGLE_CLIENT_SECRET: string; 6 | GOOGLE_REDIRECT_URI: string; 7 | NOTION_CLIENT_ID: string; 8 | NOTION_CLIENT_SECRET: string; 9 | NOTION_REDIRECT_URI: string; 10 | JWT_SECRET: string; 11 | DB: D1Database; 12 | SENTRY_DSN: string; 13 | MAILJET_API_KEY: string; 14 | MAILJET_SECRET_KEY: string; 15 | MAILJET_TEMPLATE_ID: string; 16 | ENVIRONMENT: 'development' | 'production'; 17 | }; 18 | 19 | type Runtime = import('@astrojs/cloudflare').DirectoryRuntime; 20 | 21 | declare namespace App { 22 | interface Locals extends Runtime { 23 | user: { 24 | name: string; 25 | surname: string; 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/functions-helpers/auth-data.ts: -------------------------------------------------------------------------------- 1 | import type { GTokenResponseT } from '@/functions-helpers/google-api'; 2 | 3 | export type AuthDataT = { 4 | gToken: GTokenResponseT; 5 | }; 6 | -------------------------------------------------------------------------------- /src/functions-helpers/db-api.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1'; 2 | import { users } from '@/schema'; 3 | import type { NTokenResponseT } from './notion-api'; 4 | import { eq } from 'drizzle-orm'; 5 | 6 | export async function storeNotionToken( 7 | email: string, 8 | nToken: NTokenResponseT, 9 | env: CFEnvT, 10 | ) { 11 | const db = drizzle(env.DB, { logger: true }); 12 | const [userData] = await db 13 | .update(users) 14 | .set({ 15 | nToken, 16 | modified: new Date(), 17 | }) 18 | .where(eq(users.email, email)) 19 | .returning(); 20 | return userData; 21 | } 22 | -------------------------------------------------------------------------------- /src/functions-helpers/google-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google API helpers 3 | * IMPORTANT: This file is to be used by Server Functions only! 4 | */ 5 | 6 | import { 7 | GOOGLE_MAX_TASKS, 8 | GOOGLE_TOKEN_URI, 9 | GOOGLE_USERINFO_URL, 10 | NOTION_RATE_LIMIT, 11 | } from '@/constants'; 12 | import type { GTaskT } from '@/helpers/api'; 13 | import type { NTaskT } from './notion-api'; 14 | 15 | interface GTasksResponseT { 16 | nextPageToken?: string; 17 | items: GTaskT[]; 18 | } 19 | 20 | interface UserInfoResponseT { 21 | id: string; 22 | email: string; 23 | verified_email: boolean; 24 | picture: string; 25 | } 26 | 27 | interface OritinalTokenResponseT { 28 | access_token: string; 29 | expires_in: number; 30 | refresh_token: string; 31 | scope: string; 32 | token_type: 'Bearer'; 33 | } 34 | 35 | export interface GTokenResponseT extends OritinalTokenResponseT { 36 | user: UserInfoResponseT; 37 | } 38 | 39 | export interface GTasklistT { 40 | id: string; 41 | title: string; 42 | } 43 | 44 | class FetchError extends Error { 45 | code: number; 46 | constructor(status: number) { 47 | super(`HTTP error! status: ${status}`); 48 | this.code = status; 49 | } 50 | } 51 | 52 | /** 53 | * Fetch open tasks for initial sync 54 | */ 55 | export async function fetchOpenTasks( 56 | tasklistId: string, 57 | token: string, 58 | ): Promise { 59 | const tasksAPIUrl = new URL( 60 | `https://tasks.googleapis.com/tasks/v1/lists/${tasklistId}/tasks`, 61 | ); 62 | tasksAPIUrl.searchParams.set('maxResults', GOOGLE_MAX_TASKS.toString()); 63 | tasksAPIUrl.searchParams.set('showCompleted', 'false'); 64 | 65 | const resp = await fetch(tasksAPIUrl.toString(), { 66 | method: 'GET', 67 | headers: { Authorization: `Bearer ${token}`, accept: 'application/json' }, 68 | }); 69 | if (!resp.ok) { 70 | const error = new Error(resp.statusText) as any; 71 | error.code = resp.status; 72 | throw error; 73 | } 74 | return await resp.json(); 75 | } 76 | 77 | type GTaskIdT = string; 78 | type NTaskIdT = string; 79 | type IdTupleT = [GTaskIdT, NTaskIdT]; 80 | 81 | export async function createAllTasks( 82 | nTasks: NTaskT[], 83 | gTasklistId: string, 84 | accessToken: string, 85 | ): Promise { 86 | const promises = []; 87 | for (let i = 0; i < nTasks.length; i++) { 88 | const promise: Promise = new Promise((resolveTask) => { 89 | setTimeout( 90 | async () => { 91 | const nTask = nTasks[i]; 92 | const gTask = await createTask(nTask, gTasklistId, accessToken); 93 | resolveTask([gTask.id, nTask.id]); 94 | }, 95 | Math.floor(i / NOTION_RATE_LIMIT) * 1000, 96 | ); 97 | }); 98 | promises.push(promise); 99 | } 100 | return Promise.all(promises); 101 | } 102 | 103 | async function createTask( 104 | nTask: NTaskT, 105 | gTasklistId: string, 106 | accessToken: string, // access token 107 | ): Promise { 108 | console.log('Creating Google task', nTask.title); 109 | try { 110 | const tasksAPIUrl = new URL( 111 | `https://tasks.googleapis.com/tasks/v1/lists/${gTasklistId}/tasks`, 112 | ); 113 | 114 | const tasksResp = await fetch(tasksAPIUrl.toString(), { 115 | method: 'POST', 116 | headers: { 117 | Authorization: `Bearer ${accessToken}`, 118 | accept: 'application/json', 119 | }, 120 | body: JSON.stringify({ 121 | title: nTask.title, 122 | due: nTask.due?.start ? new Date(nTask.due.start).toISOString() : null, 123 | status: nTask.status === 'Done' ? 'completed' : 'needsAction', 124 | }), 125 | }); 126 | if (!tasksResp.ok) { 127 | throw new Error( 128 | `Failed to create a Google task: ${tasksResp.status} ${tasksResp.statusText}`, 129 | ); 130 | } 131 | const resp = await tasksResp.json(); 132 | return resp as GTaskT; 133 | } catch (error) { 134 | console.error('Error creating a google task', error); 135 | throw error; 136 | } 137 | } 138 | 139 | export async function fetchUserInfo( 140 | accessToken: string, 141 | ): Promise { 142 | const userResp = await fetch(GOOGLE_USERINFO_URL, { 143 | method: 'GET', 144 | headers: { 145 | Authorization: `Bearer ${accessToken}`, 146 | accept: 'application/json', 147 | }, 148 | }); 149 | if (!userResp.ok) { 150 | throw new FetchError(userResp.status); 151 | } 152 | const userData = (await userResp.json()) as UserInfoResponseT; 153 | return userData; 154 | } 155 | 156 | export async function fetchToken( 157 | authCode: string, 158 | env: CFEnvT, 159 | ): Promise { 160 | try { 161 | const googleTokenUrl = new URL(GOOGLE_TOKEN_URI); 162 | googleTokenUrl.searchParams.set('code', authCode); 163 | googleTokenUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID); 164 | googleTokenUrl.searchParams.set('client_secret', env.GOOGLE_CLIENT_SECRET); 165 | googleTokenUrl.searchParams.set('redirect_uri', env.GOOGLE_REDIRECT_URI); 166 | googleTokenUrl.searchParams.set('grant_type', 'authorization_code'); 167 | const tokensResp = await fetch(googleTokenUrl.toString(), { 168 | method: 'POST', 169 | headers: { accept: 'application/json' }, 170 | }); 171 | if (!tokensResp.ok) { 172 | throw new Error( 173 | `Failed to fetch Google token data: ${tokensResp.status} ${tokensResp.statusText}`, 174 | ); 175 | } 176 | // TODO: handle error response 177 | const tokenData = (await tokensResp.json()) as OritinalTokenResponseT; 178 | const userData = await fetchUserInfo(tokenData.access_token); 179 | 180 | if (!userData.verified_email) { 181 | throw new Error('User email not verified'); 182 | } 183 | 184 | return { ...tokenData, user: userData }; 185 | } catch (error) { 186 | console.error('Error fetching Google token data', error); 187 | throw error; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/functions-helpers/notion-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notion API helpers 3 | * IMPORTANT: This file is to be used by Server Functions only! 4 | */ 5 | import { Buffer } from 'node:buffer'; 6 | import { Client } from '@notionhq/client'; 7 | import type { GTaskT } from '@/helpers/api'; 8 | import { NOTION_RATE_LIMIT } from '@/constants'; 9 | import { z, type ZodIssue } from 'zod'; 10 | import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints'; 11 | import type { PluginData } from '@cloudflare/pages-plugin-sentry'; 12 | 13 | type PromiseValueType = T extends Promise ? R : T; 14 | export type DBSchemaT = PromiseValueType< 15 | ReturnType 16 | >; 17 | 18 | // Notion Tasks statuses 19 | const DONE = 'Done' as const; 20 | const TODO = 'To Do' as const; 21 | type StatusT = typeof DONE | typeof TODO; 22 | 23 | export type SchemaValidationResponseT = 24 | | { success: true } 25 | | { success: false; issues: ZodIssue[] }; 26 | 27 | const TOKEN_URL = 'https://api.notion.com/v1/oauth/token'; 28 | 29 | export interface NTaskT { 30 | id: string; 31 | title: string; 32 | status: StatusT; 33 | due: null | { start: string }; 34 | lastEdited: string; // ISO date string '2023-10-25T11:56:00.000Z' 35 | lastEditedByBot: boolean; // true if last edited by bot (Google Tasks Sync Bot) 36 | } 37 | 38 | export interface NTokenResponseT { 39 | access_token: string; 40 | bot_id: string; 41 | duplicated_template_id: string | null; 42 | owner: any; 43 | workspace_icon: string | null; 44 | workspace_id: string; 45 | workspace_name: string | null; 46 | } 47 | interface NDatabaseT { 48 | id: string; 49 | title: string; 50 | } 51 | export interface NDatabasesResponseT { 52 | items: NDatabaseT[]; 53 | } 54 | 55 | export async function fetchDatabaseSchema(databaseId: string, token: string) { 56 | const notion = new Client({ auth: token }); 57 | const response = await notion.databases.retrieve({ database_id: databaseId }); 58 | return response; 59 | } 60 | 61 | export interface NPropsMapT { 62 | title: { id: string; name: string; type: 'title' }; 63 | status: { id: string; name: string; type: 'status' }; 64 | due: { id: string; name: string; type: 'date' }; 65 | lastEdited: { id: string; name: string; type: 'last_edited_time' }; 66 | lastEditedBy: { id: string; name: string; type: 'last_edited_by' }; 67 | } 68 | 69 | export type DBSchemaFieldT = 70 | | 'title' 71 | | 'status' 72 | | 'due' 73 | | 'lastEdited' 74 | | 'lastEditedBy'; 75 | 76 | export const DbPropsSchema = z.object({ 77 | title: z.object({ 78 | id: z.string(), 79 | name: z.string(), 80 | type: z.literal('title'), 81 | }), 82 | status: z.object({ 83 | id: z.string(), 84 | name: z.string(), 85 | type: z.literal('status'), // TODO: ensure Status prop has proper values 86 | status: z.object({ 87 | options: z 88 | .array( 89 | z.object({ 90 | id: z.string(), 91 | name: z.string(), 92 | color: z.string(), 93 | }), 94 | ) 95 | .refine((arr) => arr.some((opt) => opt.name === DONE), { 96 | message: 'status_done_or_todo', 97 | }) 98 | .refine((arr) => arr.some((opt) => opt.name === TODO), { 99 | message: 'status_done_or_todo', 100 | }), 101 | }), 102 | }), 103 | due: z.object({ 104 | id: z.string(), 105 | name: z.string(), 106 | type: z.literal('date'), 107 | }), 108 | lastEdited: z.object({ 109 | id: z.string(), 110 | name: z.string(), 111 | type: z.literal('last_edited_time'), 112 | }), 113 | lastEditedBy: z.object({ 114 | id: z.string(), 115 | name: z.string(), 116 | type: z.literal('last_edited_by'), 117 | }), 118 | }); 119 | 120 | export function validateDbBSchema( 121 | dbSchema: DBSchemaT, 122 | ): SchemaValidationResponseT { 123 | const props = Object.values(dbSchema.properties); 124 | 125 | const nPropsMap = { 126 | title: props.find((p) => p.type === 'title'), 127 | status: props.find((p) => p.type === 'status'), 128 | due: props.find((p) => p.type === 'date'), 129 | lastEdited: props.find((p) => p.type === 'last_edited_time'), 130 | lastEditedBy: props.find((p) => p.type === 'last_edited_by'), 131 | } as NPropsMapT; 132 | 133 | const parseRes = DbPropsSchema.safeParse(nPropsMap); 134 | 135 | return parseRes.success 136 | ? { success: true } 137 | : { success: false, issues: parseRes.error.issues }; 138 | } 139 | 140 | /** 141 | * Fetch open tasks for initial sync 142 | */ 143 | export async function fetchOpenTasks( 144 | databaseId: string, 145 | propsMap: NPropsMapT, 146 | token: string, 147 | ) { 148 | const notion = new Client({ auth: token }); 149 | const filterProps = Object.values(propsMap).map( 150 | (prop: { id: any }) => prop.id, 151 | ); 152 | const response = await notion.databases.query({ 153 | database_id: databaseId, 154 | archived: true, 155 | filter_properties: filterProps, 156 | page_size: 100, 157 | filter: { 158 | property: propsMap.status.name, 159 | status: { equals: TODO }, 160 | }, 161 | sorts: [ 162 | { 163 | property: propsMap.lastEdited.id, 164 | direction: 'descending', 165 | }, 166 | ], 167 | }); 168 | 169 | type DBPropT = PageObjectResponse['properties'][string]; 170 | type ExtractPropType = T extends { type: U } ? T : never; 171 | type TitlePropT = ExtractPropType; 172 | type StatusPropT = ExtractPropType; 173 | type DuePropT = ExtractPropType; 174 | type LastEditedPropT = ExtractPropType; 175 | type LastEditedByPropT = ExtractPropType; 176 | 177 | const tasks: NTaskT[] = (response.results as PageObjectResponse[]).map( 178 | (result) => ({ 179 | id: result.id, 180 | title: (result.properties[propsMap.title.name] as TitlePropT).title // @ts-ignore 181 | .map((title) => title.plain_text) 182 | .join(''), 183 | status: (result.properties[propsMap.status.name] as StatusPropT).status! 184 | .name as StatusT, 185 | due: (result.properties[propsMap.due.name] as DuePropT).date, 186 | lastEdited: ( 187 | result.properties[propsMap.lastEdited.name] as LastEditedPropT 188 | ).last_edited_time, 189 | lastEditedBy: ( 190 | result.properties[propsMap.lastEditedBy.name] as LastEditedByPropT 191 | ).last_edited_by.id, 192 | lastEditedByBot: 193 | // @ts-ignore 194 | result.properties[propsMap.lastEditedBy.name].last_edited_by.type === 195 | 'bot', 196 | }), 197 | ); 198 | 199 | return { databaseId, items: tasks }; 200 | } 201 | 202 | type GTaskIdT = string; 203 | type NTaskIdT = string; 204 | type IdTupleT = [GTaskIdT, NTaskIdT]; 205 | 206 | export async function createAllTasks( 207 | gTasks: GTaskT[], 208 | databaseId: string, 209 | propsMap: NPropsMapT, 210 | acdessToken: string, 211 | ): Promise { 212 | const promises = []; 213 | for (let i = 0; i < gTasks.length; i++) { 214 | const promise: Promise = new Promise((resolveTask) => { 215 | setTimeout( 216 | async () => { 217 | const gTask = gTasks[i]; 218 | const nTask = await createTask( 219 | gTask, 220 | databaseId, 221 | propsMap, 222 | acdessToken, 223 | ); 224 | resolveTask([gTask.id, nTask.id]); 225 | }, 226 | Math.floor(i / NOTION_RATE_LIMIT) * 1000, 227 | ); 228 | }); 229 | promises.push(promise); 230 | } 231 | return Promise.all(promises); 232 | } 233 | 234 | async function createTask( 235 | gTask: GTaskT, 236 | databaseId: string, 237 | propsMap: NPropsMapT, 238 | accessToken: string, 239 | ) { 240 | console.log('Creating Notion task', gTask.title); 241 | try { 242 | const notion = new Client({ auth: accessToken }); 243 | const date = gTask.due ? { start: gTask.due.slice(0, 10) } : null; 244 | const properties = { 245 | [propsMap.title.name]: { title: [{ text: { content: gTask.title } }] }, 246 | [propsMap.due.name]: { date }, 247 | [propsMap.status.name]: { 248 | status: { name: gTask.status === 'completed' ? DONE : TODO }, 249 | }, 250 | }; 251 | const response = await notion.pages.create({ 252 | parent: { database_id: databaseId }, 253 | properties, 254 | }); 255 | return response; 256 | } catch (error) { 257 | console.error('Error creating Notion task', error); 258 | throw error; 259 | } 260 | } 261 | 262 | export async function fetchDatabases( 263 | token: string, 264 | ): Promise { 265 | try { 266 | const notion = new Client({ auth: token }); 267 | 268 | const response = await notion.search({ 269 | query: '', 270 | filter: { 271 | value: 'database', 272 | property: 'object', 273 | }, 274 | sort: { 275 | direction: 'ascending', 276 | timestamp: 'last_edited_time', 277 | }, 278 | }); 279 | 280 | const items = response.results.map((result) => { 281 | return { 282 | id: result.id, 283 | // @ts-ignore 284 | title: result.title.map((t) => t.text.content).join(''), 285 | }; 286 | }); 287 | 288 | return { items }; 289 | } catch (error) { 290 | console.error('Error fetching databases', error); 291 | throw error; 292 | } 293 | } 294 | 295 | export async function fetchToken( 296 | authCode: string, 297 | env: CFEnvT, 298 | sentry: PluginData['sentry'], 299 | ) { 300 | // https://developers.notion.com/reference/create-a-token 301 | try { 302 | // encode in base 64 303 | const encoded = Buffer.from( 304 | `${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`, 305 | ).toString('base64'); 306 | 307 | const tokensResp = await fetch(TOKEN_URL, { 308 | method: 'POST', 309 | headers: { 310 | Accept: 'application/json', 311 | 'Content-Type': 'application/json', 312 | Authorization: `Basic ${encoded}`, 313 | }, 314 | body: JSON.stringify({ 315 | code: authCode, 316 | redirect_uri: env.NOTION_REDIRECT_URI, 317 | grant_type: 'authorization_code', 318 | }), 319 | }); 320 | 321 | sentry.addBreadcrumb({ 322 | type: 'http', 323 | category: 'xhr', 324 | data: { 325 | url: tokensResp.url, 326 | method: 'POST', 327 | status_code: tokensResp.status, 328 | reason: tokensResp.statusText, 329 | }, 330 | }); 331 | 332 | if (!tokensResp.ok) { 333 | if (tokensResp.status === 400) { 334 | // Possible error values: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 335 | // @ts-ignore 336 | const { error, error_description } = await tokensResp.json(); 337 | throw new Error( 338 | `Failed to fetch token data: ${tokensResp.status} ${tokensResp.statusText}. \n Extra Info: ${error} ${error_description}`, 339 | ); 340 | } 341 | throw new Error( 342 | `Failed to fetch token data: ${tokensResp.status} ${tokensResp.statusText}`, 343 | ); 344 | } 345 | 346 | const tokenData = (await tokensResp.json()) as NTokenResponseT; 347 | return tokenData; 348 | } catch (error) { 349 | console.error('Error fetching token data', error); 350 | throw error; 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/functions-helpers/server-error.ts: -------------------------------------------------------------------------------- 1 | export class ServerError extends Error { 2 | originalMessage: string; 3 | constructor(message: string, originalError?: any) { 4 | const name = 'ServerError'; 5 | const msg = 6 | `${name}: ${message}` + 7 | (originalError ? `\nCaused by: ${originalError?.message}` : ''); 8 | super(msg); 9 | this.name = name; 10 | this.cause = originalError; // Storing the original error 11 | this.originalMessage = originalError?.message; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/helpers/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NDatabasesResponseT, 3 | NTaskT, 4 | SchemaValidationResponseT, 5 | } from '@/functions-helpers/notion-api'; 6 | import type { UserT } from '@/schema'; 7 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 8 | 9 | export interface GTasklistT { 10 | id: string; 11 | title: string; 12 | } 13 | 14 | class FetchError extends Error { 15 | code: number; 16 | constructor(status: number) { 17 | super(`HTTP error! status: ${status}`); 18 | this.code = status; 19 | } 20 | } 21 | 22 | // https://developers.google.com/tasks/reference/rest/v1/tasks#resource:-task 23 | export interface GTaskT { 24 | id: string; 25 | title: string; // can be an empty string 26 | status: 'needsAction' | 'completed'; 27 | due?: string; // ISO Date string 2023-10-31T00:00:00.000Z time portion is always 00:00:00. We can't get or set time. 28 | notes?: string; // == Description 29 | updated: string; // ISO date string '2023-10-25T11:56:22.678Z' 30 | parent?: string; // omitted if task is a top-level task 31 | completed?: string; // Complettion date of the task 32 | deleted?: boolean; 33 | hidden?: boolean; 34 | } 35 | 36 | export const useHasTokenQuery = () => 37 | useQuery({ 38 | queryKey: ['has-token'], 39 | queryFn: async () => { 40 | const response = await fetch('/api/has-token'); 41 | if (!response.ok) { 42 | throw new FetchError(response.status); 43 | } 44 | return 'Yes'; 45 | }, 46 | retry(failureCount, error) { 47 | // @ts-ignore 48 | if (error?.code === 401 || failureCount > 2) { 49 | return false; 50 | } 51 | return true; 52 | }, 53 | }); 54 | 55 | export const useUserQuery = (enabled: boolean = true) => 56 | useQuery({ 57 | queryKey: ['user'], 58 | queryFn: async () => { 59 | const response = await fetch('/api/user'); 60 | if (!response.ok) { 61 | throw new FetchError(response.status); 62 | } 63 | const data = (await response.json()) as UserT; 64 | return data; 65 | }, 66 | retry(failureCount, error) { 67 | // @ts-ignore 68 | if (error?.code === 401 || error?.code === 403 || failureCount > 2) { 69 | return false; 70 | } 71 | return true; 72 | }, 73 | enabled, 74 | }); 75 | 76 | export const useUserNotionDBMutation = () => { 77 | const queryClient = useQueryClient(); 78 | return useMutation({ 79 | mutationFn: async (databaseId: string) => { 80 | const response = await fetch('/api/user', { 81 | method: 'POST', 82 | body: JSON.stringify({ databaseId }), 83 | headers: { 'Content-Type': 'application/json' }, 84 | }); 85 | if (!response.ok) { 86 | throw new FetchError(response.status); 87 | } 88 | const data = (await response.json()) as UserT; 89 | return data; 90 | }, 91 | onSuccess: (data) => { 92 | queryClient.setQueryData(['user'], data); 93 | }, 94 | }); 95 | }; 96 | 97 | export const useUserTasklistMutation = () => { 98 | const queryClient = useQueryClient(); 99 | return useMutation({ 100 | mutationFn: async (tasklistId: string) => { 101 | const response = await fetch('/api/user', { 102 | method: 'POST', 103 | body: JSON.stringify({ tasklistId }), 104 | headers: { 'Content-Type': 'application/json' }, 105 | }); 106 | if (!response.ok) { 107 | throw new FetchError(response.status); 108 | } 109 | const data = (await response.json()) as UserT; 110 | return data; 111 | }, 112 | onSuccess: (data) => { 113 | queryClient.setQueryData(['user'], data); 114 | }, 115 | }); 116 | }; 117 | 118 | export const useUserDeletion = () => { 119 | const queryClient = useQueryClient(); 120 | return useMutation({ 121 | mutationFn: async ({ email }: { email: string }) => { 122 | const response = await fetch('/api/user', { 123 | method: 'DELETE', 124 | body: JSON.stringify({ email }), 125 | headers: { 'Content-Type': 'application/json' }, 126 | }); 127 | if (!response.ok) { 128 | throw new FetchError(response.status); 129 | } 130 | return; 131 | }, 132 | onSuccess: (_data) => { 133 | queryClient.setQueryData(['user'], null); 134 | }, 135 | }); 136 | }; 137 | 138 | export const useTasklistsQuery = (enabled: boolean = true) => 139 | useQuery({ 140 | queryKey: ['tasklists'], 141 | queryFn: async () => { 142 | const response = await fetch('/api/tasklists'); 143 | if (!response.ok) { 144 | throw new FetchError(response.status); 145 | } 146 | const data = (await response.json()) as { items: GTasklistT[] }; 147 | return data.items; 148 | }, 149 | // @ts-ignore 150 | retry(failureCount, error) { 151 | console.log('failure Error', error); 152 | // @ts-ignore 153 | if (error?.code === 401 || failureCount > 2) { 154 | return false; 155 | } 156 | return true; 157 | }, 158 | enabled, 159 | }); 160 | 161 | export const useDBsQuery = (enabled: boolean = true) => 162 | useQuery({ 163 | queryKey: ['databases'], 164 | queryFn: async () => { 165 | const response = await fetch('/api/databases'); 166 | if (!response.ok) { 167 | throw new FetchError(response.status); 168 | } 169 | const data = (await response.json()) as NDatabasesResponseT; 170 | return data.items; 171 | }, 172 | // @ts-ignore 173 | retry(failureCount, error) { 174 | // @ts-ignore 175 | if (error?.code === 401 || failureCount > 2) { 176 | return false; 177 | } 178 | return true; 179 | }, 180 | enabled, 181 | }); 182 | 183 | export const useDBValidateQuery = (dbId: string | false | null | undefined) => 184 | useQuery({ 185 | queryKey: ['database-validate', dbId], 186 | queryFn: async () => { 187 | const response = await fetch(`/api/databases/validate/${dbId}`); 188 | if (!response.ok) { 189 | throw new FetchError(response.status); 190 | } 191 | const data = (await response.json()) as SchemaValidationResponseT; 192 | return data; 193 | }, 194 | retry(failureCount, error) { 195 | // @ts-ignore 196 | if (error?.code === 401 || failureCount > 2) { 197 | return false; 198 | } 199 | return true; 200 | }, 201 | enabled: !!dbId, 202 | }); 203 | 204 | export const useSyncMutation = () => { 205 | const queryClient = useQueryClient(); 206 | return useMutation({ 207 | mutationFn: async () => { 208 | const response = await fetch('/api/sync', { 209 | method: 'POST', 210 | headers: { 'Content-Type': 'application/json' }, 211 | }); 212 | if (!response.ok) { 213 | throw new FetchError(response.status); 214 | } 215 | const data = await response.json(); 216 | return data; 217 | }, 218 | onSuccess: () => { 219 | // Invalidate and refetch 220 | queryClient.invalidateQueries({ queryKey: ['user'] }); 221 | }, 222 | }); 223 | }; 224 | 225 | export const useGTasksQuery = (enabled: boolean = true) => 226 | useQuery({ 227 | queryKey: ['gtasks'], 228 | queryFn: async () => { 229 | const response = await fetch('/api/google-tasks'); 230 | if (!response.ok) { 231 | throw new FetchError(response.status); 232 | } 233 | const data = (await response.json()) as { items: GTaskT[] }; 234 | return data.items; 235 | }, 236 | // @ts-ignore 237 | retry(failureCount, error) { 238 | // @ts-ignore 239 | if (error?.code === 401 || failureCount > 2) { 240 | return false; 241 | } 242 | return true; 243 | }, 244 | enabled, 245 | }); 246 | 247 | export const useNTasksQuery = (enabled: boolean) => 248 | useQuery({ 249 | queryKey: ['ntasks'], 250 | queryFn: async () => { 251 | const response = await fetch('/api/notion-tasks'); 252 | if (!response.ok) { 253 | throw new FetchError(response.status); 254 | } 255 | const data = (await response.json()) as { items: NTaskT[] }; 256 | return data.items; 257 | }, 258 | // @ts-ignore 259 | retry(failureCount, error) { 260 | // @ts-ignore 261 | if (error?.code === 401 || failureCount > 2) { 262 | return false; 263 | } 264 | return true; 265 | }, 266 | enabled, 267 | }); 268 | -------------------------------------------------------------------------------- /src/helpers/decodeJWTTokens.ts: -------------------------------------------------------------------------------- 1 | import jwt from '@tsndr/cloudflare-worker-jwt'; 2 | import type { GTokenResponseT } from '@/functions-helpers/google-api'; 3 | 4 | /** 5 | * Get Google token data from JWT cookie 6 | */ 7 | export async function decodeJWTToken( 8 | gJwtToken: string | null | undefined, 9 | JWT_SECRET: string, 10 | ): Promise { 11 | if (!gJwtToken) { 12 | return null; 13 | } 14 | const isGTokenValid = await jwt.verify(gJwtToken, JWT_SECRET); 15 | if (isGTokenValid) { 16 | const { payload: gToken } = (await jwt.decode(gJwtToken)) as unknown as { 17 | payload: GTokenResponseT; 18 | }; 19 | return gToken; 20 | } 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/parseRequestCookies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get Google and Notion JWT tokens from request cookies 3 | * @param req 4 | * @returns 5 | */ 6 | export function parseRequestCookies(req: Request) { 7 | const cookieHeader = req.headers.get('Cookie') || ''; 8 | const cookies = cookieHeader.split('; ').reduce( 9 | (acc, cookie) => { 10 | const [name, value] = cookie.split('='); 11 | acc[name] = value; 12 | return acc; 13 | }, 14 | {} as { [key: string]: string }, 15 | ); 16 | 17 | return { gJWTToken: cookies['gtoken'] || null } as const; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/useActualNotionDbId.ts: -------------------------------------------------------------------------------- 1 | import { useDBsQuery, useUserQuery } from '@/helpers/api'; 2 | import useIsGoogleSetupComplete from './useIsGoogleSetupComplete'; 3 | 4 | // Provide latest actual saved Notion DB Id the user has given permission to. Otherwise, return null. 5 | export default function useActualNotionDbId( 6 | hasToken: boolean = false, 7 | ): false | null | undefined | string { 8 | const userQ = useUserQuery(hasToken); 9 | const isGoogleSetUp = useIsGoogleSetupComplete(hasToken); 10 | const isNotionAuthorized = !userQ.error && !!userQ.data?.nConnected; 11 | const dbsQ = useDBsQuery(isGoogleSetUp && isNotionAuthorized); 12 | const permissionedDB = dbsQ.data?.length === 1 && dbsQ.data[0].id; 13 | const notionDbId = 14 | isGoogleSetUp && 15 | !dbsQ.isLoading && 16 | userQ.data?.databaseId === permissionedDB && 17 | userQ.data?.databaseId; 18 | return notionDbId; 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useIsGoogleSetupComplete.ts: -------------------------------------------------------------------------------- 1 | import { useUserQuery } from '@/helpers/api'; 2 | 3 | export default function useIsGoogleSetupComplete(hasToken: boolean = false) { 4 | const userQ = useUserQuery(hasToken); 5 | const isGoogleSetUp = 6 | !userQ.isLoading && !userQ.isError && !!userQ.data?.tasklistId; 7 | return isGoogleSetUp; 8 | } 9 | -------------------------------------------------------------------------------- /src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aantipov/notion-google-tasks-website/5dbda7da38108c1709502087233c36aba729285e/src/images/logo.png -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from 'astro:assets'; 3 | import LogoImage from '@/images/logo.png'; 4 | import '@/global.css'; 5 | 6 | const serviceName = 'Notion-Google Tasks Sync'; 7 | 8 | interface Props { 9 | title?: string; 10 | desc?: string; 11 | } 12 | 13 | const defaultTitle = 14 | 'Notion <=> Google Tasks: Sync Your Tasks in 1 minute for Free!'; 15 | const defaultDescription = 16 | 'Synchronize your tasks between Notion and Google Tasks instantly and securely. Our two-way sync service ensures your data is always up-to-date and safe. Try it for free today!'; 17 | const { title = defaultTitle, desc = defaultDescription } = Astro.props; 18 | const today = new Date(); 19 | const imageUrl = new URL(LogoImage.src, Astro.url.origin); 20 | const canonicalURL = Astro.site; 21 | const ogTitle = 'Sync Notion & Google Tasks for Free!'; 22 | const ogDesc = 23 | 'Real-time, two-way syncing for free. Simplify task management across platforms effortlessly'; 24 | --- 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | {title} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 68 | 69 | 95 | 96 | 97 | 137 | 138 |
139 | 140 |
141 | 142 | 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/Layout.astro'; 3 | import Hero from '@/components/Hero/Hero.astro'; 4 | import Main from '@/components/Main.tsx'; 5 | // export const prerender = false; 6 | --- 7 | 8 | 9 | 10 | 11 |
12 |

16 | Start Syncing 17 |
in 3 steps
18 |

19 | 20 |
21 |
22 | 23 |
24 |

27 | Limitations 28 |

29 |
30 |
    31 |
  • 32 | Only tasks' "Title", "Date", and "Status" properties are synced. 35 | Tasks descriptions are not synced. 36 |
  • 37 |
  • Tasks hierarchy is not supported.
  • 38 |
  • No support for multiple Google Tasklists and Notion databases.
  • 39 |
  • 40 | Synchronizaion of tasks' time is unfortunately not supported due to 41 | the limitations of the Google Tasks API. 42 |
  • 43 |
  • 44 | Notion Database should be configured to have properties of the 45 | following types: "Status", "Date", "Last edited time" and "Last Edited By". "Status" property should have the 50 | following options: "To Do", "Done". 53 |
  • 54 |
55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /src/pages/privacy-policy.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/Layout.astro'; 3 | const serviceName = 'Notion-Google Tasks Sync Service'; 4 | const updatedOn = '2023-12-11'; 5 | --- 6 | 7 | 8 |
9 | to Home Page 10 | 11 |

12 | Privacy Policy for {serviceName} 13 |

14 |

Effective Date: {updatedOn}

15 | 16 | 17 | 37 | 38 |
39 |

Information We Collect

40 |

41 | We collect and store the following information when you synchronize your 42 | Notion Database with Google Tasks: 43 |

44 |
    45 |
  • Google's Access Token and Refresh Token
  • 46 |
  • Notion's Access Token
  • 47 |
  • Google Tasks' Tasklist ID
  • 48 |
  • Notion's Database ID
  • 49 |
  • Date of Synchronization Establishment
  • 50 |
  • 51 | Mapping Information between Google Tasks and Notion Database items 52 |
  • 53 |
54 |
55 | 56 |
57 |

How We Use Your Information

58 |

59 | The collected information is used solely for providing our 60 | synchronization service. 61 |

62 |
63 | 64 |
65 |

Storage and Security

66 |

67 | We use Cloudflare D1 for data storage and have implemented security 68 | measures to protect your information. 69 |

70 |
71 | 72 |
73 |

Your Rights and Choices

74 |

75 | You can access, modify, or request deletion of your data by contacting 76 | us at antipov.alexei@gmail.com. 77 |

78 |
79 | 80 |
81 |

Changes to Our Privacy Policy

82 |

83 | We reserve the right to modify this policy and will post changes on this 84 | page. 85 |

86 |
87 | 88 |
89 |

Reporting Issues

90 |

91 | Questions, feature requests, or issues can be reported by creating an 92 | issue in our GitHub repository. 96 |

97 |
98 | 99 |
100 |

Contact Us

101 |

102 | If you have any other questions about this policy, contact us at 103 | antipov.alexei@gmail.com. 104 |

105 |
106 |
107 |
108 | -------------------------------------------------------------------------------- /src/pages/terms-of-use.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/Layout.astro'; 3 | const serviceName = 'Notion Google Tasks Sync Service'; 4 | const updatedOn = '2023-11-22'; 5 | --- 6 | 7 | 8 |
9 | to Home Page 10 | 11 |

Terms of Use for {serviceName}

12 |

Last Updated: {updatedOn}

13 | 14 |
15 |

1. Service Description

16 |

17 | Our service synchronizes data between your Google Tasks and Notion. This 18 | includes task title, status, and due date. Synchronization occurs every 19 | 5 minutes. 20 |

21 |
22 | 23 |
24 |

2. Task Deletion and Archival

25 |

26 | Tasks deleted in Google Tasks are archived in Notion, and tasks archived 27 | or deleted in Notion are marked as completed in Google Tasks. 28 |

29 |
30 | 31 |
32 |

3. Limitations

33 |
    34 |
  • 35 | Syncing is limited to one task list in Google Tasks and one database 36 | in Notion per account. 37 |
  • 38 |
  • Tasks hierarchy is not respected.
  • 39 |
  • Task descriptions are not synchronized.
  • 40 |
  • Supports syncing up to 50 tasks.
  • 41 |
42 |
43 | 44 |
45 |

4. Service Availability

46 |

47 | The service is provided for free and while we strive for continuous 48 | operation, we do not guarantee specific uptime. 49 |

50 |
51 | 52 |
53 |

5. Data Retention

54 |

55 | User data is not deleted by us, but users can request data deletion by 56 | contacting us. 57 |

58 |
59 | 60 |
61 |

6. Changes to Terms

62 |

63 | We may modify these Terms and will notify users of any changes on our 64 | website. 65 |

66 |
67 | 68 |
69 |

7. Reporting Issues

70 |

71 | Questions, feature requests, or issues can be reported by creating an 72 | issue in our GitHub repository. 76 |

77 |
78 | 79 |
80 |

8. Contact Information

81 |

82 | For other questions or concerns, contact us at antipov.alexei@gmail.com. 83 |

84 |
85 |
86 |
87 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 2 | import type * as googleApi from '@/functions-helpers/google-api'; 3 | import type * as notionApi from '@/functions-helpers/notion-api'; 4 | 5 | type GTokenT = 6 | ReturnType extends Promise ? T : never; 7 | type GTokenRestrictedT = Pick; 8 | type NTokenT = 9 | ReturnType extends Promise ? T : never; 10 | type GTaskIdT = string; 11 | type NTaskIdT = string; 12 | type CompletedAtT = string | null; // ISO date string '2023-10-25' 13 | 14 | export const users = sqliteTable('users', { 15 | email: text('email').primaryKey(), 16 | gToken: text('g_token', { mode: 'json' }) 17 | .$type() 18 | .notNull(), 19 | nToken: text('n_token', { mode: 'json' }).$type(), 20 | tasklistId: text('tasklist_id'), 21 | databaseId: text('database_id'), 22 | mapping: text('mapping', { mode: 'json' }).$type< 23 | [GTaskIdT, NTaskIdT, CompletedAtT?][] 24 | >(), 25 | lastSynced: integer('last_synced', { mode: 'timestamp' }), // Important to recognize that sync was established successfully 26 | setupCompletionPromptSent: integer('setup_completion_prompt_sent', { 27 | mode: 'boolean', 28 | }), 29 | setupCompletionPromptSentDate: integer('setup_completion_prompt_sent_date', { 30 | mode: 'timestamp', 31 | }), 32 | syncError: text('sync_error', { mode: 'json' }).$type<{ 33 | message: string; 34 | num: number; // Number of consecutive sync errors 35 | nextRetry: number | null; // Timestamp in ms. Null if no retries left. Max 10 retries within 5 days 36 | sentEmail: boolean; 37 | }>(), // Last sync error message. Reset to null on successful sync 38 | created: integer('created', { mode: 'timestamp' }).notNull(), 39 | modified: integer('modified', { mode: 'timestamp' }).notNull(), 40 | }); 41 | 42 | export type UserRawT = typeof users.$inferInsert; 43 | export type UserT = Omit & { 44 | nConnected: boolean; 45 | }; 46 | 47 | export const syncStats = sqliteTable('sync_stats', { 48 | id: integer('id').primaryKey(), 49 | email: text('email').notNull(), 50 | created: integer('created', { mode: 'number' }).notNull(), 51 | updated: integer('updated', { mode: 'number' }).notNull(), 52 | deleted: integer('deleted', { mode: 'number' }).notNull(), 53 | total: integer('total', { mode: 'number' }).notNull(), 54 | system: text('system', { enum: ['google', 'notion'] }).notNull(), 55 | created_at: integer('created_at', { mode: 'timestamp' }).notNull(), 56 | }); 57 | -------------------------------------------------------------------------------- /src/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ['class'], 4 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}'], 5 | prefix: '', 6 | theme: { 7 | container: { 8 | center: true, 9 | padding: '2rem', 10 | screens: { 11 | '2xl': '1400px', 12 | }, 13 | }, 14 | extend: { 15 | colors: { 16 | border: 'hsl(var(--border))', 17 | input: 'hsl(var(--input))', 18 | ring: 'hsl(var(--ring))', 19 | background: 'hsl(var(--background))', 20 | foreground: 'hsl(var(--foreground))', 21 | primary: { 22 | DEFAULT: 'hsl(var(--primary))', 23 | foreground: 'hsl(var(--primary-foreground))', 24 | }, 25 | secondary: { 26 | DEFAULT: 'hsl(var(--secondary))', 27 | foreground: 'hsl(var(--secondary-foreground))', 28 | }, 29 | destructive: { 30 | DEFAULT: 'hsl(var(--destructive))', 31 | foreground: 'hsl(var(--destructive-foreground))', 32 | }, 33 | muted: { 34 | DEFAULT: 'hsl(var(--muted))', 35 | foreground: 'hsl(var(--muted-foreground))', 36 | }, 37 | accent: { 38 | DEFAULT: 'hsl(var(--accent))', 39 | foreground: 'hsl(var(--accent-foreground))', 40 | }, 41 | popover: { 42 | DEFAULT: 'hsl(var(--popover))', 43 | foreground: 'hsl(var(--popover-foreground))', 44 | }, 45 | card: { 46 | DEFAULT: 'hsl(var(--card))', 47 | foreground: 'hsl(var(--card-foreground))', 48 | }, 49 | }, 50 | borderRadius: { 51 | lg: 'var(--radius)', 52 | md: 'calc(var(--radius) - 2px)', 53 | sm: 'calc(var(--radius) - 4px)', 54 | }, 55 | keyframes: { 56 | 'accordion-down': { 57 | from: { height: '0' }, 58 | to: { height: 'var(--radix-accordion-content-height)' }, 59 | }, 60 | 'accordion-up': { 61 | from: { height: 'var(--radix-accordion-content-height)' }, 62 | to: { height: '0' }, 63 | }, 64 | }, 65 | animation: { 66 | 'accordion-down': 'accordion-down 0.2s ease-out', 67 | 'accordion-up': 'accordion-up 0.2s ease-out', 68 | }, 69 | }, 70 | }, 71 | plugins: [require('tailwindcss-animate')], 72 | }; 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "exclude": ["functions/**/*", "dist/**/*", "node_modules/**/*"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"], 8 | "@helpers/*": ["src/helpers/*"], 9 | }, 10 | "verbatimModuleSyntax": true, 11 | "plugins": [ 12 | { 13 | "name": "@astrojs/ts-plugin", 14 | }, 15 | ], 16 | "jsx": "react-jsx", 17 | "jsxImportSource": "react", 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | # name = "notion-gtasks-worker" 2 | # main = "src/index.ts" 3 | # compatibility_date = "2023-10-16" 4 | # compatibility_flags = [ "nodejs_compat" ] 5 | 6 | # If you are only using Pages + D1, you only need the below in your wrangler.toml to interact with D1 locally. 7 | [[d1_databases]] 8 | binding = "DB" # Should match preview_database_id 9 | database_name = "notion-gtasks" 10 | database_id = "22e7ad09-c2ae-4374-85f6-6f26b9a5fb5e" # wrangler d1 info YOUR_DATABASE_NAME 11 | preview_database_id = "DB" # Required for Pages local development 12 | 13 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) 14 | # Note: Use secrets to store sensitive data. 15 | # Docs: https://developers.cloudflare.com/workers/platform/environment-variables 16 | [vars] 17 | # GOOGLE_REDIRECT_URI = "https://notion-gtasks-worker.moiva.workers.dev/auth-callback" 18 | 19 | # [triggers] 20 | # crons = ["* * * * *"] 21 | 22 | # [env.production.vars] 23 | 24 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. 25 | # Docs: https://developers.cloudflare.com/workers/runtime-apis/kv 26 | # [[kv_namespaces]] 27 | # binding = "NOTION_GTASKS_KV" 28 | # id = "fb0190bf97af4a95ac67ca43ac0dfdcd" 29 | 30 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. 31 | # Docs: https://developers.cloudflare.com/queues/get-started 32 | # [[queues.producers]] 33 | # binding = "SYNC_QUEUE" 34 | # queue = "user-email-sync" 35 | 36 | # Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. 37 | # Docs: https://developers.cloudflare.com/queues/get-started 38 | # [[queues.consumers]] 39 | # queue = "user-email-sync" 40 | # max_batch_size = 1 # optional: defaults to 10 41 | # max_batch_timeout = 1 # optional: defaults to 5 seconds 42 | 43 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. 44 | # Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ 45 | # [[r2_buckets]] 46 | # binding = "MY_BUCKET" 47 | # bucket_name = "my-bucket" 48 | 49 | # Bind another Worker service. Use this binding to call another Worker without network overhead. 50 | # Docs: https://developers.cloudflare.com/workers/platform/services 51 | # [[services]] 52 | # binding = "MY_SERVICE" 53 | # service = "my-service" 54 | 55 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. 56 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. 57 | # Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects 58 | # [[durable_objects.bindings]] 59 | # name = "MY_DURABLE_OBJECT" 60 | # class_name = "MyDurableObject" 61 | 62 | # Durable Object migrations. 63 | # Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations 64 | # [[migrations]] 65 | # tag = "v1" 66 | # new_classes = ["MyDurableObject"] 67 | --------------------------------------------------------------------------------