From e95e5054df9b3cee911dc8aedbe497c128b80570 Mon Sep 17 00:00:00 2001 From: snackman Date: Sun, 22 Mar 2026 23:23:32 -0400 Subject: [PATCH] Add Gmail-based Luma event importer Gmail OAuth integration to scan Luma emails, extract event data (names, dates, locations) from confirmations, approvals, reminders, and ICS attachments. Includes review screen, confidence scoring, deduplication, and privacy controls. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 338 +++++++++++++- package.json | 1 + src/app/api/gmail/callback/route.ts | 35 ++ src/app/api/gmail/connect/route.ts | 30 ++ src/app/api/gmail/disconnect/route.ts | 45 ++ src/app/api/gmail/import/route.ts | 103 ++++ src/app/api/gmail/sync/route.ts | 58 +++ src/app/import/page.tsx | 442 ++++++++++++++++++ src/components/LumaEventHistory.tsx | 299 ++++++++++++ src/components/LumaImportReview.tsx | 370 +++++++++++++++ src/lib/gmail/auth.ts | 163 +++++++ src/lib/gmail/constants.ts | 50 ++ src/lib/gmail/crypto.ts | 46 ++ src/lib/gmail/dedup.ts | 120 +++++ src/lib/gmail/fetch.ts | 155 ++++++ src/lib/gmail/ics-parser.ts | 104 +++++ src/lib/gmail/index.ts | 9 + src/lib/gmail/luma-extractor.ts | 331 +++++++++++++ src/lib/gmail/normalize.ts | 73 +++ src/lib/gmail/server-auth.ts | 27 ++ src/lib/gmail/types.ts | 91 ++++ .../20260322120000_gmail_luma_importer.sql | 137 ++++++ 22 files changed, 3007 insertions(+), 20 deletions(-) create mode 100644 src/app/api/gmail/callback/route.ts create mode 100644 src/app/api/gmail/connect/route.ts create mode 100644 src/app/api/gmail/disconnect/route.ts create mode 100644 src/app/api/gmail/import/route.ts create mode 100644 src/app/api/gmail/sync/route.ts create mode 100644 src/app/import/page.tsx create mode 100644 src/components/LumaEventHistory.tsx create mode 100644 src/components/LumaImportReview.tsx create mode 100644 src/lib/gmail/auth.ts create mode 100644 src/lib/gmail/constants.ts create mode 100644 src/lib/gmail/crypto.ts create mode 100644 src/lib/gmail/dedup.ts create mode 100644 src/lib/gmail/fetch.ts create mode 100644 src/lib/gmail/ics-parser.ts create mode 100644 src/lib/gmail/index.ts create mode 100644 src/lib/gmail/luma-extractor.ts create mode 100644 src/lib/gmail/normalize.ts create mode 100644 src/lib/gmail/server-auth.ts create mode 100644 src/lib/gmail/types.ts create mode 100644 supabase/migrations/20260322120000_gmail_luma_importer.sql diff --git a/package-lock.json b/package-lock.json index 9a5ad067..0cb0f842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-virtual": "^3.13.21", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "googleapis": "^171.4.0", "html-to-image": "^1.11.13", "lucide-react": "^0.563.0", "mapbox-gl": "^3.18.1", @@ -2888,6 +2889,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3176,6 +3186,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -3185,6 +3215,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3243,6 +3282,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytewise": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", @@ -3285,7 +3330,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3299,7 +3343,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3449,6 +3492,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3517,7 +3569,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3610,7 +3661,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3627,6 +3677,15 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -3728,7 +3787,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3738,7 +3796,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3776,7 +3833,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4321,6 +4377,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -4394,6 +4456,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4474,6 +4559,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4493,7 +4590,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4530,6 +4626,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -4560,7 +4684,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4585,7 +4708,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4684,11 +4806,65 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4766,7 +4942,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4795,7 +4970,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4827,6 +5001,19 @@ "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -5408,6 +5595,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5464,6 +5660,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -5895,7 +6112,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5951,7 +6167,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/murmurhash-js": { @@ -6082,6 +6297,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6103,7 +6356,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6481,6 +6733,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6721,6 +6988,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6921,7 +7208,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6941,7 +7227,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6958,7 +7243,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6977,7 +7261,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7708,6 +7991,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/use-deep-compare-effect": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz", @@ -7739,6 +8028,15 @@ "supercluster": ">=8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c62eb31c..ddd9a7b4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tanstack/react-virtual": "^3.13.21", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "googleapis": "^171.4.0", "html-to-image": "^1.11.13", "lucide-react": "^0.563.0", "mapbox-gl": "^3.18.1", diff --git a/src/app/api/gmail/callback/route.ts b/src/app/api/gmail/callback/route.ts new file mode 100644 index 00000000..35d31ad4 --- /dev/null +++ b/src/app/api/gmail/callback/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { handleGoogleCallback } from '@/lib/gmail'; + +export async function GET(req: NextRequest) { + try { + const url = new URL(req.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); // userId passed as state + const error = url.searchParams.get('error'); + + if (error) { + // User denied access or something went wrong + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''; + return NextResponse.redirect(`${appUrl}/import?error=access_denied`); + } + + if (!code || !state) { + return NextResponse.json( + { error: 'Missing code or state parameter' }, + { status: 400 } + ); + } + + // Exchange code for tokens and store them + await handleGoogleCallback(code, state); + + // Redirect back to the import page + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''; + return NextResponse.redirect(`${appUrl}/import?connected=true`); + } catch (err) { + console.error('Gmail callback error:', err); + const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''; + return NextResponse.redirect(`${appUrl}/import?error=callback_failed`); + } +} diff --git a/src/app/api/gmail/connect/route.ts b/src/app/api/gmail/connect/route.ts new file mode 100644 index 00000000..bc3add6c --- /dev/null +++ b/src/app/api/gmail/connect/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUser } from '@/lib/gmail/server-auth'; +import { getGoogleAuthUrl, getGmailConnection } from '@/lib/gmail'; + +export async function GET(req: NextRequest) { + try { + const { user, error } = await getAuthenticatedUser(req); + if (error) return error; + + // Check if already connected + const existing = await getGmailConnection(user!.id); + if (existing) { + return NextResponse.json({ + connected: true, + email: existing.google_account_email, + connectedAt: existing.connected_at, + }); + } + + // Generate OAuth URL + const authUrl = getGoogleAuthUrl(user!.id); + return NextResponse.json({ authUrl }); + } catch (err) { + console.error('Gmail connect error:', err); + return NextResponse.json( + { error: 'Failed to initiate Gmail connection' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/gmail/disconnect/route.ts b/src/app/api/gmail/disconnect/route.ts new file mode 100644 index 00000000..e892b7c5 --- /dev/null +++ b/src/app/api/gmail/disconnect/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { getAuthenticatedUser } from '@/lib/gmail/server-auth'; +import { disconnectGmail } from '@/lib/gmail'; + +export async function POST(req: NextRequest) { + try { + const { user, error } = await getAuthenticatedUser(req); + if (error) return error; + + const userId = user!.id; + const body = await req.json().catch(() => ({})); + const deleteData = body.deleteData === true; + + // Disconnect Gmail (revokes token, marks connection as disconnected) + await disconnectGmail(userId); + + // Optionally delete all imported data + if (deleteData) { + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + // Sources are CASCADE deleted when events are deleted + await supabase + .from('imported_events') + .delete() + .eq('user_id', userId) + .eq('source', 'gmail_luma'); + } + + return NextResponse.json({ + success: true, + dataDeleted: deleteData, + }); + } catch (err) { + console.error('Gmail disconnect error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json( + { error: `Disconnect failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/gmail/import/route.ts b/src/app/api/gmail/import/route.ts new file mode 100644 index 00000000..7560512b --- /dev/null +++ b/src/app/api/gmail/import/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { getAuthenticatedUser } from '@/lib/gmail/server-auth'; +import type { ImportEventPayload } from '@/lib/gmail/types'; + +function getSupabase() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); +} + +export async function POST(req: NextRequest) { + try { + const { user, error } = await getAuthenticatedUser(req); + if (error) return error; + + const userId = user!.id; + const body = await req.json(); + const events: ImportEventPayload[] = body.events; + + if (!Array.isArray(events) || events.length === 0) { + return NextResponse.json( + { error: 'No events provided' }, + { status: 400 } + ); + } + + const supabase = getSupabase(); + let imported = 0; + let skipped = 0; + + for (const event of events) { + // Upsert the event (update if external_event_key already exists) + const { data: upserted, error: eventError } = await supabase + .from('imported_events') + .upsert( + { + user_id: userId, + source: 'gmail_luma', + external_event_key: event.externalEventKey, + event_name: event.eventName, + event_start_at: event.eventStartAt, + event_end_at: event.eventEndAt, + location_raw: event.locationRaw, + location_normalized: event.locationRaw, // Same as raw for MVP + event_url: event.eventUrl, + status: event.status, + parse_confidence: event.parseConfidence, + first_seen_at: event.firstSeenAt, + last_seen_at: event.lastSeenAt, + updated_at: new Date().toISOString(), + }, + { + onConflict: 'user_id,external_event_key', + } + ) + .select('id') + .single(); + + if (eventError || !upserted) { + console.error('Failed to upsert event:', eventError); + skipped++; + continue; + } + + // Insert source references + for (const source of event.sources) { + await supabase.from('imported_event_sources').upsert( + { + imported_event_id: upserted.id, + gmail_message_id: source.gmailMessageId, + gmail_thread_id: source.gmailThreadId, + message_type: source.hadIcs ? 'calendar_invite' : 'unknown', + sender: source.sender, + subject: source.subject, + received_at: source.receivedAt, + had_ics: source.hadIcs, + }, + { + onConflict: 'id', // No real conflict expected, just insert + ignoreDuplicates: true, + } + ); + } + + imported++; + } + + return NextResponse.json({ + success: true, + imported, + skipped, + }); + } catch (err) { + console.error('Gmail import error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json( + { error: `Import failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/gmail/sync/route.ts b/src/app/api/gmail/sync/route.ts new file mode 100644 index 00000000..232a3bd8 --- /dev/null +++ b/src/app/api/gmail/sync/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { getAuthenticatedUser } from '@/lib/gmail/server-auth'; +import { + getValidAccessToken, + fetchLumaMessages, + extractLumaEvent, + deduplicateEvents, +} from '@/lib/gmail'; + +export async function POST(req: NextRequest) { + try { + const { user, error } = await getAuthenticatedUser(req); + if (error) return error; + + const userId = user!.id; + + // Get a valid access token + const accessToken = await getValidAccessToken(userId); + + // Fetch Luma messages from Gmail + const messages = await fetchLumaMessages(accessToken); + + // Extract candidate events from each message + const candidates = messages + .map((msg) => extractLumaEvent(msg)) + .filter((c): c is NonNullable => c !== null); + + // Deduplicate + const events = deduplicateEvents(candidates); + + // Update last_sync_at + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + await supabase + .from('gmail_connections') + .update({ last_sync_at: new Date().toISOString() }) + .eq('user_id', userId) + .eq('status', 'active'); + + return NextResponse.json({ + events, + totalMessages: messages.length, + totalCandidates: candidates.length, + totalEvents: events.length, + }); + } catch (err) { + console.error('Gmail sync error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json( + { error: `Sync failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/import/page.tsx b/src/app/import/page.tsx new file mode 100644 index 00000000..b6e92f21 --- /dev/null +++ b/src/app/import/page.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { Suspense, useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; +import { LumaImportReview } from '@/components/LumaImportReview'; +import { LumaEventHistory } from '@/components/LumaEventHistory'; +import { + Mail, + Shield, + Eye, + CheckCircle, + Loader2, + ArrowLeft, + AlertCircle, +} from 'lucide-react'; +import type { DeduplicatedEvent } from '@/lib/gmail/types'; + +type ImportStep = 'connect' | 'syncing' | 'review' | 'importing' | 'done' | 'history'; + +export default function ImportPage() { + return ( + + + + } + > + + + ); +} + +function ImportPageContent() { + const { user, session, loading: authLoading } = useAuth(); + const searchParams = useSearchParams(); + + const [step, setStep] = useState('connect'); + const [isConnected, setIsConnected] = useState(false); + const [connectedEmail, setConnectedEmail] = useState(''); + const [candidates, setCandidates] = useState([]); + const [syncStats, setSyncStats] = useState({ totalMessages: 0, totalCandidates: 0 }); + const [importedCount, setImportedCount] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const [checkingConnection, setCheckingConnection] = useState(true); + + const getAuthHeaders = useCallback((): Record => { + if (!session?.access_token) return {}; + return { Authorization: `Bearer ${session.access_token}` }; + }, [session]); + + // Check for OAuth callback params + useEffect(() => { + const connected = searchParams.get('connected'); + const error = searchParams.get('error'); + + if (connected === 'true') { + setIsConnected(true); + setStep('syncing'); + } + if (error) { + setErrorMessage( + error === 'access_denied' + ? 'Gmail access was denied. You can try again when ready.' + : 'Something went wrong connecting to Gmail. Please try again.' + ); + } + }, [searchParams]); + + // Check if already connected + useEffect(() => { + if (!user || !session) { + setCheckingConnection(false); + return; + } + + async function checkConnection() { + try { + const res = await fetch('/api/gmail/connect', { + headers: getAuthHeaders(), + }); + const data = await res.json(); + if (data.connected) { + setIsConnected(true); + setConnectedEmail(data.email); + setStep('history'); + } + } catch { + // Not connected, stay on connect step + } finally { + setCheckingConnection(false); + } + } + + checkConnection(); + }, [user, session, getAuthHeaders]); + + // Auto-sync when entering syncing step + useEffect(() => { + if (step !== 'syncing' || !session) return; + + async function runSync() { + try { + setErrorMessage(''); + const res = await fetch('/api/gmail/sync', { + method: 'POST', + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Sync failed'); + } + + const data = await res.json(); + setCandidates(data.events); + setSyncStats({ + totalMessages: data.totalMessages, + totalCandidates: data.totalCandidates, + }); + setStep('review'); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : 'Failed to sync Gmail' + ); + setStep('connect'); + } + } + + runSync(); + }, [step, session, getAuthHeaders]); + + const handleConnect = async () => { + if (!session) return; + + try { + setErrorMessage(''); + const res = await fetch('/api/gmail/connect', { + headers: getAuthHeaders(), + }); + const data = await res.json(); + + if (data.connected) { + setIsConnected(true); + setStep('syncing'); + return; + } + + if (data.authUrl) { + window.location.href = data.authUrl; + } + } catch { + setErrorMessage('Failed to start Gmail connection'); + } + }; + + const handleImport = async (selectedEvents: DeduplicatedEvent[]) => { + if (!session) return; + + setStep('importing'); + try { + const res = await fetch('/api/gmail/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + }, + body: JSON.stringify({ + events: selectedEvents.map((e) => ({ + externalEventKey: e.externalEventKey, + eventName: e.eventName, + eventStartAt: e.eventStartAt, + eventEndAt: e.eventEndAt, + locationRaw: e.locationRaw, + eventUrl: e.eventUrl, + status: e.status, + parseConfidence: e.parseConfidence, + firstSeenAt: e.firstSeenAt, + lastSeenAt: e.lastSeenAt, + sources: e.sources, + })), + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Import failed'); + } + + const data = await res.json(); + setImportedCount(data.imported); + setStep('done'); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : 'Failed to import events' + ); + setStep('review'); + } + }; + + const handleResync = () => { + setStep('syncing'); + setCandidates([]); + }; + + // Auth loading or checking connection + if (authLoading || checkingConnection) { + return ( +
+ +
+ ); + } + + // Not logged in + if (!user) { + return ( +
+
+ +

+ Sign in to import events +

+

+ You need to be signed in to connect your Gmail and import Luma events. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + +

Import Luma Events

+
+
+ +
+ {/* Error banner */} + {errorMessage && ( +
+ +
+

{errorMessage}

+ +
+
+ )} + + {/* Step: Connect */} + {step === 'connect' && } + + {/* Step: Syncing */} + {step === 'syncing' && } + + {/* Step: Review */} + {step === 'review' && ( + setStep(isConnected ? 'history' : 'connect')} + /> + )} + + {/* Step: Importing */} + {step === 'importing' && ( +
+ +

Importing your events...

+
+ )} + + {/* Step: Done */} + {step === 'done' && ( + setStep('history')} + /> + )} + + {/* Step: History */} + {step === 'history' && ( + + )} +
+
+ ); +} + +/** Trust / consent screen before OAuth */ +function ConnectStep({ onConnect }: { onConnect: () => void }) { + return ( +
+
+
+
+ +
+

+ Import your Luma events +

+

+ Automatically find events you RSVP'd to, including hidden addresses + revealed after approval. +

+
+ + {/* What we access */} +
+
+ +
+

We only read Luma emails

+

+ Emails from Luma about RSVPs, approvals, reminders, and calendar invites. +

+
+
+ +
+ +
+

We extract

+

+ Event name, date & time, venue/location. That's it. +

+
+
+ +
+ +
+

We do not

+

+ Read unrelated emails, send emails, or modify your inbox. +

+
+
+
+ + {/* Actions */} +
+ + + Maybe later + +
+ + {/* Technical details */} +
+ + Technical details + +
    +
  • Gmail read-only permission (gmail.readonly scope)
  • +
  • Search query restricted to known Luma sender addresses
  • +
  • Only structured event data is stored (no raw email bodies)
  • +
  • You can disconnect and delete all data at any time
  • +
+
+
+
+ ); +} + +/** Loading state while syncing Gmail */ +function SyncingStep() { + return ( +
+
+
+ +
+ +
+

+ Scanning your Luma emails... +

+

+ Looking for RSVP confirmations, approvals, reminders, and calendar invites + from Luma. This may take a moment. +

+
+ ); +} + +/** Success screen after import */ +function DoneStep({ + count, + onViewHistory, +}: { + count: number; + onViewHistory: () => void; +}) { + return ( +
+
+ +
+

+ {count} event{count !== 1 ? 's' : ''} imported! +

+

+ Your Luma events have been saved. You can view them in your event history. +

+ +
+ ); +} diff --git a/src/components/LumaEventHistory.tsx b/src/components/LumaEventHistory.tsx new file mode 100644 index 00000000..035dbf44 --- /dev/null +++ b/src/components/LumaEventHistory.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Calendar, + MapPin, + Link2, + RefreshCw, + Unplug, + Trash2, + Loader2, + Mail, + AlertCircle, +} from 'lucide-react'; +import { supabase } from '@/lib/supabase'; +import { useAuth } from '@/contexts/AuthContext'; + +interface ImportedEvent { + id: string; + event_name: string; + event_start_at: string | null; + event_end_at: string | null; + location_raw: string | null; + event_url: string | null; + status: string | null; + parse_confidence: number | null; + source: string; + created_at: string; +} + +interface LumaEventHistoryProps { + connectedEmail: string; + onResync: () => void; + getAuthHeaders: () => Record; +} + +export function LumaEventHistory({ + connectedEmail, + onResync, + getAuthHeaders, +}: LumaEventHistoryProps) { + const { user } = useAuth(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [disconnecting, setDisconnecting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const loadEvents = useCallback(async () => { + if (!user) return; + + setLoading(true); + try { + const { data, error } = await supabase + .from('imported_events') + .select('*') + .eq('user_id', user.id) + .eq('source', 'gmail_luma') + .order('event_start_at', { ascending: false, nullsFirst: false }); + + if (error) throw error; + setEvents(data || []); + } catch (err) { + console.error('Failed to load events:', err); + setErrorMessage('Failed to load imported events'); + } finally { + setLoading(false); + } + }, [user]); + + useEffect(() => { + loadEvents(); + }, [loadEvents]); + + const handleDisconnect = async (deleteData: boolean) => { + setDisconnecting(true); + setShowDeleteConfirm(false); + + try { + const res = await fetch('/api/gmail/disconnect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + }, + body: JSON.stringify({ deleteData }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Disconnect failed'); + } + + // Reload page to reset state + window.location.href = '/import'; + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : 'Failed to disconnect' + ); + setDisconnecting(false); + } + }; + + return ( +
+ {/* Connection status */} +
+
+
+
+ +
+
+

Gmail Connected

+

{connectedEmail}

+
+
+ +
+ + +
+
+
+ + {/* Disconnect confirmation dialog */} + {showDeleteConfirm && ( +
+
+ +
+

+ Disconnect Gmail? +

+

+ Choose whether to keep or delete your imported event data. +

+
+ + + +
+
+
+
+ )} + + {/* Error */} + {errorMessage && ( +
+

{errorMessage}

+
+ )} + + {/* Event list */} +
+

+ Imported Events{' '} + + ({events.length}) + +

+
+ + {loading ? ( +
+ +
+ ) : events.length === 0 ? ( +
+

No imported events yet.

+ +
+ ) : ( +
+ {events.map((event) => ( +
+
+
+

+ {event.event_name || 'Untitled Event'} +

+
+ {event.event_start_at && ( + + + {formatDate(event.event_start_at)} + + )} + {event.location_raw && ( + + + {event.location_raw} + + )} + {event.event_url && ( + + + View on Luma + + )} +
+
+ + {event.status && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + approved: 'bg-green-500/10 text-green-400 border-green-500/20', + rsvp: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + waitlist: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + unknown: 'bg-stone-800 text-stone-500 border-stone-700', + }; + + return ( + + {status} + + ); +} + +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} diff --git a/src/components/LumaImportReview.tsx b/src/components/LumaImportReview.tsx new file mode 100644 index 00000000..e7b8a6da --- /dev/null +++ b/src/components/LumaImportReview.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { + CheckSquare, + Square, + MapPin, + Calendar, + Link2, + ArrowRight, + X, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { CONFIDENCE_THRESHOLDS } from '@/lib/gmail/constants'; +import type { DeduplicatedEvent } from '@/lib/gmail/types'; + +interface LumaImportReviewProps { + events: DeduplicatedEvent[]; + syncStats: { totalMessages: number; totalCandidates: number }; + onImport: (selected: DeduplicatedEvent[]) => void; + onCancel: () => void; +} + +export function LumaImportReview({ + events, + syncStats, + onImport, + onCancel, +}: LumaImportReviewProps) { + // Auto-select high and medium confidence events + const [selected, setSelected] = useState>(() => { + const initial = new Set(); + for (const event of events) { + if (event.parseConfidence >= CONFIDENCE_THRESHOLDS.MEDIUM) { + initial.add(event.externalEventKey); + } + } + return initial; + }); + + const [expandedEvent, setExpandedEvent] = useState(null); + + const selectedCount = selected.size; + const allSelected = selectedCount === events.length; + + const toggleEvent = (key: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const toggleAll = () => { + if (allSelected) { + setSelected(new Set()); + } else { + setSelected(new Set(events.map((e) => e.externalEventKey))); + } + }; + + const handleImport = () => { + const selectedEvents = events.filter((e) => + selected.has(e.externalEventKey) + ); + onImport(selectedEvents); + }; + + const { high, medium, low } = useMemo(() => { + let h = 0, m = 0, l = 0; + for (const e of events) { + if (e.parseConfidence >= CONFIDENCE_THRESHOLDS.HIGH) h++; + else if (e.parseConfidence >= CONFIDENCE_THRESHOLDS.MEDIUM) m++; + else l++; + } + return { high: h, medium: m, low: l }; + }, [events]); + + if (events.length === 0) { + return ( +
+

No Luma events found

+

+ We scanned {syncStats.totalMessages} email{syncStats.totalMessages !== 1 ? 's' : ''} but + couldn't extract any event data. This might mean you don't have Luma + event emails in this Gmail account. +

+ +
+ ); + } + + return ( +
+ {/* Summary bar */} +
+
+
+

+ Found {events.length} event{events.length !== 1 ? 's' : ''} from{' '} + {syncStats.totalMessages} email{syncStats.totalMessages !== 1 ? 's' : ''} +

+
+ {high > 0 && ( + + {high} high confidence + + )} + {medium > 0 && ( + + {medium} medium + + )} + {low > 0 && ( + {low} low + )} +
+
+ +
+ + | + + {selectedCount} selected + +
+
+
+ + {/* Event list */} +
+ {events.map((event) => ( + toggleEvent(event.externalEventKey)} + onExpand={() => + setExpandedEvent( + expandedEvent === event.externalEventKey + ? null + : event.externalEventKey + ) + } + /> + ))} +
+ + {/* Actions */} +
+ + +
+
+ ); +} + +function EventRow({ + event, + isSelected, + isExpanded, + onToggle, + onExpand, +}: { + event: DeduplicatedEvent; + isSelected: boolean; + isExpanded: boolean; + onToggle: () => void; + onExpand: () => void; +}) { + const confidenceLabel = getConfidenceLabel(event.parseConfidence); + const formattedDate = event.eventStartAt + ? formatEventDate(event.eventStartAt) + : null; + + return ( +
+
+ {/* Checkbox */} + + + {/* Event info */} +
+
+

+ {event.eventName} +

+ +
+ +
+ {formattedDate && ( + + + {formattedDate} + + )} + {event.locationRaw && ( + + + {event.locationRaw} + + )} + {event.eventUrl && ( + + + lu.ma + + )} +
+ + {/* Source summary */} +
+ + {event.sources.length} email{event.sources.length !== 1 ? 's' : ''} + + + {event.messageTypes.map((type) => ( + + {type.replace(/_/g, ' ')} + + ))} +
+
+ + {/* Expand button */} + +
+ + {/* Expanded details */} + {isExpanded && ( +
+

Source Emails

+
+ {event.sources.map((source, i) => ( +
+ + {source.receivedAt + ? new Date(source.receivedAt).toLocaleDateString() + : 'Unknown'} + + {source.subject} + {source.hadIcs && ( + .ics + )} +
+ ))} +
+
+ )} +
+ ); +} + +function ConfidenceBadge({ + confidence, + label, +}: { + confidence: number; + label: string; +}) { + const colors = + confidence >= CONFIDENCE_THRESHOLDS.HIGH + ? 'bg-green-500/10 text-green-400 border-green-500/20' + : confidence >= CONFIDENCE_THRESHOLDS.MEDIUM + ? 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' + : 'bg-red-500/10 text-red-400 border-red-500/20'; + + return ( + + {label} + + ); +} + +function StatusBadge({ status }: { status: string }) { + const colors: Record = { + approved: 'text-green-400', + rsvp: 'text-blue-400', + waitlist: 'text-yellow-400', + unknown: 'text-stone-500', + }; + + return ( + + {status} + + ); +} + +function getConfidenceLabel(confidence: number): string { + if (confidence >= CONFIDENCE_THRESHOLDS.HIGH) return 'High'; + if (confidence >= CONFIDENCE_THRESHOLDS.MEDIUM) return 'Medium'; + return 'Low'; +} + +function formatEventDate(dateStr: string): string { + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} diff --git a/src/lib/gmail/auth.ts b/src/lib/gmail/auth.ts new file mode 100644 index 00000000..c7f8eb1e --- /dev/null +++ b/src/lib/gmail/auth.ts @@ -0,0 +1,163 @@ +import { google } from 'googleapis'; +import { createClient } from '@supabase/supabase-js'; +import { encrypt, decrypt } from './crypto'; +import { GOOGLE_SCOPES } from './constants'; + +function getOAuth2Client() { + return new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI + ); +} + +function getSupabase() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); +} + +/** + * Generate the Google OAuth consent URL. + * The state parameter carries the user ID so the callback can associate tokens. + */ +export function getGoogleAuthUrl(userId: string): string { + const client = getOAuth2Client(); + return client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: GOOGLE_SCOPES, + state: userId, + }); +} + +/** + * Exchange the authorization code for tokens and store them encrypted in Supabase. + */ +export async function handleGoogleCallback( + code: string, + userId: string +): Promise<{ email: string }> { + const client = getOAuth2Client(); + const { tokens } = await client.getToken(code); + + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error('Missing access or refresh token from Google'); + } + + // Get the user's Google email + client.setCredentials(tokens); + const oauth2 = google.oauth2({ version: 'v2', auth: client }); + const userInfo = await oauth2.userinfo.get(); + const email = userInfo.data.email || 'unknown'; + + const supabase = getSupabase(); + + // Deactivate any existing connections for this user + await supabase + .from('gmail_connections') + .update({ status: 'disconnected' }) + .eq('user_id', userId) + .eq('status', 'active'); + + // Insert new connection + const { error } = await supabase.from('gmail_connections').insert({ + user_id: userId, + google_account_email: email, + access_token_encrypted: encrypt(tokens.access_token), + refresh_token_encrypted: encrypt(tokens.refresh_token), + scope: tokens.scope || GOOGLE_SCOPES.join(' '), + status: 'active', + }); + + if (error) throw new Error(`Failed to save Gmail connection: ${error.message}`); + + return { email }; +} + +/** + * Get a valid access token for the user, refreshing if needed. + */ +export async function getValidAccessToken(userId: string): Promise { + const supabase = getSupabase(); + + const { data: connection, error } = await supabase + .from('gmail_connections') + .select('*') + .eq('user_id', userId) + .eq('status', 'active') + .single(); + + if (error || !connection) { + throw new Error('No active Gmail connection found'); + } + + const refreshToken = decrypt(connection.refresh_token_encrypted); + + const client = getOAuth2Client(); + client.setCredentials({ refresh_token: refreshToken }); + + // Force a token refresh to always get a valid access token + const { credentials } = await client.refreshAccessToken(); + + if (!credentials.access_token) { + throw new Error('Failed to refresh Google access token'); + } + + // Update the stored access token + await supabase + .from('gmail_connections') + .update({ + access_token_encrypted: encrypt(credentials.access_token), + }) + .eq('id', connection.id); + + return credentials.access_token; +} + +/** + * Disconnect Gmail: revoke token and mark connection as disconnected. + */ +export async function disconnectGmail(userId: string): Promise { + const supabase = getSupabase(); + + const { data: connection } = await supabase + .from('gmail_connections') + .select('*') + .eq('user_id', userId) + .eq('status', 'active') + .single(); + + if (connection) { + // Try to revoke the token with Google + try { + const accessToken = decrypt(connection.access_token_encrypted); + const client = getOAuth2Client(); + await client.revokeToken(accessToken); + } catch { + // Token may already be invalid β€” continue with disconnect + } + + await supabase + .from('gmail_connections') + .update({ status: 'disconnected' }) + .eq('id', connection.id); + } +} + +/** + * Check if a user has an active Gmail connection. + */ +export async function getGmailConnection(userId: string) { + const supabase = getSupabase(); + + const { data } = await supabase + .from('gmail_connections') + .select('id, google_account_email, connected_at, last_sync_at, status') + .eq('user_id', userId) + .eq('status', 'active') + .single(); + + return data; +} diff --git a/src/lib/gmail/constants.ts b/src/lib/gmail/constants.ts new file mode 100644 index 00000000..7d4b8afd --- /dev/null +++ b/src/lib/gmail/constants.ts @@ -0,0 +1,50 @@ +/** Known Luma email senders */ +export const LUMA_SENDERS = [ + 'notifications@luma.email', + 'hi@lu.ma', + 'hello@lu.ma', + 'noreply@lu.ma', +]; + +/** Known Luma email domains */ +export const LUMA_DOMAINS = ['luma.email', 'lu.ma']; + +/** + * Gmail search queries to find Luma-related emails. + * Each query is run separately; results are merged and deduplicated by message ID. + */ +export const GMAIL_SEARCH_QUERIES = [ + 'from:(notifications@luma.email OR hi@lu.ma OR hello@lu.ma OR noreply@lu.ma)', + '("lu.ma" OR "luma") AND ("RSVP" OR "approved" OR "confirmed" OR "invited")', +]; + +/** OAuth scopes requested from Google */ +export const GOOGLE_SCOPES = [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/userinfo.email', +]; + +/** Maximum number of Gmail messages to fetch per sync */ +export const MAX_MESSAGES_PER_SYNC = 200; + +/** Confidence scoring weights */ +export const CONFIDENCE_WEIGHTS = { + KNOWN_SENDER: 0.35, + ICS_PARSED: 0.25, + EVENT_NAME_FOUND: 0.20, + DATE_FOUND: 0.10, + LOCATION_FOUND: 0.10, +} as const; + +/** Confidence scoring penalties */ +export const CONFIDENCE_PENALTIES = { + UNKNOWN_SENDER: -0.20, + WEAK_REGEX_ONLY: -0.15, + MISSING_TITLE: -0.15, +} as const; + +/** Confidence thresholds for UI display */ +export const CONFIDENCE_THRESHOLDS = { + HIGH: 0.7, + MEDIUM: 0.4, +} as const; diff --git a/src/lib/gmail/crypto.ts b/src/lib/gmail/crypto.ts new file mode 100644 index 00000000..79365cdd --- /dev/null +++ b/src/lib/gmail/crypto.ts @@ -0,0 +1,46 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; + +function getEncryptionKey(): Buffer { + const key = process.env.GMAIL_ENCRYPTION_KEY; + if (!key) throw new Error('GMAIL_ENCRYPTION_KEY env var is required'); + // Derive a 32-byte key from the env var using SHA-256 + return crypto.createHash('sha256').update(key).digest(); +} + +/** Encrypt a plaintext string. Returns base64-encoded iv:tag:ciphertext */ +export function encrypt(plaintext: string): string { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(plaintext, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + const tag = cipher.getAuthTag(); + + // Format: base64(iv):base64(tag):base64(ciphertext) + return `${iv.toString('base64')}:${tag.toString('base64')}:${encrypted}`; +} + +/** Decrypt a string produced by encrypt() */ +export function decrypt(encrypted: string): string { + const key = getEncryptionKey(); + const parts = encrypted.split(':'); + if (parts.length !== 3) throw new Error('Invalid encrypted format'); + + const iv = Buffer.from(parts[0], 'base64'); + const tag = Buffer.from(parts[1], 'base64'); + const ciphertext = parts[2]; + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/src/lib/gmail/dedup.ts b/src/lib/gmail/dedup.ts new file mode 100644 index 00000000..06dd35c3 --- /dev/null +++ b/src/lib/gmail/dedup.ts @@ -0,0 +1,120 @@ +import type { CandidateEvent, DeduplicatedEvent } from './types'; + +/** + * Deduplicate candidate events from multiple emails into canonical records. + * Groups by external_event_key and merges fields using best-available data. + */ +export function deduplicateEvents( + candidates: CandidateEvent[] +): DeduplicatedEvent[] { + const groups = new Map(); + + for (const candidate of candidates) { + const key = candidate.externalEventKey; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(candidate); + } + + const deduped: DeduplicatedEvent[] = []; + + for (const [key, group] of groups) { + // Also check for fuzzy matches within the remaining ungrouped events + // For now we rely on the key-based grouping which covers URL, ICS UID, and name+date + + const merged = mergeGroup(key, group); + deduped.push(merged); + } + + // Sort by event date (newest first), then by confidence + deduped.sort((a, b) => { + if (a.eventStartAt && b.eventStartAt) { + return new Date(b.eventStartAt).getTime() - new Date(a.eventStartAt).getTime(); + } + if (a.eventStartAt) return -1; + if (b.eventStartAt) return 1; + return b.parseConfidence - a.parseConfidence; + }); + + return deduped; +} + +/** Merge a group of candidates for the same event into one canonical record */ +function mergeGroup( + key: string, + group: CandidateEvent[] +): DeduplicatedEvent { + // Sort by confidence descending so we prefer the best data + const sorted = [...group].sort((a, b) => b.parseConfidence - a.parseConfidence); + + // Best event name: prefer the longest non-empty name (likely most complete) + const eventName = sorted + .map((c) => c.eventName) + .filter(Boolean) + .sort((a, b) => b.length - a.length)[0] || sorted[0].eventName; + + // Best dates: prefer ICS-derived dates (calendar_invite type) + const withDates = sorted.filter((c) => c.eventStartAt); + const icsCandidate = withDates.find((c) => c.messageType === 'calendar_invite'); + const bestDateCandidate = icsCandidate || withDates[0] || sorted[0]; + + // Best location: prefer the longest location string (most detailed) + const locationRaw = sorted + .map((c) => c.locationRaw) + .filter((l): l is string => !!l) + .sort((a, b) => b.length - a.length)[0] || null; + + // Best event URL + const eventUrl = sorted.find((c) => c.eventUrl)?.eventUrl || null; + + // Best status: priority order: approved > rsvp > waitlist > unknown + const statusPriority: Record = { + approved: 3, + rsvp: 2, + waitlist: 1, + unknown: 0, + }; + const status = sorted.reduce((best, c) => { + return (statusPriority[c.status] || 0) > (statusPriority[best] || 0) + ? c.status + : best; + }, sorted[0].status); + + // Highest confidence + const parseConfidence = Math.max(...sorted.map((c) => c.parseConfidence)); + + // Date range + const receivedDates = sorted + .map((c) => c.source.receivedAt) + .filter(Boolean) + .map((d) => new Date(d).getTime()) + .filter((t) => !isNaN(t)); + + const firstSeenAt = receivedDates.length + ? new Date(Math.min(...receivedDates)).toISOString() + : new Date().toISOString(); + + const lastSeenAt = receivedDates.length + ? new Date(Math.max(...receivedDates)).toISOString() + : new Date().toISOString(); + + // Collect all source references + const sources = sorted.map((c) => c.source); + const messageTypes = [...new Set(sorted.map((c) => c.messageType))]; + + return { + externalEventKey: key, + eventName, + eventStartAt: bestDateCandidate.eventStartAt, + eventEndAt: bestDateCandidate.eventEndAt, + locationRaw, + eventUrl, + status, + parseConfidence, + firstSeenAt, + lastSeenAt, + sources, + messageTypes, + }; +} diff --git a/src/lib/gmail/fetch.ts b/src/lib/gmail/fetch.ts new file mode 100644 index 00000000..2308e824 --- /dev/null +++ b/src/lib/gmail/fetch.ts @@ -0,0 +1,155 @@ +import { google } from 'googleapis'; +import { GMAIL_SEARCH_QUERIES, MAX_MESSAGES_PER_SYNC } from './constants'; +import type { GmailMessage, GmailAttachment } from './types'; + +/** + * Fetch Luma-related messages from Gmail using the provided access token. + * Runs multiple search queries, deduplicates by message ID, and fetches full content. + */ +export async function fetchLumaMessages( + accessToken: string +): Promise { + const auth = new google.auth.OAuth2(); + auth.setCredentials({ access_token: accessToken }); + const gmail = google.gmail({ version: 'v1', auth }); + + // Collect unique message IDs from all queries + const messageIds = new Set(); + + for (const query of GMAIL_SEARCH_QUERIES) { + let pageToken: string | undefined; + let fetched = 0; + + do { + const res = await gmail.users.messages.list({ + userId: 'me', + q: query, + maxResults: 100, + pageToken, + }); + + const messages = res.data.messages || []; + for (const msg of messages) { + if (msg.id) messageIds.add(msg.id); + } + + fetched += messages.length; + pageToken = res.data.nextPageToken || undefined; + } while (pageToken && fetched < MAX_MESSAGES_PER_SYNC); + } + + // Fetch full message content for each unique ID + const fullMessages: GmailMessage[] = []; + + // Batch in groups of 20 to avoid rate limits + const ids = Array.from(messageIds).slice(0, MAX_MESSAGES_PER_SYNC); + const batchSize = 20; + + for (let i = 0; i < ids.length; i += batchSize) { + const batch = ids.slice(i, i + batchSize); + const results = await Promise.all( + batch.map((id) => + gmail.users.messages + .get({ + userId: 'me', + id, + format: 'full', + }) + .then((res) => res.data) + .catch(() => null) + ) + ); + + for (const msg of results) { + if (!msg || !msg.id) continue; + + const parsed = parseGmailMessage(msg); + if (parsed) fullMessages.push(parsed); + } + } + + return fullMessages; +} + +/** Extract headers, body, and attachments from a Gmail API message */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseGmailMessage(msg: any): GmailMessage | null { + const headers = msg.payload?.headers || []; + const getHeader = (name: string): string => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const h = headers.find((h: any) => h.name?.toLowerCase() === name.toLowerCase()); + return h?.value || ''; + }; + + const from = getHeader('From'); + const subject = getHeader('Subject'); + const date = getHeader('Date'); + + let bodyText = ''; + let bodyHtml = ''; + const attachments: GmailAttachment[] = []; + + // Recursively walk the MIME parts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function walkParts(parts: any[]) { + for (const part of parts) { + const mimeType = part.mimeType || ''; + + if (mimeType === 'text/plain' && part.body?.data) { + bodyText += decodeBase64Url(part.body.data); + } else if (mimeType === 'text/html' && part.body?.data) { + bodyHtml += decodeBase64Url(part.body.data); + } else if ( + mimeType === 'text/calendar' || + (part.filename && part.filename.endsWith('.ics')) + ) { + // ICS attachment β€” may be inline or have data directly + if (part.body?.data) { + attachments.push({ + filename: part.filename || 'invite.ics', + mimeType, + data: decodeBase64Url(part.body.data), + }); + } + } + + if (part.parts) { + walkParts(part.parts); + } + } + } + + if (msg.payload) { + // Single-part message + if (msg.payload.body?.data) { + const mimeType = msg.payload.mimeType || ''; + if (mimeType === 'text/plain') { + bodyText = decodeBase64Url(msg.payload.body.data); + } else if (mimeType === 'text/html') { + bodyHtml = decodeBase64Url(msg.payload.body.data); + } + } + + // Multi-part message + if (msg.payload.parts) { + walkParts(msg.payload.parts); + } + } + + return { + id: msg.id!, + threadId: msg.threadId || msg.id!, + from, + subject, + date, + bodyText, + bodyHtml, + attachments, + }; +} + +/** Decode Gmail's URL-safe base64 encoding */ +function decodeBase64Url(encoded: string): string { + const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + return Buffer.from(base64, 'base64').toString('utf-8'); +} diff --git a/src/lib/gmail/ics-parser.ts b/src/lib/gmail/ics-parser.ts new file mode 100644 index 00000000..b5c9d8b7 --- /dev/null +++ b/src/lib/gmail/ics-parser.ts @@ -0,0 +1,104 @@ +import type { ICSEvent } from './types'; + +/** + * Parse an ICS (iCalendar) file content and extract event fields. + * Handles line folding (RFC 5545 Section 3.1) and escaped characters. + */ +export function parseICS(icsContent: string): ICSEvent | null { + if (!icsContent) return null; + + // Unfold lines: lines starting with a space or tab are continuations + const unfolded = icsContent.replace(/\r?\n[ \t]/g, ''); + const lines = unfolded.split(/\r?\n/); + + let inEvent = false; + const fields: Record = {}; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === 'BEGIN:VEVENT') { + inEvent = true; + continue; + } + if (trimmed === 'END:VEVENT') { + break; // Only parse the first VEVENT + } + + if (!inEvent) continue; + + // Parse property: handle both PROP:VALUE and PROP;PARAMS:VALUE + const colonIdx = trimmed.indexOf(':'); + if (colonIdx === -1) continue; + + const propPart = trimmed.substring(0, colonIdx); + const value = trimmed.substring(colonIdx + 1); + + // Get the property name (strip parameters like DTSTART;TZID=...) + const semicolonIdx = propPart.indexOf(';'); + const propName = (semicolonIdx === -1 ? propPart : propPart.substring(0, semicolonIdx)).toUpperCase(); + + // Unescape ICS values + fields[propName] = unescapeICS(value); + } + + if (!fields.SUMMARY && !fields.DTSTART && !fields.LOCATION) { + return null; // Not enough data + } + + return { + uid: fields.UID || '', + summary: fields.SUMMARY || '', + description: fields.DESCRIPTION || '', + location: fields.LOCATION || '', + dtstart: parseICSDate(fields.DTSTART || ''), + dtend: parseICSDate(fields.DTEND || ''), + url: fields.URL || '', + }; +} + +/** Unescape ICS escaped characters */ +function unescapeICS(value: string): string { + return value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +/** + * Parse an ICS date string into an ISO 8601 string. + * Handles formats like: + * - 20260315T190000Z (UTC) + * - 20260315T190000 (local) + * - 20260315 (date only) + */ +function parseICSDate(value: string): string { + if (!value) return ''; + + // Remove any trailing Z for parsing, we'll add it back + const isUTC = value.endsWith('Z'); + const clean = value.replace('Z', '').trim(); + + if (clean.length === 8) { + // Date only: YYYYMMDD + const year = clean.substring(0, 4); + const month = clean.substring(4, 6); + const day = clean.substring(6, 8); + return `${year}-${month}-${day}`; + } + + if (clean.length >= 15) { + // DateTime: YYYYMMDDTHHMMSS + const year = clean.substring(0, 4); + const month = clean.substring(4, 6); + const day = clean.substring(6, 8); + const hour = clean.substring(9, 11); + const minute = clean.substring(11, 13); + const second = clean.substring(13, 15); + return `${year}-${month}-${day}T${hour}:${minute}:${second}${isUTC ? 'Z' : ''}`; + } + + // Fallback: return as-is + return value; +} diff --git a/src/lib/gmail/index.ts b/src/lib/gmail/index.ts new file mode 100644 index 00000000..60db08b3 --- /dev/null +++ b/src/lib/gmail/index.ts @@ -0,0 +1,9 @@ +export { getGoogleAuthUrl, handleGoogleCallback, getValidAccessToken, disconnectGmail, getGmailConnection } from './auth'; +export { fetchLumaMessages } from './fetch'; +export { normalizeEmailBody } from './normalize'; +export { parseICS } from './ics-parser'; +export { extractLumaEvent } from './luma-extractor'; +export { deduplicateEvents } from './dedup'; +export { encrypt, decrypt } from './crypto'; +export * from './constants'; +export * from './types'; diff --git a/src/lib/gmail/luma-extractor.ts b/src/lib/gmail/luma-extractor.ts new file mode 100644 index 00000000..e2efc597 --- /dev/null +++ b/src/lib/gmail/luma-extractor.ts @@ -0,0 +1,331 @@ +import { normalizeEmailBody } from './normalize'; +import { parseICS } from './ics-parser'; +import { + LUMA_SENDERS, + LUMA_DOMAINS, + CONFIDENCE_WEIGHTS, + CONFIDENCE_PENALTIES, +} from './constants'; +import type { + GmailMessage, + CandidateEvent, + LumaMessageType, + ICSEvent, +} from './types'; + +/** + * Extract a candidate Luma event from a Gmail message. + * Returns null if the message doesn't appear to contain event data. + */ +export function extractLumaEvent(message: GmailMessage): CandidateEvent | null { + const normalizedBody = message.bodyHtml + ? normalizeEmailBody(message.bodyHtml) + : message.bodyText; + + const combinedText = `${message.subject}\n${normalizedBody}`; + + // Parse ICS attachments + let icsEvent: ICSEvent | null = null; + for (const attachment of message.attachments) { + if ( + attachment.mimeType === 'text/calendar' || + attachment.filename.endsWith('.ics') + ) { + icsEvent = parseICS(attachment.data); + if (icsEvent) break; + } + } + + // Classify message type + const messageType = classifyMessageType(message.subject, normalizedBody, !!icsEvent); + + // Extract event name (priority: ICS > structured header > subject) + const eventName = extractEventName(icsEvent, message.subject, normalizedBody); + if (!eventName) return null; + + // Extract dates + const { startAt, endAt } = extractDates(icsEvent, normalizedBody); + + // Extract location + const locationRaw = extractLocation(icsEvent, normalizedBody); + + // Extract Luma event URL + const eventUrl = extractLumaUrl(combinedText); + + // Determine RSVP status + const status = inferStatus(messageType, normalizedBody); + + // Generate a dedup key + const externalEventKey = generateEventKey(eventUrl, icsEvent, eventName, startAt); + + // Compute confidence score + const parseConfidence = computeConfidence(message.from, icsEvent, eventName, startAt, locationRaw); + + return { + externalEventKey, + eventName, + eventStartAt: startAt, + eventEndAt: endAt, + locationRaw, + eventUrl, + status, + parseConfidence, + messageType, + source: { + gmailMessageId: message.id, + gmailThreadId: message.threadId, + sender: message.from, + subject: message.subject, + receivedAt: message.date, + hadIcs: !!icsEvent, + }, + }; +} + +/** Classify the type of Luma email */ +function classifyMessageType( + subject: string, + body: string, + hasIcs: boolean +): LumaMessageType { + const text = `${subject}\n${body}`.toLowerCase(); + + if (hasIcs) return 'calendar_invite'; + if (/cancel{1,2}ed|event cancel/i.test(text)) return 'cancellation'; + if (/waitlist|wait list|waiting list/i.test(text)) return 'waitlist'; + if (/\bapproved\b|you['']?re approved|application accepted/i.test(text)) return 'approval'; + if (/you['']?re going|you['']?re in|rsvp confirmed|registration confirmed|successfully registered/i.test(text)) return 'rsvp_confirmation'; + if (/starts? soon|tomorrow|reminder|don['']?t forget|happening/i.test(text)) return 'reminder'; + + return 'unknown'; +} + +/** Extract event name with priority: ICS SUMMARY > structured header > subject */ +function extractEventName( + icsEvent: ICSEvent | null, + subject: string, + body: string +): string | null { + // 1. ICS SUMMARY + if (icsEvent?.summary) return icsEvent.summary.trim(); + + // 2. Look for a prominent event name in the body + // Luma emails often have the event name as a standalone line near the top + const bodyLines = body.split('\n').filter((l) => l.trim()); + for (let i = 0; i < Math.min(bodyLines.length, 10); i++) { + const line = bodyLines[i].trim(); + // Skip common header/footer patterns + if ( + line.length > 10 && + line.length < 200 && + !/^(hi |hey |hello |dear |from:|to:|date:|subject:)/i.test(line) && + !/unsubscribe|privacy|terms|view in browser/i.test(line) && + !/^\d+\s*(event|ticket|attendee)/i.test(line) + ) { + // Check if it looks like a title (often preceded by confirmation text) + if (i > 0 && /rsvp|approved|confirmed|going|registered/i.test(bodyLines[i - 1])) { + return line; + } + } + } + + // 3. Extract from subject line (remove common prefixes) + if (subject) { + let cleaned = subject + .replace(/^(re:\s*|fwd:\s*|fw:\s*)/gi, '') + .replace(/^(you['']?re going to |you['']?re approved for |reminder:\s*|rsvp confirmed:\s*)/gi, '') + .replace(/\s*\|.*$/, '') // Remove " | Luma" suffix + .replace(/\s*[-–—]\s*luma$/i, '') + .trim(); + + if (cleaned) return cleaned; + } + + return null; +} + +/** Extract event dates */ +function extractDates( + icsEvent: ICSEvent | null, + body: string +): { startAt: string | null; endAt: string | null } { + // 1. ICS dates + if (icsEvent?.dtstart) { + return { + startAt: icsEvent.dtstart, + endAt: icsEvent.dtend || null, + }; + } + + // 2. Try to find date patterns in body + // Match common date formats: "March 15, 2026", "Mar 15, 2026", "2026-03-15", "15 March 2026" + const datePatterns = [ + // "March 15, 2026 at 7:00 PM" or "March 15, 2026, 7:00 PM" + /(\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4}(?:\s*(?:at|,)\s*\d{1,2}:\d{2}\s*(?:AM|PM)?)?)/i, + // "Mar 15, 2026" + /(\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s+\d{4})/i, + // "15 March 2026" + /(\b\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4})/i, + // ISO format + /(\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?)/, + ]; + + for (const pattern of datePatterns) { + const match = body.match(pattern); + if (match) { + const dateStr = match[1]; + try { + const parsed = new Date(dateStr); + if (!isNaN(parsed.getTime())) { + return { startAt: parsed.toISOString(), endAt: null }; + } + } catch { + // Skip invalid dates + } + } + } + + return { startAt: null, endAt: null }; +} + +/** Extract location from ICS or email body */ +function extractLocation( + icsEvent: ICSEvent | null, + body: string +): string | null { + // 1. ICS LOCATION + if (icsEvent?.location) return icsEvent.location.trim(); + + // 2. Look for location patterns in body + const locationPatterns = [ + // "Location: ..." or "Where: ..." or "Venue: ..." on a line + /(?:location|where|venue|address)\s*[:]\s*(.+)/i, + // "πŸ“ Address" or "πŸ“Address" + /\u{1F4CD}\s*(.+)/u, + // Google Maps link text + /(?:google\.com\/maps|maps\.google\.com)[^\s)]+/i, + ]; + + const lines = body.split('\n'); + for (const line of lines) { + for (const pattern of locationPatterns) { + const match = line.match(pattern); + if (match) { + const loc = match[1]?.trim(); + if (loc && loc.length > 3 && loc.length < 300) { + return loc; + } + } + } + } + + return null; +} + +/** Extract a Luma event URL from text */ +function extractLumaUrl(text: string): string | null { + // Match lu.ma URLs + const lumaUrlPattern = /https?:\/\/lu\.ma\/[^\s)>"',]+/i; + const match = text.match(lumaUrlPattern); + if (match) return match[0].replace(/[.,;!?]+$/, ''); // Strip trailing punctuation + + // Also check for luma.email tracking links + const lumaTrackingPattern = /https?:\/\/[^\s]*luma[^\s]*\/[^\s)>"',]+/i; + const trackingMatch = text.match(lumaTrackingPattern); + if (trackingMatch) return trackingMatch[0].replace(/[.,;!?]+$/, ''); + + return null; +} + +/** Infer RSVP status from message type and content */ +function inferStatus( + messageType: LumaMessageType, + body: string +): 'approved' | 'rsvp' | 'waitlist' | 'unknown' { + if (messageType === 'approval') return 'approved'; + if (messageType === 'rsvp_confirmation') return 'rsvp'; + if (messageType === 'waitlist') return 'waitlist'; + + const lower = body.toLowerCase(); + if (/\bapproved\b/.test(lower)) return 'approved'; + if (/\brsvp|registered|confirmed\b/.test(lower)) return 'rsvp'; + if (/\bwaitlist\b/.test(lower)) return 'waitlist'; + + return 'unknown'; +} + +/** Generate a dedup key for an event */ +function generateEventKey( + eventUrl: string | null, + icsEvent: ICSEvent | null, + eventName: string | null, + startAt: string | null +): string { + // Prefer Luma URL as the most unique identifier + if (eventUrl) { + // Normalize: strip tracking params, lowercase + const url = new URL(eventUrl); + return `luma:${url.pathname.toLowerCase()}`; + } + + // ICS UID + if (icsEvent?.uid) return `ics:${icsEvent.uid}`; + + // Fallback: name + date + const nameSlug = (eventName || 'unknown') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .substring(0, 60); + const dateSlug = startAt + ? new Date(startAt).toISOString().substring(0, 10) + : 'nodate'; + return `derived:${nameSlug}:${dateSlug}`; +} + +/** Compute confidence score for a parsed event */ +function computeConfidence( + sender: string, + icsEvent: ICSEvent | null, + eventName: string | null, + startAt: string | null, + location: string | null +): number { + let confidence = 0; + + // Known sender check + const senderLower = sender.toLowerCase(); + const isKnownSender = + LUMA_SENDERS.some((s) => senderLower.includes(s)) || + LUMA_DOMAINS.some((d) => senderLower.includes(d)); + + if (isKnownSender) { + confidence += CONFIDENCE_WEIGHTS.KNOWN_SENDER; + } else { + confidence += CONFIDENCE_PENALTIES.UNKNOWN_SENDER; + } + + // ICS parsed + if (icsEvent) { + confidence += CONFIDENCE_WEIGHTS.ICS_PARSED; + } + + // Event name + if (eventName) { + confidence += CONFIDENCE_WEIGHTS.EVENT_NAME_FOUND; + } else { + confidence += CONFIDENCE_PENALTIES.MISSING_TITLE; + } + + // Date found + if (startAt) { + confidence += CONFIDENCE_WEIGHTS.DATE_FOUND; + } + + // Location found + if (location) { + confidence += CONFIDENCE_WEIGHTS.LOCATION_FOUND; + } + + // Clamp between 0 and 1 + return Math.max(0, Math.min(1, confidence)); +} diff --git a/src/lib/gmail/normalize.ts b/src/lib/gmail/normalize.ts new file mode 100644 index 00000000..cee72caf --- /dev/null +++ b/src/lib/gmail/normalize.ts @@ -0,0 +1,73 @@ +/** + * Normalize an HTML email body to clean text. + * Uses simple regex-based approach β€” no external dependency needed. + */ +export function normalizeEmailBody(html: string): string { + if (!html) return ''; + + let text = html; + + // Remove style and script blocks + text = text.replace(/]*>[\s\S]*?<\/style>/gi, ''); + text = text.replace(/]*>[\s\S]*?<\/script>/gi, ''); + + // Replace
,
,

,

with newlines + text = text.replace(//gi, '\n'); + text = text.replace(/<\/p>/gi, '\n\n'); + text = text.replace(/<\/div>/gi, '\n'); + text = text.replace(/<\/tr>/gi, '\n'); + text = text.replace(/<\/li>/gi, '\n'); + + // Extract href from links (keep the URL for location/event detection) + text = text.replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)'); + + // Strip remaining HTML tags + text = text.replace(/<[^>]+>/g, ''); + + // Decode HTML entities + text = decodeEntities(text); + + // Normalize whitespace + text = text.replace(/[ \t]+/g, ' '); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.trim(); + + return text; +} + +/** Decode common HTML entities */ +function decodeEntities(text: string): string { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', + '/': '/', + '–': '\u2013', + '—': '\u2014', + '‘': '\u2018', + '’': '\u2019', + '“': '\u201C', + '”': '\u201D', + '•': '\u2022', + '…': '\u2026', + }; + + let result = text; + for (const [entity, char] of Object.entries(entities)) { + result = result.replaceAll(entity, char); + } + + // Numeric entities + result = result.replace(/&#(\d+);/g, (_, num) => + String.fromCharCode(parseInt(num, 10)) + ); + result = result.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => + String.fromCharCode(parseInt(hex, 16)) + ); + + return result; +} diff --git a/src/lib/gmail/server-auth.ts b/src/lib/gmail/server-auth.ts new file mode 100644 index 00000000..9ee5cdd2 --- /dev/null +++ b/src/lib/gmail/server-auth.ts @@ -0,0 +1,27 @@ +import { createClient } from '@supabase/supabase-js'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Get authenticated Supabase user from a request's Authorization header. + * The frontend sends the Supabase access token as a Bearer token. + */ +export async function getAuthenticatedUser(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return { user: null, error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) }; + } + + const token = authHeader.substring(7); + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + const { data: { user }, error } = await supabase.auth.getUser(token); + + if (error || !user) { + return { user: null, error: NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }) }; + } + + return { user, error: null }; +} diff --git a/src/lib/gmail/types.ts b/src/lib/gmail/types.ts new file mode 100644 index 00000000..53283e59 --- /dev/null +++ b/src/lib/gmail/types.ts @@ -0,0 +1,91 @@ +/** Represents a normalized Gmail message ready for parsing */ +export interface GmailMessage { + id: string; + threadId: string; + from: string; + subject: string; + date: string; + bodyText: string; + bodyHtml: string; + attachments: GmailAttachment[]; +} + +/** A decoded attachment from a Gmail message */ +export interface GmailAttachment { + filename: string; + mimeType: string; + data: string; // decoded content +} + +/** Parsed fields from an ICS calendar attachment */ +export interface ICSEvent { + uid: string; + summary: string; + description: string; + location: string; + dtstart: string; + dtend: string; + url: string; +} + +/** Message type classification for Luma emails */ +export type LumaMessageType = + | 'rsvp_confirmation' + | 'approval' + | 'reminder' + | 'calendar_invite' + | 'waitlist' + | 'cancellation' + | 'unknown'; + +/** A candidate event extracted from one email */ +export interface CandidateEvent { + externalEventKey: string; + eventName: string; + eventStartAt: string | null; + eventEndAt: string | null; + locationRaw: string | null; + eventUrl: string | null; + status: 'approved' | 'rsvp' | 'waitlist' | 'unknown'; + parseConfidence: number; + messageType: LumaMessageType; + source: { + gmailMessageId: string; + gmailThreadId: string; + sender: string; + subject: string; + receivedAt: string; + hadIcs: boolean; + }; +} + +/** A deduplicated canonical event with merged sources */ +export interface DeduplicatedEvent { + externalEventKey: string; + eventName: string; + eventStartAt: string | null; + eventEndAt: string | null; + locationRaw: string | null; + eventUrl: string | null; + status: 'approved' | 'rsvp' | 'waitlist' | 'unknown'; + parseConfidence: number; + firstSeenAt: string; + lastSeenAt: string; + sources: CandidateEvent['source'][]; + messageTypes: LumaMessageType[]; +} + +/** Shape of an event sent from the client for import */ +export interface ImportEventPayload { + externalEventKey: string; + eventName: string; + eventStartAt: string | null; + eventEndAt: string | null; + locationRaw: string | null; + eventUrl: string | null; + status: string; + parseConfidence: number; + firstSeenAt: string; + lastSeenAt: string; + sources: CandidateEvent['source'][]; +} diff --git a/supabase/migrations/20260322120000_gmail_luma_importer.sql b/supabase/migrations/20260322120000_gmail_luma_importer.sql new file mode 100644 index 00000000..d9daf5a8 --- /dev/null +++ b/supabase/migrations/20260322120000_gmail_luma_importer.sql @@ -0,0 +1,137 @@ +-- Gmail-based Luma event importer tables +-- Stores Gmail OAuth connections, imported events, and source email references + +-- ============================================================ +-- gmail_connections: stores OAuth tokens per user +-- ============================================================ +CREATE TABLE IF NOT EXISTS gmail_connections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users NOT NULL, + google_account_email text NOT NULL, + access_token_encrypted text NOT NULL, + refresh_token_encrypted text NOT NULL, + scope text, + connected_at timestamptz DEFAULT now(), + last_sync_at timestamptz, + status text DEFAULT 'active' CHECK (status IN ('active', 'disconnected')) +); + +-- One active connection per user +CREATE UNIQUE INDEX IF NOT EXISTS idx_gmail_connections_user + ON gmail_connections (user_id) WHERE status = 'active'; + +ALTER TABLE gmail_connections ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own gmail connections" + ON gmail_connections FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own gmail connections" + ON gmail_connections FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own gmail connections" + ON gmail_connections FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own gmail connections" + ON gmail_connections FOR DELETE + USING (auth.uid() = user_id); + +-- ============================================================ +-- imported_events: canonical deduplicated event records +-- ============================================================ +CREATE TABLE IF NOT EXISTS imported_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users NOT NULL, + source text DEFAULT 'gmail_luma', + external_event_key text, + event_name text, + event_start_at timestamptz, + event_end_at timestamptz, + location_raw text, + location_normalized text, + event_url text, + status text CHECK (status IN ('approved', 'rsvp', 'waitlist', 'unknown')), + parse_confidence float, + first_seen_at timestamptz, + last_seen_at timestamptz, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (user_id, external_event_key) +); + +CREATE INDEX IF NOT EXISTS idx_imported_events_user + ON imported_events (user_id); + +ALTER TABLE imported_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own imported events" + ON imported_events FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own imported events" + ON imported_events FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own imported events" + ON imported_events FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own imported events" + ON imported_events FOR DELETE + USING (auth.uid() = user_id); + +-- ============================================================ +-- imported_event_sources: email provenance per event +-- ============================================================ +CREATE TABLE IF NOT EXISTS imported_event_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imported_event_id uuid REFERENCES imported_events ON DELETE CASCADE, + gmail_message_id text, + gmail_thread_id text, + message_type text CHECK (message_type IN ( + 'rsvp_confirmation', 'approval', 'reminder', + 'calendar_invite', 'waitlist', 'cancellation', 'unknown' + )), + sender text, + subject text, + received_at timestamptz, + had_ics boolean DEFAULT false, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_imported_event_sources_event + ON imported_event_sources (imported_event_id); + +ALTER TABLE imported_event_sources ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own imported event sources" + ON imported_event_sources FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM imported_events + WHERE imported_events.id = imported_event_sources.imported_event_id + AND imported_events.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can insert own imported event sources" + ON imported_event_sources FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM imported_events + WHERE imported_events.id = imported_event_sources.imported_event_id + AND imported_events.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can delete own imported event sources" + ON imported_event_sources FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM imported_events + WHERE imported_events.id = imported_event_sources.imported_event_id + AND imported_events.user_id = auth.uid() + ) + );