diff --git a/package-lock.json b/package-lock.json index 30c1c28..92fd8a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "almeira-web", "version": "0.1.0", "dependencies": { + "@google/genai": "^2.18.0", "@livekit/components-react": "^2.9.21", "@livekit/components-styles": "^1.2.0", "@tailwindcss/typography": "^0.5.20", @@ -751,6 +752,30 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@google/genai": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.18.0.tgz", + "integrity": "sha512-uy9gWVTAZXuA/2tld0QJl/QNiGEn4QOmfX4PiRgZQmeFQRQhojpD/Gf41SPnBJmjEnT83a+h4AdU1HHYaYvVDw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1699,6 +1724,63 @@ "node": ">=12.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -2603,7 +2685,6 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2627,6 +2708,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3671,6 +3758,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.10.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", @@ -3765,6 +3872,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/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4144,6 +4257,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-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -4454,6 +4576,15 @@ "node": ">= 0.4" } }, + "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.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", @@ -5142,6 +5273,12 @@ "node": ">=0.8.x" } }, + "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/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5212,6 +5349,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", @@ -5328,6 +5488,18 @@ "node": ">= 6" } }, + "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/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -5382,6 +5554,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "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", @@ -5523,6 +5723,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "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/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6450,6 +6676,27 @@ "setimmediate": "^1.0.5" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6897,6 +7144,12 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7244,6 +7497,26 @@ "node": ">=18.17" } }, + "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-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -7263,6 +7536,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -7479,6 +7770,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7943,6 +8247,29 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -8146,6 +8473,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9135,7 +9471,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -9313,6 +9648,15 @@ "node": ">=18" } }, + "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/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -9486,6 +9830,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index fa2ee6c..a8988d3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "check-config": "node --env-file=.env.local check_config.js" }, "dependencies": { + "@google/genai": "^2.18.0", "@livekit/components-react": "^2.9.21", "@livekit/components-styles": "^1.2.0", "@tailwindcss/typography": "^0.5.20", diff --git a/src/app/actions/entities.ts b/src/app/actions/entities.ts index d8545a4..0bf36e1 100644 --- a/src/app/actions/entities.ts +++ b/src/app/actions/entities.ts @@ -125,7 +125,12 @@ export async function saveEntityAction(entity: string, formData: FormData) { const res = await createDocumentServer(collectionId, data); if (res.success) { revalidatePath(`/dashboard/${entity}`); - redirect(`/dashboard/${entity}`); + const returnUrl = formData.get('returnUrl') as string; + if (returnUrl && returnUrl.startsWith('/dashboard/')) { + redirect(returnUrl); + } else { + redirect(`/dashboard/${entity}`); + } } else { throw new Error(res.error || 'Error desconocido al crear documento'); } @@ -153,7 +158,12 @@ export async function updateEntityAction(entity: string, id: string, formData: F const res = await updateDocumentServer(collectionId, id, data); if (res.success) { revalidatePath(`/dashboard/${entity}`); - redirect(`/dashboard/${entity}`); + const returnUrl = formData.get('returnUrl') as string; + if (returnUrl && returnUrl.startsWith('/dashboard/')) { + redirect(returnUrl); + } else { + redirect(`/dashboard/${entity}`); + } } else { throw new Error(res.error || 'Error desconocido al actualizar documento'); } diff --git a/src/app/api/reports/posts/route.tsx b/src/app/api/reports/posts/route.tsx index 79fac8b..6ceb1cc 100644 --- a/src/app/api/reports/posts/route.tsx +++ b/src/app/api/reports/posts/route.tsx @@ -20,6 +20,15 @@ export const runtime = 'nodejs'; export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); + + let hostUrl = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'almeiraproducciones.com'; + // Handle cases where x-forwarded-host might have multiple hosts separated by commas + if (hostUrl.includes(',')) { + hostUrl = hostUrl.split(',')[0].trim(); + } + const protocol = request.headers.get('x-forwarded-proto') || (hostUrl.includes('localhost') ? 'http' : 'https'); + const customOrigin = `${protocol}://${hostUrl}`; + const q = searchParams.get('q') || ''; const sort = searchParams.get('sort') || 'fecha_publicacion'; const dir = searchParams.get('dir') || 'desc'; @@ -174,14 +183,15 @@ export async function GET(request: Request) { ); } + const externalLink = `${customOrigin}/${post.slug || post.$id}`; docChildren.push( new Paragraph({ children: [ new ExternalHyperlink({ children: [ - new TextRun({ text: `https://almeira.com.co/${post.slug || post.$id}`, style: "Hyperlink" }), + new TextRun({ text: externalLink, style: "Hyperlink" }), ], - link: `https://almeira.com.co/${post.slug || post.$id}`, + link: externalLink, }) ], spacing: { after: 400 } diff --git a/src/app/api/webhooks/whatsapp/route.ts b/src/app/api/webhooks/whatsapp/route.ts index 6ec7940..eba2db0 100644 --- a/src/app/api/webhooks/whatsapp/route.ts +++ b/src/app/api/webhooks/whatsapp/route.ts @@ -1,34 +1,166 @@ import { NextResponse } from 'next/server'; -import { ID, Databases } from 'node-appwrite'; +import { ID, Query } from 'node-appwrite'; +import { InputFile } from 'node-appwrite/file'; import { createAdminClient } from '@/lib/appwrite.server'; import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; +import { processWhatsAppMessage } from '@/lib/gemini'; +import { sendWhatsAppMessage, getBase64FromMediaMessage } from '@/lib/evolution'; + +// Grupo de publicaciones (tomado del test) o puedes dejarlo abierto a todos los grupos +const TARGET_GROUP_JID = '573134848203-1408546044@g.us'; + +// Función para obtener el último día del mes actual +function getLastDayOfMonth(): string { + const date = new Date(); + const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0); + return lastDay.toISOString().split('T')[0]; +} + +function generateSlug(titulo: string): string { + return titulo.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-').substring(0, 100); +} + +function truncateString(str: string, length: number): string { + if (!str) return ''; + return str.length > length ? str.substring(0, length - 3) + '...' : str; +} export async function POST(req: Request) { try { const payload = await req.json(); - - // Estructura típica de Evolution API - const messageData = { - sender_name: payload.data?.pushName || payload.data?.key?.remoteJid || 'Usuario WhatsApp', - phone_number: payload.data?.key?.remoteJid?.split('@')[0] || 'Desconocido', - content: payload.data?.message?.conversation || payload.data?.message?.extendedTextMessage?.text || 'Mensaje Multimedia', - timestamp: new Date().toISOString(), - status: 'pending', // Por defecto entran para moderación - platform: 'whatsapp', - avatar: payload.data?.profilePicUrl || '' - }; + const eventType = payload.event; - const { databases } = await createAdminClient(); + // Solo procesamos nuevos mensajes + if (eventType !== 'messages.upsert') { + return NextResponse.json({ success: true, message: 'Ignored event' }, { status: 200 }); + } - // NOTA: Asegúrate de que la colección 'whatsapp_messages' exista en Appwrite - await databases.createDocument( + const data = payload.data; + const remoteJid = data?.key?.remoteJid; + const fromMe = data?.key?.fromMe; + + // Ignorar mensajes enviados por el bot mismo + if (fromMe) { + return NextResponse.json({ success: true, message: 'Ignored bot message' }, { status: 200 }); + } + + // Recomendado: filtrar por el grupo específico + // if (remoteJid !== TARGET_GROUP_JID) { + // return NextResponse.json({ success: true, message: 'Ignored other chat' }, { status: 200 }); + // } + + const messageData = data?.message || {}; + + // Extraer texto del mensaje + const text = messageData.conversation || messageData.extendedTextMessage?.text || messageData.imageMessage?.caption || messageData.videoMessage?.caption || ''; + const lowerText = text.trim().toLowerCase(); + + const contextInfo = messageData.extendedTextMessage?.contextInfo; + const isReply = !!contextInfo?.stanzaId; + const quotedText = contextInfo?.quotedMessage?.conversation || contextInfo?.quotedMessage?.extendedTextMessage?.text || ''; + + const { databases, storage } = await createAdminClient(); + + // LOGIC 1: APROBAR PUBLICACIÓN + if (isReply && lowerText.includes('aprobar')) { + // Extraer ID de la referencia en el mensaje del bot + const refMatch = quotedText.match(/Ref:\s*([a-zA-Z0-9_-]+)/); + if (refMatch && refMatch[1]) { + const postId = refMatch[1]; + + try { + // Actualizar Appwrite + await databases.updateDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + APPWRITE_ALMEIRA_CONFIG.collections.posts, + postId, + { estado: 'publicado' } + ); + + await sendWhatsAppMessage(remoteJid, `✅ La publicación ha sido aprobada y publicada exitosamente.\n(ID: ${postId})`); + return NextResponse.json({ success: true, message: 'Post aprobado' }, { status: 200 }); + } catch (e: any) { + console.error("Error approving post:", e); + await sendWhatsAppMessage(remoteJid, `❌ Error al publicar: ${e.message}`); + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } + } + } + + // LOGIC 2: PROCESAR NUEVO MENSAJE PARA GEMINI + // Evitámos tratar de procesar respuestas cortas o mensajes vacíos + if (!text && !messageData.imageMessage) { + return NextResponse.json({ success: true, message: 'Ignored empty message' }, { status: 200 }); + } + + // Verificamos si tiene multimedia + let base64Media = undefined; + let mimeType = undefined; + let imageId = undefined; + + if (messageData.imageMessage || messageData.videoMessage || messageData.documentMessage) { + const media = await getBase64FromMediaMessage(data); + if (media) { + base64Media = media.base64; + mimeType = media.mimetype; + + // Subimos a Appwrite Storage + const buffer = Buffer.from(base64Media, 'base64'); + // Appwrite requiere un InputFile o similar. node-appwrite maneja InputFile.fromBuffer + const extension = mimeType.split('/')[1] || 'jpg'; + const fileName = `wa_${Date.now()}.${extension}`; + const file = InputFile.fromBuffer(buffer, fileName); + + const uploadedFile = await storage.createFile( + APPWRITE_ALMEIRA_CONFIG.mediaBucketId, + ID.unique(), + file + ); + imageId = uploadedFile.$id; + } + } + + // Llamar a Gemini + const parsed = await processWhatsAppMessage(text, base64Media, mimeType); + + // Determinar fecha de vencimiento + let fechaVencimiento = parsed.fecha_vencimiento; + if ((parsed.tipo === 'evento' || parsed.tipo === 'aviso') && !fechaVencimiento) { + fechaVencimiento = getLastDayOfMonth(); + } + + // Crear Post en Appwrite + const newPost = await databases.createDocument( APPWRITE_ALMEIRA_CONFIG.databaseId, - 'whatsapp_messages', + APPWRITE_ALMEIRA_CONFIG.collections.posts, ID.unique(), - messageData + { + titulo: truncateString(parsed.titulo, 70), + slug: generateSlug(parsed.titulo) + '-' + Date.now().toString().slice(-4), + tipo: parsed.tipo, + resumen_redes: truncateString(parsed.seo_descripcion, 155), + seo_titulo: truncateString(parsed.titulo, 60), + seo_descripcion: truncateString(parsed.seo_descripcion, 155), + contenido: parsed.contenido, + estado: 'borrador', // Se requiere aprobación + fecha_publicacion: new Date().toISOString(), + ...(fechaVencimiento && { fecha_finalizacion: new Date(fechaVencimiento).toISOString() }), + ...(imageId && { imagen_destacada_id: imageId }) + } ); - return NextResponse.json({ success: true, message: 'Mensaje recibido y guardado' }, { status: 200 }); + // Notificar al grupo de WhatsApp pidiendo aprobación + const requestText = `📰 *Publicación Procesada (Pendiente de Aprobación)*\n\n` + + `*Título:* ${parsed.titulo}\n` + + `*Tipo:* ${parsed.tipo.toUpperCase()}\n` + + `*SEO:* ${parsed.seo_descripcion}\n\n` + + `Para aprobar esta publicación, responde a este mismo mensaje con la palabra "Aprobar".\n\n` + + `(Ref: ${newPost.$id})`; + + await sendWhatsAppMessage(remoteJid, requestText); + + return NextResponse.json({ success: true, message: 'Mensaje procesado' }, { status: 200 }); + } catch (error: any) { console.error('Error in WhatsApp Webhook:', error); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); diff --git a/src/app/dashboard/[entity]/[id]/page.tsx b/src/app/dashboard/[entity]/[id]/page.tsx index 499c7a0..512de7e 100644 --- a/src/app/dashboard/[entity]/[id]/page.tsx +++ b/src/app/dashboard/[entity]/[id]/page.tsx @@ -8,9 +8,11 @@ import { redirect } from 'next/navigation'; import { EntityForm } from '@/components/dashboard/EntityForm'; import type { AuthUser, Post } from '@/types/publications'; -export default async function EditEntityPage({ params }: { params: Promise<{ entity: string, id: string }> }) { +export default async function EditEntityPage({ params, searchParams }: { params: Promise<{ entity: string, id: string }>, searchParams: Promise<{ returnUrl?: string }> }) { const { entity, id } = await params; - + const sParams = await searchParams; + const returnUrl = sParams.returnUrl || ''; + const collectionId = collectionMap[entity]; const fields = SCHEMA_MAP[entity] || []; @@ -36,29 +38,29 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent // Fetch the existing document let document: any = null; try { - document = JSON.parse(JSON.stringify(await sessionClient.databases.getDocument( - APPWRITE_ALMEIRA_CONFIG.databaseId, - collectionId, - id - ))); + document = JSON.parse(JSON.stringify(await sessionClient.databases.getDocument( + APPWRITE_ALMEIRA_CONFIG.databaseId, + collectionId, + id + ))); } catch (e) { - return ( -
-

No se encontró el registro o no tienes permisos.

-
- ); + return ( +
+

No se encontró el registro o no tienes permisos.

+
+ ); } // Fetch relationship options - const relationData: Record = {}; + const relationData: Record = {}; await Promise.all( fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => { - try { - const relKey = field.relationCollection as string; - if(!collectionMap[relKey]) return; - const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], [Query.limit(100)]); - relationData[field.name] = JSON.parse(JSON.stringify(res.documents)); - } catch(e) { console.error('Error obteniendo relaciones de', field.name, e); } + try { + const relKey = field.relationCollection as string; + if (!collectionMap[relKey]) return; + const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], [Query.limit(100)]); + relationData[field.name] = JSON.parse(JSON.stringify(res.documents)); + } catch (e) { console.error('Error obteniendo relaciones de', field.name, e); } }) ); @@ -80,20 +82,22 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent relationData={relationData} submitAction={boundUpdate} appwriteConfig={appwriteConfig} + returnUrl={returnUrl} /> ); } // ── OTHER ENTITIES: Use the generic EntityForm ──────── return ( - ); } diff --git a/src/app/dashboard/[entity]/new/page.tsx b/src/app/dashboard/[entity]/new/page.tsx index 7d24374..578bfe4 100644 --- a/src/app/dashboard/[entity]/new/page.tsx +++ b/src/app/dashboard/[entity]/new/page.tsx @@ -12,10 +12,14 @@ import type { AuthUser } from '@/types/publications'; */ export default async function NewEntityPage({ params, + searchParams, }: { params: Promise<{ entity: string }>; + searchParams: Promise<{ returnUrl?: string }>; }) { const { entity } = await params; + const sParams = await searchParams; + const returnUrl = sParams.returnUrl || ''; const collectionId = collectionMap[entity]; const fields = SCHEMA_MAP[entity] || []; @@ -33,7 +37,7 @@ export default async function NewEntityPage({ if (!user) redirect('/login'); // Pre-load all relation data server-side - const relationData: Record> = {}; + const relationData: Record> = {}; try { const client = await createAdminClient(); await Promise.all( @@ -76,6 +80,7 @@ export default async function NewEntityPage({ relationData={relationData} submitAction={boundSave} appwriteConfig={appwriteConfig} + returnUrl={returnUrl} /> ); } @@ -97,6 +102,7 @@ export default async function NewEntityPage({ relationData={relationData} submitAction={boundSave} appwriteConfig={{ endpoint, projectId }} + returnUrl={returnUrl} /> ); } diff --git a/src/components/dashboard/EntityForm.tsx b/src/components/dashboard/EntityForm.tsx index d40c975..9b94e9a 100644 --- a/src/components/dashboard/EntityForm.tsx +++ b/src/components/dashboard/EntityForm.tsx @@ -13,7 +13,7 @@ import { Client, Databases } from 'appwrite'; // Minimalistic image preview for relations with 'media' const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => { - if(!fileId) return null; + if (!fileId) return null; const url = `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${projectId}`; return (
@@ -27,303 +27,306 @@ const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => { } interface EntityFormProps { - entity: string; - entityName: string; - initialData?: any; - fields: FieldDefinition[]; - relationData: Record; - submitAction: (formData: FormData) => Promise; - appwriteConfig: { - endpoint: string; - projectId: string; - } + entity: string; + entityName: string; + initialData?: any; + fields: FieldDefinition[]; + relationData: Record; + submitAction: (formData: FormData) => Promise; + appwriteConfig: { + endpoint: string; + projectId: string; + }; + returnUrl?: string; } export const EntityForm = ({ - entity, - entityName, - initialData, - fields, - relationData, - submitAction, - appwriteConfig + entity, + entityName, + initialData, + fields, + relationData, + submitAction, + appwriteConfig, + returnUrl }: EntityFormProps) => { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || ''); - const [slug, setSlug] = useState(initialData?.slug || ''); - const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); - const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || ''); - const [editorValues, setEditorValues] = useState>({}); - const [localRelationData, setLocalRelationData] = useState(relationData); - const [activeTab, setActiveTab] = useState(''); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalEntity, setModalEntity] = useState(''); - const [activeRelationField, setActiveRelationField] = useState(''); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || ''); + const [slug, setSlug] = useState(initialData?.slug || ''); + const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); + const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || ''); + const [editorValues, setEditorValues] = useState>({}); + const [localRelationData, setLocalRelationData] = useState(relationData); + const [activeTab, setActiveTab] = useState(''); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalEntity, setModalEntity] = useState(''); + const [activeRelationField, setActiveRelationField] = useState(''); - // Initialize editor values - useEffect(() => { - const initialValues: Record = {}; - fields.forEach(f => { - if (f.type === 'textarea') { - initialValues[f.name] = initialData?.[f.name] || ''; - } - }); - setEditorValues(initialValues); - }, [fields, initialData]); - - // Update slug automatically if auto-slug is enabled - useEffect(() => { - if (isAutoSlug) { - const generated = title - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - setSlug(generated); - } - }, [title, isAutoSlug]); - - const sections = Array.from(new Set(fields.map(f => f.section || 'General'))); - - useEffect(() => { - if (sections.length > 0 && !activeTab) { - setActiveTab(sections[0]); - } - }, [sections, activeTab]); - - const refreshRelation = async (field: string, collectionId: string) => { - const client = new Client() - .setEndpoint(appwriteConfig.endpoint) - .setProject(appwriteConfig.projectId); - const databases = new Databases(client); - - try { - const res = await databases.listDocuments('almeira', collectionId, []); - setLocalRelationData(prev => ({ ...prev, [field]: res.documents })); - } catch (err) { - console.error("Error al refrescar relación:", err); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - - // Add editor values to formData - for (const [key, value] of Object.entries(editorValues)) { - formData.set(key, value); - } - - // Ensure featured image ID is set if present - if (featuredImageId) { - formData.set('logotipo', featuredImageId); - } - - startTransition(async () => { - await submitAction(formData); - }); - }; - - // Organize fields into Main Content and Sidebar Metadata - const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id']; - const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name)); - const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name)); - - const getDefValue = (field: FieldDefinition) => { - const val = initialData ? initialData[field.name] : null; - - // Set smart defaults for new items - if (!initialData) { - if (field.name === 'logotipo') { - return featuredImageId; - } if (field.name === 'fecha_publicacion' || field.type === 'date') { - // Get local ISO string for datetime-local input - const now = new Date(); - return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); - } - if (field.type === 'boolean') return 'false'; - } - - if (val === undefined || val === null) return ''; - if (field.type === 'boolean') return val ? 'true' : 'false'; - if (field.type === 'date') { - if (!val) return ''; - try { return new Date(val).toISOString().slice(0, 16); } catch(e) { return ''; } - } - if (field.type === 'relation') return typeof val === 'object' ? val.$id : val; - return val; - }; - - return ( - <> -
- {/* Header Sticky */} -
-
- - - -
-

- {initialData ? 'Editando' : 'Nuevo'}: {entityName} -

- {initialData &&

{initialData.$id}

} -
-
- -
- - -
-
- -
- {sections.map(section => ( - - ))} -
- -
-
-
- -
- {fields.filter(f => (f.section || 'General') === activeTab).map(field => { - const defaultValue = getDefValue(field); - const gridSpanMap: Record = { - 1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', - 5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', - 9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12' - }; - const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12'; - - return ( -
-
- - {field.type === 'relation' && ( - Double click to create quick + - )} -
- - {field.name === 'titulo' || field.name === 'nombre_entidad' ? ( - setTitle(e.target.value)} - placeholder={`Nombre de ${field.label}...`} - maxLength={250} - className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700" - /> - ) : field.type === 'textarea' ? ( - setEditorValues(prev => ({ ...prev, [field.name]: val }))} - /> - ) : field.type === 'relation' ? ( -
- - {field.relationCollection === 'media' && ( - - )} -
- ) : field.type === 'select' ? ( - - ) : field.type === 'date' ? ( - - ) : field.type === 'boolean' ? ( - - ) : ( - - )} -
- ); - })} -
-
-
-
- - setIsModalOpen(false)} - onSuccess={async (newId) => { - const relField = fields.find(f => f.name === activeRelationField); - if (relField?.relationCollection) { - await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]); - // Set wait to ensure state has updated options before selecting - setTimeout(() => { - const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement; - if (selectElement) { - selectElement.value = newId; - } - }, 1000); + // Initialize editor values + useEffect(() => { + const initialValues: Record = {}; + fields.forEach(f => { + if (f.type === 'textarea') { + initialValues[f.name] = initialData?.[f.name] || ''; } - }} - /> - - ); + }); + setEditorValues(initialValues); + }, [fields, initialData]); + + // Update slug automatically if auto-slug is enabled + useEffect(() => { + if (isAutoSlug) { + const generated = title + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + setSlug(generated); + } + }, [title, isAutoSlug]); + + const sections = Array.from(new Set(fields.map(f => f.section || 'General'))); + + useEffect(() => { + if (sections.length > 0 && !activeTab) { + setActiveTab(sections[0]); + } + }, [sections, activeTab]); + + const refreshRelation = async (field: string, collectionId: string) => { + const client = new Client() + .setEndpoint(appwriteConfig.endpoint) + .setProject(appwriteConfig.projectId); + const databases = new Databases(client); + + try { + const res = await databases.listDocuments('almeira', collectionId, []); + setLocalRelationData(prev => ({ ...prev, [field]: res.documents })); + } catch (err) { + console.error("Error al refrescar relación:", err); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + // Add editor values to formData + for (const [key, value] of Object.entries(editorValues)) { + formData.set(key, value); + } + + // Ensure featured image ID is set if present + if (featuredImageId) { + formData.set('logotipo', featuredImageId); + } + + startTransition(async () => { + await submitAction(formData); + }); + }; + + // Organize fields into Main Content and Sidebar Metadata + const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id']; + const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name)); + const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name)); + + const getDefValue = (field: FieldDefinition) => { + const val = initialData ? initialData[field.name] : null; + + // Set smart defaults for new items + if (!initialData) { + if (field.name === 'logotipo') { + return featuredImageId; + } if (field.name === 'fecha_publicacion' || field.type === 'date') { + // Get local ISO string for datetime-local input + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + } + if (field.type === 'boolean') return 'false'; + } + + if (val === undefined || val === null) return ''; + if (field.type === 'boolean') return val ? 'true' : 'false'; + if (field.type === 'date') { + if (!val) return ''; + try { return new Date(val).toISOString().slice(0, 16); } catch (e) { return ''; } + } + if (field.type === 'relation') return typeof val === 'object' ? val.$id : val; + return val; + }; + + return ( + <> +
+ {returnUrl && } + {/* Header Sticky */} +
+
+ + + +
+

+ {initialData ? 'Editando' : 'Nuevo'}: {entityName} +

+ {initialData &&

{initialData.$id}

} +
+
+ +
+ + +
+
+ +
+ {sections.map(section => ( + + ))} +
+ +
+
+
+ +
+ {fields.filter(f => (f.section || 'General') === activeTab).map(field => { + const defaultValue = getDefValue(field); + const gridSpanMap: Record = { + 1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', + 5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', + 9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12' + }; + const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12'; + + return ( +
+
+ + {field.type === 'relation' && ( + Double click to create quick + + )} +
+ + {field.name === 'titulo' || field.name === 'nombre_entidad' ? ( + setTitle(e.target.value)} + placeholder={`Nombre de ${field.label}...`} + maxLength={250} + className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700" + /> + ) : field.type === 'textarea' ? ( + setEditorValues(prev => ({ ...prev, [field.name]: val }))} + /> + ) : field.type === 'relation' ? ( +
+ + {field.relationCollection === 'media' && ( + + )} +
+ ) : field.type === 'select' ? ( + + ) : field.type === 'date' ? ( + + ) : field.type === 'boolean' ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+
+ + setIsModalOpen(false)} + onSuccess={async (newId) => { + const relField = fields.find(f => f.name === activeRelationField); + if (relField?.relationCollection) { + await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]); + // Set wait to ensure state has updated options before selecting + setTimeout(() => { + const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement; + if (selectElement) { + selectElement.value = newId; + } + }, 1000); + } + }} + /> + + ); }; diff --git a/src/components/publications/PublicationLayout.tsx b/src/components/publications/PublicationLayout.tsx index c2b6578..8c75429 100644 --- a/src/components/publications/PublicationLayout.tsx +++ b/src/components/publications/PublicationLayout.tsx @@ -80,12 +80,13 @@ const textareaClass = inputClass + ' min-h-[100px] resize-y'; // MAIN COMPONENT // ───────────────────────────────────────────── -export const PublicationLayout: React.FC = ({ +export const PublicationLayout: React.FC = ({ initialData, relationData, submitAction, user, appwriteConfig, + returnUrl, }) => { const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -110,18 +111,18 @@ export const PublicationLayout: React.FC = ({ useEffect(() => { if (!seoDirty.titulo && titulo) { - setSeoTitulo(titulo.substring(0, 60)); + setSeoTitulo(titulo.substring(0, 60)); } }, [titulo, seoDirty.titulo]); useEffect(() => { if (!seoDirty.descripcion) { - // Strip HTML if it's the rich text - const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || ''; - const sourceMeta = resumen_redes || richTextSnippet; - if (sourceMeta) { - setSeoDescripcion(sourceMeta.substring(0, 160)); - } + // Strip HTML if it's the rich text + const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || ''; + const sourceMeta = resumen_redes || richTextSnippet; + if (sourceMeta) { + setSeoDescripcion(sourceMeta.substring(0, 160)); + } } }, [contenido, resumen_redes, seoDirty.descripcion]); @@ -224,16 +225,17 @@ export const PublicationLayout: React.FC = ({
+ {returnUrl && } {/* 12-column grid layout: left=8cols content, right=4cols sidebar */}
- + {/* ── LEFT COLUMN (8/12) ─────────────────────── */}
{/* SECCIÓN: Datos Básicos */}
} columns={1}> - + {/* Título */} = ({ {/* Inject initial Author if it's a new post (Autocompletion UX) */} {!initialData?.$id && user?.$id && ( - + )}
@@ -326,8 +328,8 @@ export const PublicationLayout: React.FC = ({ name="seo_titulo" value={seoTitulo} onChange={(e) => { - setSeoTitulo(e.target.value); - setSeoDirty(prev => ({ ...prev, titulo: true })); + setSeoTitulo(e.target.value); + setSeoDirty(prev => ({ ...prev, titulo: true })); }} maxLength={60} placeholder="Título para buscadores..." @@ -344,8 +346,8 @@ export const PublicationLayout: React.FC = ({ name="seo_descripcion" value={seoDescripcion} onChange={(e) => { - setSeoDescripcion(e.target.value); - setSeoDirty(prev => ({ ...prev, descripcion: true })); + setSeoDescripcion(e.target.value); + setSeoDirty(prev => ({ ...prev, descripcion: true })); }} maxLength={160} placeholder="Descripción para resultados de búsqueda..." @@ -455,10 +457,10 @@ export const PublicationLayout: React.FC = ({
} columns={1}> setImagenDestacadaId(val)} - label="Imagen Destacada" - mode="field" + value={imagenDestacadaId} + onChange={(val) => setImagenDestacadaId(val)} + label="Imagen Destacada" + mode="field" /> diff --git a/src/components/publications/PublicationsList.tsx b/src/components/publications/PublicationsList.tsx index 9c6ac52..93d1c51 100644 --- a/src/components/publications/PublicationsList.tsx +++ b/src/components/publications/PublicationsList.tsx @@ -34,7 +34,7 @@ import { useToast } from './ToastProvider'; interface PublicationsListProps { initialPosts: Post[]; user: AuthUser; - relationData: Record>; + relationData: Record>; submitEditAction: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>; appwriteConfig: { endpoint: string; @@ -88,7 +88,7 @@ export const PublicationsList: React.FC = ({ page?: number; }) => { const params = new URLSearchParams(window.location.search); - + // Default page to 1 on filter changes params.set('page', (updates.page ?? 1).toString()); @@ -153,7 +153,8 @@ export const PublicationsList: React.FC = ({ // ── HANDLERS ──────────────────────────────── const handleEdit = useCallback((post: Post) => { - router.push(`/dashboard/${entity}/${post.$id}`); + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + router.push(`/dashboard/${entity}/${post.$id}?returnUrl=${returnUrl}`); }, [entity, router]); const handleDelete = useCallback((postId: string) => { @@ -296,15 +297,18 @@ export const PublicationsList: React.FC = ({ {/* New post button */} - { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + router.push(`/dashboard/${entity}/new?returnUrl=${returnUrl}`); + }} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm rounded-xl transition-all shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.4)] hover:scale-105 active:scale-95 whitespace-nowrap" > Nueva publicación - +
@@ -321,12 +325,12 @@ export const PublicationsList: React.FC = ({
{filteredAndSortedPosts.map((post) => { const enrichedPost = { ...post }; - + if (typeof enrichedPost.imagen_destacada_id === 'string' && relationData['imagen_destacada_id']) { const mediaDoc = relationData['imagen_destacada_id'].find(m => m.$id === enrichedPost.imagen_destacada_id); if (mediaDoc) enrichedPost.imagen_destacada_id = mediaDoc as any; } - + if (typeof enrichedPost.tipo_publicacion_id === 'string' && relationData['tipo_publicacion_id']) { const typeDoc = relationData['tipo_publicacion_id'].find(t => t.$id === enrichedPost.tipo_publicacion_id); if (typeDoc) enrichedPost.tipo_publicacion_id = typeDoc as any; @@ -358,14 +362,17 @@ export const PublicationsList: React.FC = ({

- { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + router.push(`/dashboard/${entity}/new?returnUrl=${returnUrl}`); + }} className="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white text-sm font-bold rounded-xl transition-all" > Crear publicación - +
)} diff --git a/src/lib/appwrite.server.ts b/src/lib/appwrite.server.ts index 01cf4b3..8164131 100644 --- a/src/lib/appwrite.server.ts +++ b/src/lib/appwrite.server.ts @@ -1,4 +1,4 @@ -import { Client, Account, Databases } from 'node-appwrite'; +import { Client, Account, Databases, Storage } from 'node-appwrite'; import { cookies } from 'next/headers'; // Robust instance-level patch for Appwrite Next.js Server Components @@ -70,7 +70,7 @@ export async function createAdminClient() { .setKey((process.env.APPWRITE_API_KEY || '').trim()); patchClientInstance(client); - + return { @@ -79,6 +79,9 @@ export async function createAdminClient() { }, get databases() { return new Databases(client); + }, + get storage() { + return new Storage(client); } }; } @@ -90,11 +93,11 @@ export async function getLoggedInUser() { // Verificación de bypass (Evitamos imprimir log repetitivo a menos que sea un error) if (sessionCookie?.value === "admin-bypass-session") { - return { + return { $id: "admin-emergency", - name: "Administrador (Emergencia)", + name: "Administrador (Emergencia)", email: "admin@almeira.com.co", - prefs: { role: 'admin' } + prefs: { role: 'admin' } }; } @@ -106,7 +109,7 @@ export async function getLoggedInUser() { if (!sessionClient) { return null; } - + return await sessionClient.account.get(); } catch (error: any) { // Next.js uses a specific error to bailout of static generation. We MUST re-throw it. @@ -114,7 +117,7 @@ export async function getLoggedInUser() { throw error; } if (error?.code !== 401) { - console.error("getLoggedInUser: Error al validar sesión:", error.message || error); + console.error("getLoggedInUser: Error al validar sesión:", error.message || error); } return null; } diff --git a/src/lib/evolution.ts b/src/lib/evolution.ts index 16189e3..8990caa 100644 --- a/src/lib/evolution.ts +++ b/src/lib/evolution.ts @@ -1,67 +1,40 @@ import axios from 'axios'; -const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || ''; -const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || ''; -const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || ''; +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME; -/** - * Send a simple text message via Evolution API - */ -export async function sendWhatsAppMessage(number: string, text: string) { - if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) { - console.warn('Evolution API no está configurada en las variables de entorno.'); - return { success: false, error: 'Configuración faltante' }; - } +const headers = { + apikey: EVOLUTION_API_KEY || '', + 'Content-Type': 'application/json' +}; - try { - const response = await axios.post( - `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`, - { - number, - text - }, - { - headers: { - 'apikey': EVOLUTION_API_KEY, - 'Content-Type': 'application/json' - } - } - ); - return { success: true, data: response.data }; - } catch (error: any) { - console.error('Error al enviar mensaje de WhatsApp:', error?.response?.data || error.message); - return { success: false, error: error.message }; - } +export async function sendWhatsAppMessage(numberId: string, text: string) { + const url = `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`; + + const response = await axios.post(url, { + number: numberId, + text: text + }, { headers }); + + return response.data; } -/** - * Send an image with text/caption via Evolution API - */ -export async function sendWhatsAppImage(number: string, imageUrl: string, caption: string) { - if (!EVOLUTION_API_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE_NAME) { - console.warn('Evolution API no está configurada en las variables de entorno.'); - return { success: false, error: 'Configuración faltante' }; - } - - try { - const response = await axios.post( - `${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`, - { - number, - mediatype: 'image', - media: imageUrl, - caption - }, - { - headers: { - 'apikey': EVOLUTION_API_KEY, - 'Content-Type': 'application/json' - } - } - ); - return { success: true, data: response.data }; - } catch (error: any) { - console.error('Error al enviar imagen de WhatsApp:', error?.response?.data || error.message); - return { success: false, error: error.message }; +export async function getBase64FromMediaMessage(messagePayload: any): Promise<{ base64: string, mimetype: string } | null> { + try { + const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; + const response = await axios.post(url, { + message: messagePayload + }, { headers }); + + if (response.data && response.data.base64) { + return { + base64: response.data.base64, + mimetype: response.data.mimetype || 'image/jpeg' + }; // sometimes mimetype might be missing depending on version, fallback to jpeg. } + } catch (err: any) { + console.error('Error in getBase64FromMediaMessage:', err?.response?.data || err.message); } + return null; +} diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts new file mode 100644 index 0000000..43fdb4d --- /dev/null +++ b/src/lib/gemini.ts @@ -0,0 +1,73 @@ +import { GoogleGenAI, Type } from '@google/genai'; + +const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' }); + +export interface ProcessedPublication { + titulo: string; + tipo: 'noticia' | 'evento' | 'aviso'; + seo_descripcion: string; + contenido: string; + fecha_vencimiento: string | null; +} + +export async function processWhatsAppMessage( + text: string, + base64Media?: string, + mimeType?: string +): Promise { + if (!process.env.GEMINI_API_KEY) { + throw new Error('No GEMINI_API_KEY configured'); + } + + const systemInstruction = ` + Eres un asistente editorial experto. + Analiza un mensaje enviado al grupo de WhatsApp "Publicaciones Almeira". + Debes estructurar el mensaje en base a lo siguiente: + 1. titulo: Asigna un título claro y corto. + 2. tipo: Determina si es "noticia" (Noticias, actualidad), "evento" (Invitaciones, programaciones), o "aviso" (Clasificados, empleos, ventas, anuncios parroquiales, etc). + 3. seo_descripcion: Un texto máximo de 150 caracteres describiendo de qué trata. + 4. contenido: El texto detallado de la publicación en HTML válido formateado (usa

, , listas locales si es útil) pero retén la información original importante. + 5. fecha_vencimiento: Si el texto indica fecha límite del evento o del aviso (Ej, "hasta el 30 de noviembre", o "el sábado"), devuélvela en formato YYYY-MM-DD. Si no tiene, omite el campo o pasa null. + `; + + // Prepara los contenidos + const contents: any[] = [ + { text: `Mensaje de WhatsApp: \n\n${text}` } + ]; + + if (base64Media && mimeType) { + contents.push({ + inlineData: { + data: base64Media, + mimeType: mimeType + } + }); + } + + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: contents, + config: { + systemInstruction: systemInstruction, + responseMimeType: "application/json", + responseSchema: { + type: Type.OBJECT, + properties: { + titulo: { type: Type.STRING }, + tipo: { type: Type.STRING, enum: ["noticia", "evento", "aviso"] }, + seo_descripcion: { type: Type.STRING, description: "Máximo 150 caracteres" }, + contenido: { type: Type.STRING, description: "Contenido en HTML" }, + fecha_vencimiento: { type: Type.STRING, description: "Fecha opcional YYYY-MM-DD", nullable: true } + }, + required: ["titulo", "tipo", "seo_descripcion", "contenido"] + } + } + }); + + if (!response.text) { + throw new Error('Gemini devolvió respuesta vacía'); + } + + const result = JSON.parse(response.text) as ProcessedPublication; + return result; +} diff --git a/test-whatsapp-local.js b/test-whatsapp-local.js new file mode 100644 index 0000000..15a8c93 --- /dev/null +++ b/test-whatsapp-local.js @@ -0,0 +1,40 @@ +// Using Node built-in fetch + +// This script simulates a webhook payload entirely on localhost, +// causing the endpoint to trigger Gemini and send a real message to the target WhatsApp group. +async function testWebhook() { + const payload = { + event: 'messages.upsert', + data: { + key: { + remoteJid: '573134848203-1408546044@g.us', // The Publications group + fromMe: false, + id: 'TEST_MESSAGE_ID_12345' + }, + pushName: 'Admin de Prueba (AI)', + message: { + conversation: 'Gran Bingo Bailable para la recolección de fondos navideños. Se aceptan donaciones hasta el 31 de noviembre. No faltes!' + } + } + }; + + try { + console.log("Enviando petición a la ruta local (asegúrate de tener 'npm run dev' abierto en otra terminal)..."); + const response = await fetch('http://localhost:3000/api/webhooks/whatsapp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + console.log("Respuesta del servidor local:", response.status, data); + + if (response.ok) { + console.log("\n¡Si todo salió bien, debió aparecerte un mensaje de validación en tu grupo de WhatsApp!"); + } + } catch (e) { + console.error("Error contactando al servidor local (Quizás no está encendido):", e.message); + } +} + +testWebhook();