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() + ) + );