From 947d9c8af8b54837fea86617d076bda13dd986d8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Feb 2026 21:05:39 +0500 Subject: [PATCH] Features: Juno Chat & E2E encryption & forgot password --- package-lock.json | 405 +++++++++ package.json | 2 + prisma.config.ts | 2 +- prisma/schema.prisma | 45 + src/app/admin/connections/page.tsx | 17 +- src/app/admin/emails/page.tsx | 23 +- src/app/admin/events/page.tsx | 68 +- src/app/admin/orders/page.tsx | 60 +- src/app/admin/page.tsx | 7 +- src/app/admin/product-syncs/page.tsx | 23 +- src/app/admin/sync-health/page.tsx | 14 +- src/app/api/admin/connections/route.ts | 3 +- src/app/api/admin/events/route.ts | 4 +- src/app/api/admin/orders/route.ts | 10 +- src/app/api/admin/product-syncs/route.ts | 15 +- src/app/api/chat/attach-orders/route.ts | 104 +++ src/app/api/chat/attach-products/route.ts | 92 ++ src/app/api/chat/contacts/route.ts | 102 +++ src/app/api/chat/messages/route.ts | 67 ++ .../api/integrations/gmail/callback/route.ts | 2 +- src/app/api/integrations/gmail/poll/route.ts | 2 +- src/app/api/integrations/gmail/watch/route.ts | 2 +- .../juno-engine/flagged-emails/[id]/route.ts | 50 ++ .../api/juno-engine/flagged-emails/route.ts | 119 +++ .../templates/[templateId]/route.ts | 2 +- src/app/api/juno-engine/templates/route.ts | 3 +- src/app/api/juno-engine/tickets/route.ts | 2 +- .../settings/sessions/[sessionId]/route.ts | 4 +- src/app/api/settings/sessions/route.ts | 4 +- .../stores/[storeId]/kb/faqs/import/route.ts | 9 +- src/app/api/user/chat-setup/route.ts | 28 + src/app/dashboard/chat/page.tsx | 11 + src/app/dashboard/help-support/page.tsx | 2 +- .../juno-engine/flagged-email/page.tsx | 64 +- src/app/dashboard/layout.tsx | 8 +- src/app/dashboard/products/page.tsx | 10 +- src/app/dashboard/suppliers/global/page.tsx | 2 +- src/app/dashboard/suppliers/my/page.tsx | 2 +- src/app/forgot-password/page.tsx | 122 +++ src/app/login/page.tsx | 5 + src/app/reset-password/page.tsx | 175 ++++ src/app/supplier/chat/page.tsx | 7 + src/app/supplier/layout.tsx | 14 +- src/app/supplier/page.tsx | 40 +- src/app/supplier/vendors/page.tsx | 5 +- src/app/verify-email/page.tsx | 113 +++ src/auth.ts | 6 + src/components/MobileSidebar.tsx | 6 +- src/components/MobileSupplierSidebar.tsx | 3 +- src/components/PageTransition.tsx | 5 +- src/components/Sidebar.tsx | 6 + src/components/SupplierSidebar.tsx | 14 +- src/components/ThemeProvider.tsx | 7 +- src/components/UserAccountMenu.tsx | 3 +- src/components/chat/ChatView.tsx | 805 ++++++++++++++++++ src/hooks/useChatCrypto.ts | 62 ++ src/lib/chat-crypto.ts | 164 ++++ src/lib/kb/documentProcessing.ts | 34 +- src/lib/logger.ts | 8 +- src/types/next-auth.d.ts | 1 + 60 files changed, 2849 insertions(+), 145 deletions(-) create mode 100644 src/app/api/chat/attach-orders/route.ts create mode 100644 src/app/api/chat/attach-products/route.ts create mode 100644 src/app/api/chat/contacts/route.ts create mode 100644 src/app/api/chat/messages/route.ts create mode 100644 src/app/api/juno-engine/flagged-emails/[id]/route.ts create mode 100644 src/app/api/juno-engine/flagged-emails/route.ts create mode 100644 src/app/api/user/chat-setup/route.ts create mode 100644 src/app/dashboard/chat/page.tsx create mode 100644 src/app/forgot-password/page.tsx create mode 100644 src/app/reset-password/page.tsx create mode 100644 src/app/supplier/chat/page.tsx create mode 100644 src/app/verify-email/page.tsx create mode 100644 src/components/chat/ChatView.tsx create mode 100644 src/hooks/useChatCrypto.ts create mode 100644 src/lib/chat-crypto.ts diff --git a/package-lock.json b/package-lock.json index e7a705e..1553e30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,12 @@ "dotenv": "^17.2.4", "framer-motion": "^12.33.0", "lucide-react": "^0.563.0", + "mammoth": "^1.11.0", "next": "16.1.6", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", "otplib": "^13.2.1", + "pdf-parse": "^2.4.5", "pg": "^8.18.0", "prisma": "^7.3.0", "qrcode": "^1.5.4", @@ -2212,6 +2214,176 @@ "node": ">=16" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4222,6 +4394,14 @@ "uncrypto": "^0.1.3" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4545,6 +4725,25 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "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" + } + ] + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -4561,6 +4760,11 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4857,6 +5061,11 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5160,6 +5369,11 @@ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5183,6 +5397,14 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6482,6 +6704,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -6516,6 +6743,11 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7058,6 +7290,17 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7098,6 +7341,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -7386,6 +7637,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7425,6 +7686,45 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mammoth/node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7873,6 +8173,11 @@ "node >= 0.4.6" ] }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7958,6 +8263,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7978,6 +8288,14 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7997,6 +8315,36 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -8267,6 +8615,11 @@ } } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8431,6 +8784,25 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -8653,6 +9025,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8766,6 +9143,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -8940,6 +9322,11 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -8972,6 +9359,14 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -9487,6 +9882,11 @@ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9573,6 +9973,11 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/valibot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", diff --git a/package.json b/package.json index 775a248..0358b73 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,12 @@ "dotenv": "^17.2.4", "framer-motion": "^12.33.0", "lucide-react": "^0.563.0", + "mammoth": "^1.11.0", "next": "16.1.6", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", "otplib": "^13.2.1", + "pdf-parse": "^2.4.5", "pg": "^8.18.0", "prisma": "^7.3.0", "qrcode": "^1.5.4", diff --git a/prisma.config.ts b/prisma.config.ts index 831a20f..16ff3d6 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -9,6 +9,6 @@ export default defineConfig({ path: "prisma/migrations", }, datasource: { - url: process.env["DATABASE_URL"], + url: process.env["DATABASE_URL"]!, }, }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2acd71c..3084c1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,7 +27,11 @@ model User { mfaEnabled Boolean @default(false) mfaSecret String? // Encrypted TOTP secret + chatPublicKey String? + // Relations + sentMessages ChatMessage[] @relation("SentMessages") + receivedMessages ChatMessage[] @relation("ReceivedMessages") accounts Account[] sessions Session[] stores Store[] // Changed from singular to support multiple stores @@ -109,6 +113,7 @@ model Store { faqItems FaqItem[] knowledgeChunks KnowledgeChunk[] emailLogs EmailLog[] + flaggedEmails FlaggedEmail[] templates Template[] createdAt DateTime @default(now()) @@ -383,6 +388,28 @@ model EmailLog { @@index([userId, sentAt]) } +enum FlaggedEmailPriority { + LOW + MEDIUM + HIGH +} + +model FlaggedEmail { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + subject String @db.Text + from String // Sender email + reason String @db.Text + priority FlaggedEmailPriority @default(MEDIUM) + messageId String? // Gmail message id for future sync + flaggedAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([storeId]) + @@index([storeId, flaggedAt]) +} + enum KnowledgeDocumentStatus { UPLOADED PROCESSING @@ -482,3 +509,21 @@ model Template { @@index([storeId]) } + +model ChatMessage { + id String @id @default(cuid()) + senderId String + receiverId String + encryptedContent String @db.Text + encryptedKeyForSender String @db.Text + encryptedKeyForReceiver String @db.Text + iv String + + sender User @relation("SentMessages", fields: [senderId], references: [id]) + receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) + + createdAt DateTime @default(now()) + + @@index([senderId, receiverId]) + @@index([receiverId, senderId]) +} diff --git a/src/app/admin/connections/page.tsx b/src/app/admin/connections/page.tsx index d248b85..fc70def 100644 --- a/src/app/admin/connections/page.tsx +++ b/src/app/admin/connections/page.tsx @@ -1,12 +1,13 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { Link2 } from "lucide-react" interface AdminConnection { id: string status: string - createdAt: string - updatedAt: string + createdAt: Date + updatedAt: Date store: { businessName: string shopifyStoreName: string @@ -17,12 +18,14 @@ interface AdminConnection { } async function getConnections(): Promise { - const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, { - cache: "no-store", + const connections = await prisma.connection.findMany({ + include: { + store: { select: { businessName: true, shopifyStoreName: true } }, + supplier: { select: { companyName: true } }, + }, + orderBy: { createdAt: "desc" }, }) - if (!res.ok) return [] - const data = (await res.json()) as { connections: AdminConnection[] } - return data.connections + return connections as unknown as AdminConnection[] } export default async function AdminConnectionsPage() { diff --git a/src/app/admin/emails/page.tsx b/src/app/admin/emails/page.tsx index 99521a7..6627a73 100644 --- a/src/app/admin/emails/page.tsx +++ b/src/app/admin/emails/page.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { Mail } from "lucide-react" @@ -12,7 +13,7 @@ interface AdminEmailLog { status: string providerMessageId: string | null errorMessage: string | null - sentAt: string + sentAt: Date store: { businessName: string shopifyStoreName: string @@ -20,15 +21,17 @@ interface AdminEmailLog { } async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> { - // Use a relative URL so the admin's session cookies are forwarded - const res = await fetch("/api/admin/emails?limit=100", { - cache: "no-store", - }) - if (!res.ok) { - return { emails: [], total: 0 } - } - const data = (await res.json()) as { emails: AdminEmailLog[]; total: number } - return data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prismaAny = prisma as any + const [emails, total] = await Promise.all([ + prismaAny.emailLog.findMany({ + include: { store: { select: { businessName: true, shopifyStoreName: true } } }, + orderBy: { sentAt: "desc" }, + take: 100, + }), + prismaAny.emailLog.count(), + ]) + return { emails: emails as AdminEmailLog[], total } } export default async function AdminEmailsPage() { diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 315481f..1f11806 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { Activity, Link2, Radar, Shuffle } from "lucide-react" @@ -14,26 +15,59 @@ interface AdminEvent { metadata?: Record } +const LIMIT = 50 + async function getEvents(type?: AdminEventType): Promise { - const searchParams = new URLSearchParams() - searchParams.set("limit", "50") - if (type) { - searchParams.set("type", type) - } + const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([ + prisma.connection.findMany({ + orderBy: { createdAt: "desc" }, + take: LIMIT, + include: { store: true, supplier: true }, + }), + prisma.productSync.findMany({ + orderBy: { createdAt: "desc" }, + take: LIMIT, + include: { store: true, supplier: true }, + }), + prisma.syncMetadata.findMany({ + orderBy: { lastSyncAt: "desc" }, + take: LIMIT, + include: { store: true }, + }), + ]) - const res = await fetch( - `${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`, - { - cache: "no-store", - }, - ) + const events: AdminEvent[] = [ + ...connectionEvents.map((c) => ({ + id: c.id, + type: "CONNECTION" as AdminEventType, + status: c.status, + timestamp: c.createdAt.toISOString(), + storeName: c.store.shopifyStoreName, + supplierName: c.supplier.companyName, + metadata: {}, + })), + ...productSyncEvents.map((p) => ({ + id: p.id, + type: "PRODUCT_SYNC" as AdminEventType, + status: p.status, + timestamp: p.createdAt.toISOString(), + storeName: p.store.shopifyStoreName, + supplierName: p.supplier.companyName, + metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId }, + })), + ...syncHealthEvents.map((s) => ({ + id: s.id, + type: "SYNC_HEALTH" as AdminEventType, + status: s.syncStatus, + timestamp: s.lastSyncAt.toISOString(), + storeName: s.store.shopifyStoreName, + supplierName: null as string | null, + metadata: { resourceType: s.resourceType, recordCount: s.recordCount }, + })), + ].filter((e) => !type || e.type === type) - if (!res.ok) { - return [] - } - - const data = (await res.json()) as { events: AdminEvent[] } - return data.events + events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + return events.slice(0, LIMIT) } function typeBadge(eventType: AdminEventType) { diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index d14df33..39007c0 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { ShoppingCart } from "lucide-react" @@ -10,7 +11,7 @@ interface AdminOrder { currency: string | null financialStatus: string | null fulfillmentStatus: string | null - createdAt: string + createdAt: Date store: { businessName: string shopifyStoreName: string @@ -18,12 +19,57 @@ interface AdminOrder { } async function getOrders(): Promise { - const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, { - cache: "no-store", + const acceptedSyncs = await prisma.productSync.findMany({ + where: { status: "ACCEPTED" }, + select: { shopifyProductId: true, storeId: true }, }) - if (!res.ok) return [] - const data = (await res.json()) as { orders: AdminOrder[] } - return data.orders + if (acceptedSyncs.length === 0) return [] + + const syncsByStore = new Map>() + acceptedSyncs.forEach((sync) => { + if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set()) + syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId) + }) + const storeIds = Array.from(syncsByStore.keys()) + + const cachedOrders = await prisma.cachedOrder.findMany({ + where: { storeId: { in: storeIds } }, + include: { + store: { select: { id: true, businessName: true, shopifyStoreName: true } }, + }, + orderBy: { shopifyCreatedAt: "desc" }, + }) + + const relevantOrders = cachedOrders.filter((order) => { + const syncedProductIds = syncsByStore.get(order.storeId) + if (!syncedProductIds?.size) return false + try { + const lineItems = + typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems + if (!Array.isArray(lineItems)) return false + return lineItems.some( + (item: { product_id?: number }) => + item.product_id != null && syncedProductIds.has(String(item.product_id)) + ) + } catch { + return false + } + }) + + return relevantOrders.map((order) => { + const store = order.store as { id: string; businessName: string; shopifyStoreName: string } + return { + id: order.shopifyOrderId, + orderNumber: order.orderNumber, + email: order.email, + totalPrice: order.totalPrice, + currency: order.currency, + financialStatus: order.financialStatus, + fulfillmentStatus: order.fulfillmentStatus, + createdAt: order.shopifyCreatedAt || order.createdAt, + store: { businessName: store.businessName, shopifyStoreName: store.shopifyStoreName }, + } + }) as AdminOrder[] } export default async function AdminOrdersPage() { @@ -109,7 +155,7 @@ export default async function AdminOrdersPage() { {order.fulfillmentStatus || "—"} - {new Date(order.createdAt).toLocaleString("en-US", { + {new Date(order.createdAt as Date).toLocaleString("en-US", { dateStyle: "short", timeStyle: "short", })} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d3a46ef..182f09f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -69,11 +69,10 @@ async function getAdminOverview(): Promise { by: ["status"], _count: { _all: true }, }) as unknown as Promise, - // Use `as any` to be compatible with the current Prisma client setup - (prisma as any).productSync.groupBy({ + prisma.productSync.groupBy({ by: ["status"], _count: { _all: true }, - }) as Promise, + } as Parameters[0]) as unknown as Promise, prisma.cachedOrder.count(), prisma.syncMetadata.findMany({ where: { syncStatus: { in: ["failed"] } }, @@ -81,7 +80,7 @@ async function getAdminOverview(): Promise { take: 5, include: { store: true }, }) as Promise, - (prisma as any).productSync.findMany({ + prisma.productSync.findMany({ orderBy: { createdAt: "desc" }, take: 8, include: { diff --git a/src/app/admin/product-syncs/page.tsx b/src/app/admin/product-syncs/page.tsx index 600b203..5fbbcf9 100644 --- a/src/app/admin/product-syncs/page.tsx +++ b/src/app/admin/product-syncs/page.tsx @@ -1,13 +1,14 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { Shuffle } from "lucide-react" interface AdminProductSync { id: string status: string - createdAt: string - acceptedAt: string | null - rejectedAt: string | null + createdAt: Date + acceptedAt: Date | null + rejectedAt: Date | null shopifyProductId: string shopifyProductTitle: string store: { @@ -20,12 +21,14 @@ interface AdminProductSync { } async function getProductSyncs(): Promise { - const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, { - cache: "no-store", + const syncs = await prisma.productSync.findMany({ + include: { + store: { select: { businessName: true, shopifyStoreName: true } }, + supplier: { select: { companyName: true } }, + }, + orderBy: { createdAt: "desc" }, }) - if (!res.ok) return [] - const data = (await res.json()) as { syncs: AdminProductSync[] } - return data.syncs + return syncs as unknown as AdminProductSync[] } export default async function AdminProductSyncsPage() { @@ -113,9 +116,7 @@ export default async function AdminProductSyncsPage() { {sync.acceptedAt || sync.rejectedAt - ? new Date( - (sync.acceptedAt || sync.rejectedAt) as string, - ).toLocaleString("en-US", { + ? new Date(sync.acceptedAt || sync.rejectedAt!).toLocaleString("en-US", { dateStyle: "short", timeStyle: "short", }) diff --git a/src/app/admin/sync-health/page.tsx b/src/app/admin/sync-health/page.tsx index cac47af..4e78f57 100644 --- a/src/app/admin/sync-health/page.tsx +++ b/src/app/admin/sync-health/page.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { redirect } from "next/navigation" import { Activity, Radar } from "lucide-react" @@ -16,12 +17,15 @@ interface AdminSyncMetadata { } async function getSyncHealth(): Promise { - const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, { - cache: "no-store", + const rows = await prisma.syncMetadata.findMany({ + include: { + store: { + select: { businessName: true, shopifyStoreName: true }, + }, + }, + orderBy: { lastSyncAt: "desc" }, }) - if (!res.ok) return [] - const data = (await res.json()) as { syncs: AdminSyncMetadata[] } - return data.syncs + return rows as unknown as AdminSyncMetadata[] } export default async function AdminSyncHealthPage() { diff --git a/src/app/api/admin/connections/route.ts b/src/app/api/admin/connections/route.ts index 3dec598..5c3cb15 100644 --- a/src/app/api/admin/connections/route.ts +++ b/src/app/api/admin/connections/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { ConnectionStatus } from "@prisma/client" export async function GET(req: Request) { try { @@ -15,7 +16,7 @@ export async function GET(req: Request) { const connections = await prisma.connection.findMany({ where: { - ...(status && { status: status as any }), + ...(status && { status: status as ConnectionStatus }), }, include: { store: true, diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index 6611223..2b3bc14 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -25,7 +25,7 @@ export async function GET(req: Request) { supplier: true, }, }), - (prisma as any).productSync.findMany({ + prisma.productSync.findMany({ orderBy: { createdAt: "desc" }, take: limit, include: { @@ -52,7 +52,7 @@ export async function GET(req: Request) { supplierName: c.supplier.companyName, metadata: {}, })), - ...productSyncEvents.map((p: any) => ({ + ...productSyncEvents.map((p: (typeof productSyncEvents)[number]) => ({ id: p.id, type: "PRODUCT_SYNC" as AdminEventType, status: p.status, diff --git a/src/app/api/admin/orders/route.ts b/src/app/api/admin/orders/route.ts index 2118a16..1a95bc6 100644 --- a/src/app/api/admin/orders/route.ts +++ b/src/app/api/admin/orders/route.ts @@ -11,7 +11,7 @@ export async function GET() { } // Get all accepted product syncs - const acceptedSyncs = await (prisma as any).productSync.findMany({ + const acceptedSyncs = await prisma.productSync.findMany({ where: { status: "ACCEPTED", }, @@ -31,7 +31,7 @@ export async function GET() { } const syncsByStore = new Map>() - acceptedSyncs.forEach((sync: any) => { + acceptedSyncs.forEach((sync: (typeof acceptedSyncs)[number]) => { if (!syncsByStore.has(sync.storeId)) { syncsByStore.set(sync.storeId, new Set()) } @@ -69,7 +69,7 @@ export async function GET() { if (!Array.isArray(lineItems)) return false - return lineItems.some((item: any) => { + return lineItems.some((item: { product_id?: number }) => { const productId = item.product_id?.toString() return productId && syncedProductIds.has(productId) }) @@ -80,7 +80,7 @@ export async function GET() { const transformedOrders = relevantOrders.map((order: typeof relevantOrders[0]) => { const syncedProductIds = syncsByStore.get(order.storeId) || new Set() - let lineItems: any[] = [] + let lineItems: { product_id?: number }[] = [] try { lineItems = @@ -91,7 +91,7 @@ export async function GET() { lineItems = [] } - const syncedLineItems = lineItems.filter((item: any) => { + const syncedLineItems = lineItems.filter((item: { product_id?: number }) => { const productId = item.product_id?.toString() return productId && syncedProductIds.has(productId) }) diff --git a/src/app/api/admin/product-syncs/route.ts b/src/app/api/admin/product-syncs/route.ts index 2dd971d..6508be1 100644 --- a/src/app/api/admin/product-syncs/route.ts +++ b/src/app/api/admin/product-syncs/route.ts @@ -1,6 +1,13 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { ProductSyncStatus } from "@prisma/client" + +const VALID_STATUSES: ProductSyncStatus[] = [ + ProductSyncStatus.PENDING, + ProductSyncStatus.ACCEPTED, + ProductSyncStatus.REJECTED, +] export async function GET(req: Request) { try { @@ -11,9 +18,13 @@ export async function GET(req: Request) { } const { searchParams } = new URL(req.url) - const status = searchParams.get("status") + const statusParam = searchParams.get("status") + const status = + statusParam && VALID_STATUSES.includes(statusParam as ProductSyncStatus) + ? (statusParam as ProductSyncStatus) + : undefined - const syncs = await (prisma as any).productSync.findMany({ + const syncs = await prisma.productSync.findMany({ where: { ...(status && { status }), }, diff --git a/src/app/api/chat/attach-orders/route.ts b/src/app/api/chat/attach-orders/route.ts new file mode 100644 index 0000000..99f055f --- /dev/null +++ b/src/app/api/chat/attach-orders/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache" + +const PER_STORE_LIMIT = 25 + +/** + * GET /api/chat/attach-orders + * Returns orders from all vendor stores (for chat attach). Each item includes storeId and storeName. + * Vendor-only. + */ +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const stores = await prisma.store.findMany({ + where: { userId: session.user.id }, + select: { + id: true, + businessName: true, + shopifyDomain: true, + shopifyAccessToken: true + } + }) + + if (stores.length === 0) { + return NextResponse.json({ orders: [] }) + } + + type OrderItem = { + id: number + name: string + total_price: string + currency?: string + storeId: string + storeName: string + } + + const fetchOrdersForStore = async ( + store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null } + ): Promise => { + const cleanDomain = (store.shopifyDomain || "").trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + if (!cleanDomain || !store.shopifyAccessToken) return [] + const storeName = store.businessName || store.shopifyDomain || "Store" + + const cacheFresh = await isCacheFresh(store.id, "orders") + if (cacheFresh) { + const cached = await getCachedOrders(store.id) + return cached.slice(0, PER_STORE_LIMIT).map((order) => ({ + id: parseInt(order.shopifyOrderId), + name: order.orderNumber, + total_price: order.totalPrice, + currency: order.currency || undefined, + storeId: store.id, + storeName + })) + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 15000) + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/orders.json?status=any&limit=${PER_STORE_LIMIT}`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken!, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + if (!response.ok) return [] + const data = await response.json() + if (data.orders?.length) { + await updateOrderCache(store.id, data.orders).catch(() => {}) + } + return (data.orders || []).map((o: { id: number; name?: string; total_price?: string; currency?: string }) => ({ + id: o.id, + name: o.name || `#${o.id}`, + total_price: o.total_price || "0", + currency: o.currency, + storeId: store.id, + storeName + })) + } catch { + return [] + } + } + + const results = await Promise.allSettled(stores.map((s) => fetchOrdersForStore(s))) + const allOrders: OrderItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])) + allOrders.sort((a, b) => (b.id > a.id ? 1 : -1)) + return NextResponse.json({ orders: allOrders.slice(0, 50) }) + } catch (error) { + console.error("Chat attach orders error:", error) + return NextResponse.json({ message: "Failed to load orders", orders: [] }, { status: 500 }) + } +} diff --git a/src/app/api/chat/attach-products/route.ts b/src/app/api/chat/attach-products/route.ts new file mode 100644 index 0000000..fe8e73d --- /dev/null +++ b/src/app/api/chat/attach-products/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache" + +const PER_STORE_LIMIT = 25 + +/** + * GET /api/chat/attach-products + * Returns products from all vendor stores (for chat attach). Each item includes storeId and storeName. + * Vendor-only. + */ +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const stores = await prisma.store.findMany({ + where: { userId: session.user.id }, + select: { + id: true, + businessName: true, + shopifyDomain: true, + shopifyAccessToken: true + } + }) + + if (stores.length === 0) { + return NextResponse.json({ products: [] }) + } + + type ProductItem = { id: number; title: string; storeId: string; storeName: string } + + const fetchProductsForStore = async ( + store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null } + ): Promise => { + const cleanDomain = (store.shopifyDomain || "").trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + if (!cleanDomain || !store.shopifyAccessToken) return [] + const storeName = store.businessName || store.shopifyDomain || "Store" + + const cacheFresh = await isCacheFresh(store.id, "products") + if (cacheFresh) { + const cached = await getCachedProducts(store.id) + return cached.slice(0, PER_STORE_LIMIT).map((product) => ({ + id: parseInt(product.shopifyProductId), + title: product.title || "Product", + storeId: store.id, + storeName + })) + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 15000) + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/products.json?limit=${PER_STORE_LIMIT}`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken!, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + if (!response.ok) return [] + const data = await response.json() + if (data.products?.length) { + await updateProductCache(store.id, data.products).catch(() => {}) + } + return (data.products || []).map((p: { id: number; title?: string }) => ({ + id: p.id, + title: p.title || "Product", + storeId: store.id, + storeName + })) + } catch { + return [] + } + } + + const results = await Promise.allSettled(stores.map((s) => fetchProductsForStore(s))) + const allProducts: ProductItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])) + return NextResponse.json({ products: allProducts.slice(0, 50) }) + } catch (error) { + console.error("Chat attach products error:", error) + return NextResponse.json({ message: "Failed to load products", products: [] }, { status: 500 }) + } +} diff --git a/src/app/api/chat/contacts/route.ts b/src/app/api/chat/contacts/route.ts new file mode 100644 index 0000000..7012792 --- /dev/null +++ b/src/app/api/chat/contacts/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return new NextResponse("Unauthorized", { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + supplierProfile: true, + } + }) + + if (!user) { + return new NextResponse("User not found", { status: 404 }) + } + + interface Contact { + id: string + name: string | null + email: string | null + image: string | null + publicKey: string | null + role: string + } + let contacts: Contact[] = [] + + if (user.role === "VENDOR") { + // Fetch connected suppliers + const connections = await prisma.connection.findMany({ + where: { + store: { userId: user.id }, + status: "CONNECTED" + }, + include: { + supplier: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + chatPublicKey: true, + } + } + } + } + } + }) + contacts = connections.map(c => ({ + id: c.supplier.user.id, + name: c.supplier.companyName || c.supplier.user.name, + email: c.supplier.user.email, + image: c.supplier.user.image, + publicKey: c.supplier.user.chatPublicKey, + role: "SUPPLIER" + })) + } else if (user.role === "SUPPLIER" && user.supplierProfile) { + // Fetch connected vendors + const connections = await prisma.connection.findMany({ + where: { + supplierId: user.supplierProfile.id, + status: "CONNECTED" + }, + include: { + store: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + chatPublicKey: true, + } + } + } + } + } + }) + contacts = connections.map(c => ({ + id: c.store.user.id, + name: c.store.businessName || c.store.user.name, + email: c.store.user.email, + image: c.store.user.image, + publicKey: c.store.user.chatPublicKey, + role: "VENDOR" + })) + } + + return NextResponse.json({ contacts }) + } catch (error) { + console.error("[CHAT_CONTACTS]", error) + return new NextResponse("Internal Error", { status: 500 }) + } +} diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts new file mode 100644 index 0000000..0eed265 --- /dev/null +++ b/src/app/api/chat/messages/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return new NextResponse("Unauthorized", { status: 401 }) + } + + const { receiverId, encryptedContent, encryptedKeyForSender, encryptedKeyForReceiver, iv } = await req.json() + + if (!receiverId || !encryptedContent || !encryptedKeyForSender || !encryptedKeyForReceiver || !iv) { + return new NextResponse("Missing fields", { status: 400 }) + } + + const message = await prisma.chatMessage.create({ + data: { + senderId: session.user.id, + receiverId, + encryptedContent, + encryptedKeyForSender, + encryptedKeyForReceiver, + iv, + } + }) + + return NextResponse.json(message) + } catch (error) { + console.error("[CHAT_MESSAGES_POST]", error) + return new NextResponse("Internal Error", { status: 500 }) + } +} + +export async function GET(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return new NextResponse("Unauthorized", { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const contactId = searchParams.get("contactId") + + if (!contactId) { + return new NextResponse("Missing contactId", { status: 400 }) + } + + const messages = await prisma.chatMessage.findMany({ + where: { + OR: [ + { senderId: session.user.id, receiverId: contactId }, + { senderId: contactId, receiverId: session.user.id } + ] + }, + orderBy: { + createdAt: "asc" + } + }) + + return NextResponse.json({ messages }) + } catch (error) { + console.error("[CHAT_MESSAGES_GET]", error) + return new NextResponse("Internal Error", { status: 500 }) + } +} diff --git a/src/app/api/integrations/gmail/callback/route.ts b/src/app/api/integrations/gmail/callback/route.ts index dc1450a..2597f4b 100644 --- a/src/app/api/integrations/gmail/callback/route.ts +++ b/src/app/api/integrations/gmail/callback/route.ts @@ -23,7 +23,7 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url) const code = searchParams.get("code") const error = searchParams.get("error") - const state = searchParams.get("state") + const _state = searchParams.get("state") // Handle OAuth errors if (error) { diff --git a/src/app/api/integrations/gmail/poll/route.ts b/src/app/api/integrations/gmail/poll/route.ts index ce1a0f6..04cc6f6 100644 --- a/src/app/api/integrations/gmail/poll/route.ts +++ b/src/app/api/integrations/gmail/poll/route.ts @@ -9,7 +9,7 @@ import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/em * Manually trigger email polling and automated replies * This can be called periodically via cron job or manually */ -export async function POST(req: Request) { +export async function POST(_req: Request) { try { const session = await auth() if (!session?.user?.id) { diff --git a/src/app/api/integrations/gmail/watch/route.ts b/src/app/api/integrations/gmail/watch/route.ts index 2497bbb..7a45509 100644 --- a/src/app/api/integrations/gmail/watch/route.ts +++ b/src/app/api/integrations/gmail/watch/route.ts @@ -13,7 +13,7 @@ import { getGmailAccessToken } from "@/lib/gmail" * * Note: Gmail watch requests expire after 7 days and need to be renewed. */ -export async function POST(req: Request) { +export async function POST(_req: Request) { try { const session = await auth() if (!session?.user?.id) { diff --git a/src/app/api/juno-engine/flagged-emails/[id]/route.ts b/src/app/api/juno-engine/flagged-emails/[id]/route.ts new file mode 100644 index 0000000..68eebdc --- /dev/null +++ b/src/app/api/juno-engine/flagged-emails/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +async function getActiveStoreId(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + stores: { + where: { isActive: true }, + take: 1, + select: { id: true }, + }, + }, + }) + return user?.stores[0]?.id ?? null +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store found" }, { status: 400 }) + } + + const { id } = await params + await prisma.flaggedEmail.deleteMany({ + where: { id, storeId }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[FLAGGED_EMAILS_DELETE]", error) + return NextResponse.json( + { message: "Failed to delete flagged email" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/juno-engine/flagged-emails/route.ts b/src/app/api/juno-engine/flagged-emails/route.ts new file mode 100644 index 0000000..c09afbb --- /dev/null +++ b/src/app/api/juno-engine/flagged-emails/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +async function getActiveStoreId(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + stores: { + where: { isActive: true }, + take: 1, + select: { id: true }, + }, + }, + }) + return user?.stores[0]?.id ?? null +} + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ flaggedEmails: [] }) + } + + const flaggedEmails = await prisma.flaggedEmail.findMany({ + where: { storeId }, + orderBy: { flaggedAt: "desc" }, + }) + + return NextResponse.json({ + flaggedEmails: flaggedEmails.map((e) => ({ + id: e.id, + subject: e.subject, + from: e.from, + reason: e.reason, + priority: e.priority.toLowerCase() as "low" | "medium" | "high", + flaggedAt: e.flaggedAt.toISOString(), + })), + }) + } catch (error) { + console.error("[FLAGGED_EMAILS_GET]", error) + return NextResponse.json( + { message: "Failed to fetch flagged emails" }, + { status: 500 } + ) + } +} + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store found" }, { status: 400 }) + } + + const body = await req.json() + const { subject, from, reason, priority, messageId } = body as { + subject?: string + from?: string + reason?: string + priority?: "LOW" | "MEDIUM" | "HIGH" + messageId?: string + } + + if (!subject || !from || !reason) { + return NextResponse.json( + { message: "subject, from, and reason are required" }, + { status: 400 } + ) + } + + const priorityVal = priority && ["LOW", "MEDIUM", "HIGH"].includes(priority) + ? priority + : "MEDIUM" + + const created = await prisma.flaggedEmail.create({ + data: { + storeId, + subject, + from, + reason, + priority: priorityVal, + messageId: messageId ?? null, + }, + }) + + return NextResponse.json({ + id: created.id, + subject: created.subject, + from: created.from, + reason: created.reason, + priority: created.priority.toLowerCase(), + flaggedAt: created.flaggedAt.toISOString(), + }) + } catch (error) { + console.error("[FLAGGED_EMAILS_POST]", error) + return NextResponse.json( + { message: "Failed to create flagged email" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/juno-engine/templates/[templateId]/route.ts b/src/app/api/juno-engine/templates/[templateId]/route.ts index c13d16e..a0d7dfd 100644 --- a/src/app/api/juno-engine/templates/[templateId]/route.ts +++ b/src/app/api/juno-engine/templates/[templateId]/route.ts @@ -160,7 +160,7 @@ export async function PUT( "mood" = COALESCE(${mood}, "mood"), "description" = COALESCE(${description ?? null}, "description"), "body" = COALESCE(${templateBody}, "body"), - "variables" = COALESCE(${variables as any}, "variables"), + "variables" = COALESCE(${variables as Record | null}, "variables"), "isHtml" = COALESCE(${isHtml}, "isHtml"), "updatedAt" = NOW() WHERE "id" = ${existing.id} diff --git a/src/app/api/juno-engine/templates/route.ts b/src/app/api/juno-engine/templates/route.ts index 6e20618..3c07e8a 100644 --- a/src/app/api/juno-engine/templates/route.ts +++ b/src/app/api/juno-engine/templates/route.ts @@ -150,8 +150,7 @@ export async function POST(req: Request) { INSERT INTO "Template" ("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt") VALUES - (${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as - any}, ${isHtml ?? true}, NOW(), NOW()) + (${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as Record | null}, ${isHtml ?? true}, NOW(), NOW()) RETURNING "id", "storeId", diff --git a/src/app/api/juno-engine/tickets/route.ts b/src/app/api/juno-engine/tickets/route.ts index 82be510..b07e829 100644 --- a/src/app/api/juno-engine/tickets/route.ts +++ b/src/app/api/juno-engine/tickets/route.ts @@ -10,7 +10,7 @@ function normalizeSubject(subject: string | null): string { // Remove repeated "Re:" / "Fwd:" prefixes // e.g. "Re: Re: Hello" -> "Hello" // Case-insensitive, allow localized variants in future by trimming until no prefix matches - // eslint-disable-next-line no-constant-condition + while (true) { const match = s.match(/^(re|fw|fwd):\s*/i) if (!match) break diff --git a/src/app/api/settings/sessions/[sessionId]/route.ts b/src/app/api/settings/sessions/[sessionId]/route.ts index c40e8de..4b676c9 100644 --- a/src/app/api/settings/sessions/[sessionId]/route.ts +++ b/src/app/api/settings/sessions/[sessionId]/route.ts @@ -15,7 +15,7 @@ export async function DELETE( const { sessionId } = await params // Verify the session belongs to the user - const userSession = await (prisma as any).session.findFirst({ + const userSession = await prisma.session.findFirst({ where: { id: sessionId, userId: session.user.id @@ -27,7 +27,7 @@ export async function DELETE( } // Delete the session - await (prisma as any).session.delete({ + await prisma.session.delete({ where: { id: sessionId } }) diff --git a/src/app/api/settings/sessions/route.ts b/src/app/api/settings/sessions/route.ts index 024c647..10fbf57 100644 --- a/src/app/api/settings/sessions/route.ts +++ b/src/app/api/settings/sessions/route.ts @@ -16,13 +16,13 @@ export async function GET() { cookieStore.get("__Secure-authjs.session-token")?.value // Fetch all sessions for the user - const userSessions = await (prisma as any).session.findMany({ + const userSessions = await prisma.session.findMany({ where: { userId: session.user.id }, orderBy: { expires: "desc" } }) // Transform sessions to include additional info - const sessions = userSessions.map((s: { id: string; sessionToken: string; expires: Date }) => ({ + const sessions = userSessions.map((s: (typeof userSessions)[number]) => ({ id: s.id, sessionToken: s.sessionToken, expires: s.expires.toISOString(), diff --git a/src/app/api/stores/[storeId]/kb/faqs/import/route.ts b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts index 2839df4..ee90ce3 100644 --- a/src/app/api/stores/[storeId]/kb/faqs/import/route.ts +++ b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts @@ -3,6 +3,7 @@ import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" import { embedBatch } from "@/lib/kb/bedrockEmbeddings" +import type { FaqItem } from "@prisma/client" type FaqImportItem = { question: string @@ -91,7 +92,7 @@ export async function POST( const createdFaqs = await prisma.$transaction( validItems.map((item: FaqImportItem) => - (prisma as any).faqItem.create({ + prisma.faqItem.create({ data: { storeId: store.id, question: item.question, @@ -103,12 +104,12 @@ export async function POST( ) const texts = createdFaqs.map( - (faq) => `${faq.question}\n${faq.answer}`, + (faq: { question: string; answer: string }) => `${faq.question}\n${faq.answer}`, ) const embeddings = await embedBatch(texts) await Promise.all( - createdFaqs.map((faq, index) => { + createdFaqs.map((faq: FaqItem, index: number) => { const embedding = embeddings[index] if (!embedding || !embedding.length) return Promise.resolve() @@ -129,7 +130,7 @@ export async function POST( ) `, faq.storeId, - faq.id, + faq.id as string, `${faq.question}\n${faq.answer}`, { question: faq.question, diff --git a/src/app/api/user/chat-setup/route.ts b/src/app/api/user/chat-setup/route.ts new file mode 100644 index 0000000..ee2ea02 --- /dev/null +++ b/src/app/api/user/chat-setup/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return new NextResponse("Unauthorized", { status: 401 }) + } + + const { publicKey } = await req.json() + + if (!publicKey) { + return new NextResponse("Missing publicKey", { status: 400 }) + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { chatPublicKey: publicKey } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[CHAT_SETUP]", error) + return new NextResponse("Internal Error", { status: 500 }) + } +} diff --git a/src/app/dashboard/chat/page.tsx b/src/app/dashboard/chat/page.tsx new file mode 100644 index 0000000..47b378b --- /dev/null +++ b/src/app/dashboard/chat/page.tsx @@ -0,0 +1,11 @@ +"use client" + +import { ChatView } from "@/components/chat/ChatView" + +export default function ChatPage() { + return ( +
+ +
+ ) +} diff --git a/src/app/dashboard/help-support/page.tsx b/src/app/dashboard/help-support/page.tsx index 84270c9..3aba90a 100644 --- a/src/app/dashboard/help-support/page.tsx +++ b/src/app/dashboard/help-support/page.tsx @@ -64,7 +64,7 @@ const faqs: FAQItem[] = [ { id: "6", question: "How do I export my data?", - answer: "You can export reports from various pages using the Export button. Currently, you can export order data as CSV. More export options are coming soon.", + answer: "You can export order data as CSV from the Orders page. Additional export options are planned.", category: "general" } ] diff --git a/src/app/dashboard/juno-engine/flagged-email/page.tsx b/src/app/dashboard/juno-engine/flagged-email/page.tsx index 62c1265..b725840 100644 --- a/src/app/dashboard/juno-engine/flagged-email/page.tsx +++ b/src/app/dashboard/juno-engine/flagged-email/page.tsx @@ -1,13 +1,11 @@ "use client" -import { useState } from "react" -import { - Flag, - Search, - Filter, - Mail, +import { useState, useEffect, useCallback } from "react" +import { + Flag, + Search, + Filter, Loader2, - AlertCircle, Trash2, Archive, CheckCircle2 @@ -25,7 +23,46 @@ interface FlaggedEmail { export default function FlaggedEmailPage() { const [searchTerm, setSearchTerm] = useState("") const [flaggedEmails, setFlaggedEmails] = useState([]) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) + const [deletingId, setDeletingId] = useState(null) + + const fetchFlaggedEmails = useCallback(async () => { + setLoading(true) + try { + const res = await fetch("/api/juno-engine/flagged-emails") + if (res.ok) { + const data = await res.json() + setFlaggedEmails(data.flaggedEmails ?? []) + } + } catch { + setFlaggedEmails([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchFlaggedEmails() + }, [fetchFlaggedEmails]) + + const handleDelete = async (id: string) => { + setDeletingId(id) + try { + const res = await fetch(`/api/juno-engine/flagged-emails/${id}`, { method: "DELETE" }) + if (res.ok) { + setFlaggedEmails((prev) => prev.filter((e) => e.id !== id)) + } + } finally { + setDeletingId(null) + } + } + + const filtered = flaggedEmails.filter( + (e) => + e.subject.toLowerCase().includes(searchTerm.toLowerCase()) || + e.from.toLowerCase().includes(searchTerm.toLowerCase()) || + e.reason.toLowerCase().includes(searchTerm.toLowerCase()) + ) return (
@@ -55,7 +92,7 @@ export default function FlaggedEmailPage() {

Loading flagged emails...

- ) : flaggedEmails.length === 0 ? ( + ) : filtered.length === 0 ? (

No flagged emails found.

@@ -73,7 +110,7 @@ export default function FlaggedEmailPage() { - {flaggedEmails.map((email) => ( + {filtered.map((email) => (
@@ -98,7 +135,12 @@ export default function FlaggedEmailPage() { -
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index d91f8d9..6768ed8 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -36,8 +36,7 @@ export default async function DashboardLayout({ } return ( - // Root container fills viewport and only hides horizontal overflow -
+
{/* Desktop Sidebar */}
@@ -71,9 +70,8 @@ export default async function DashboardLayout({
- {/* Fixed-height content area; inner pages manage their own scrolling */} -
- +
+
{children}
diff --git a/src/app/dashboard/products/page.tsx b/src/app/dashboard/products/page.tsx index 962ddc5..2770ddd 100644 --- a/src/app/dashboard/products/page.tsx +++ b/src/app/dashboard/products/page.tsx @@ -1,5 +1,6 @@ "use client" +import Link from "next/link" import { motion } from "framer-motion" import { Package, @@ -12,6 +13,7 @@ import { Layers } from "lucide-react" +// Sample data for this legacy view. For live inventory data, use the Inventory page. const mockInventory = [ { id: "1", title: "Neo Wireless Controller", supplier: "Tokyo Tech Hub", price: "$45.00", stock: 120, status: "SYNCED" }, { id: "2", title: "Smart Home Hub v2", supplier: "Shenzhen Direct", price: "$89.50", stock: 15, status: "LOW STOCK" }, @@ -101,7 +103,7 @@ export default function InventoryPage() {
- {/* Analytics Footer Placeholder */} + {/* Sample analytics – real data is on the Inventory page */}
@@ -131,6 +133,12 @@ export default function InventoryPage() {
+

+ Sample data. View live inventory and sync status on the{" "} + + Inventory page + . +

) } diff --git a/src/app/dashboard/suppliers/global/page.tsx b/src/app/dashboard/suppliers/global/page.tsx index 3d31122..34aa67a 100644 --- a/src/app/dashboard/suppliers/global/page.tsx +++ b/src/app/dashboard/suppliers/global/page.tsx @@ -59,7 +59,7 @@ export default function GlobalSuppliersPage() { }, 5000) // Poll every 5 seconds return () => clearInterval(interval) - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [suppliers]) // Re-run when suppliers change useEffect(() => { diff --git a/src/app/dashboard/suppliers/my/page.tsx b/src/app/dashboard/suppliers/my/page.tsx index bdfbf70..8a12ba2 100644 --- a/src/app/dashboard/suppliers/my/page.tsx +++ b/src/app/dashboard/suppliers/my/page.tsx @@ -61,7 +61,7 @@ export default function MySuppliersPage() { }, 5000) // Poll every 5 seconds return () => clearInterval(interval) - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connections]) // Re-run when connections change useEffect(() => { diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..11f5163 --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useState } from "react" +import { motion } from "framer-motion" +import { Mail, Loader2, Zap, ArrowLeft } from "lucide-react" +import Link from "next/link" + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + setSuccess(false) + const trimmed = email.trim() + if (!trimmed) { + setError("Please enter your email address.") + setLoading(false) + return + } + try { + const res = await fetch("/api/auth/password-reset/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + }) + const data = await res.json() + if (res.ok) { + setSuccess(true) + } else { + setError(data.message || "Something went wrong. Please try again.") + } + } catch { + setError("An error occurred. Please try again.") + } finally { + setLoading(false) + } + } + + return ( +
+ +
+
+
+
+ +
+

JUNOHUB

+
+

+ Forgot Password +

+

+ Enter your email and we'll send you a link to reset your password. +

+
+ + {success ? ( +
+

+ If an account with that email exists, a password reset link has been sent. Check your inbox and spam folder. +

+ + Back to login + +
+ ) : ( +
+
+ +
+ + setEmail(e.target.value)} + placeholder="john@example.com" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" + /> +
+
+ {error && ( +

+ {error} +

+ )} + +
+ )} + +

+ + Back to login + +

+
+
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 57f78de..18fe794 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -160,6 +160,11 @@ export default function LoginPage() { className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200 disabled:opacity-50" />
+
+ + Forgot password? + +
{requiresMFA && ( diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 0000000..e6f1197 --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState, useEffect, Suspense } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { motion } from "framer-motion" +import { Lock, Loader2, Zap, CheckCircle2 } from "lucide-react" +import Link from "next/link" + +function ResetPasswordForm() { + const router = useRouter() + const searchParams = useSearchParams() + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState(false) + const token = searchParams.get("token") ?? "" + const email = searchParams.get("email") ?? "" + + useEffect(() => { + if (!token || !email) { + setError("Invalid or missing reset link. Please request a new password reset.") + } + }, [token, email]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + if (password !== confirmPassword) { + setError("Passwords do not match.") + setLoading(false) + return + } + if (password.length < 8) { + setError("Password must be at least 8 characters.") + setLoading(false) + return + } + try { + const res = await fetch("/api/auth/password-reset/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, email, password }), + }) + const data = await res.json() + if (res.ok) { + setSuccess(true) + setTimeout(() => router.push("/login"), 2000) + } else { + setError(data.message || "Failed to reset password. The link may have expired.") + } + } catch { + setError("An error occurred. Please try again.") + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+ +
+

Password has been reset successfully. Redirecting to login...

+ + Go to login now + +
+ ) + } + + if (!token || !email) { + return ( +
+

{error}

+ + Request a new reset link + +
+ ) + } + + return ( +
+
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" + /> +
+
+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" + /> +
+
+ {error && ( +

+ {error} +

+ )} + +
+ ) +} + +export default function ResetPasswordPage() { + return ( +
+ +
+
+
+
+ +
+

JUNOHUB

+
+

+ Reset Password +

+

+ Enter your new password below. +

+
+ + Loading...
}> + + + +

+ + Back to login + +

+
+ + + ) +} diff --git a/src/app/supplier/chat/page.tsx b/src/app/supplier/chat/page.tsx new file mode 100644 index 0000000..2b10418 --- /dev/null +++ b/src/app/supplier/chat/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ChatView } from "@/components/chat/ChatView" + +export default function SupplierChatPage() { + return +} diff --git a/src/app/supplier/layout.tsx b/src/app/supplier/layout.tsx index 57c069a..b85a985 100644 --- a/src/app/supplier/layout.tsx +++ b/src/app/supplier/layout.tsx @@ -40,13 +40,13 @@ export default async function SupplierLayout({ } return ( -
+
{/* Desktop Sidebar */}
- -
+ +
@@ -61,9 +61,11 @@ export default async function SupplierLayout({
-
- - {children} +
+ +
+ {children} +
diff --git a/src/app/supplier/page.tsx b/src/app/supplier/page.tsx index e1c055d..e36f9c4 100644 --- a/src/app/supplier/page.tsx +++ b/src/app/supplier/page.tsx @@ -14,6 +14,15 @@ import { AlertCircle } from "lucide-react" import Link from "next/link" +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell +} from "recharts" export default function SupplierDashboardPage() { const [stats, setStats] = useState({ @@ -230,12 +239,35 @@ export default function SupplierDashboardPage() {

Performance Overview

- Track your supplier performance metrics and growth over time. + Key metrics at a glance.

-
- -

Detailed charts coming soon

+
+ + + + + [value ?? 0, "Count"]} + /> + + {[0, 1, 2, 3].map((i) => ( + + ))} + + +
+

Products, connected vendors, pending connections, and new activity (last 7 days)

)} diff --git a/src/app/supplier/vendors/page.tsx b/src/app/supplier/vendors/page.tsx index fbafe0c..495201f 100644 --- a/src/app/supplier/vendors/page.tsx +++ b/src/app/supplier/vendors/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useState, Suspense } from "react" -import { useSearchParams } from "next/navigation" +import { useSearchParams, useRouter } from "next/navigation" import { Users, CheckCircle2, @@ -54,6 +54,7 @@ interface ProductSync { function SupplierVendorsContent() { const searchParams = useSearchParams() + const router = useRouter() const [connections, setConnections] = useState([]) const [productSyncs, setProductSyncs] = useState([]) const [loading, setLoading] = useState(true) @@ -201,6 +202,8 @@ function SupplierVendorsContent() { setInvitationToken("") setShowTokenInput(false) fetchConnections() + // Send supplier to JUNO CHAT so encryption keys auto-setup on first open + setTimeout(() => router.push("/supplier/chat"), 1500) } catch (err) { setError(err instanceof Error ? err.message : "Failed to accept invitation") } finally { diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx new file mode 100644 index 0000000..e823903 --- /dev/null +++ b/src/app/verify-email/page.tsx @@ -0,0 +1,113 @@ +"use client" + +import { useState, useEffect, Suspense } from "react" +import { useSearchParams } from "next/navigation" +import { motion } from "framer-motion" +import { Loader2, Zap, CheckCircle2, XCircle } from "lucide-react" +import Link from "next/link" + +function VerifyEmailContent() { + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [message, setMessage] = useState("") + const searchParams = useSearchParams() + const token = searchParams.get("token") ?? "" + const email = searchParams.get("email") ?? "" + + useEffect(() => { + if (!token || !email) { + setStatus("error") + setMessage("Invalid or missing verification link.") + return + } + setStatus("loading") + fetch("/api/auth/verify-email/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, email }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.message === "Email verified successfully") { + setStatus("success") + } else { + setStatus("error") + setMessage(data.message || "Verification failed.") + } + }) + .catch(() => { + setStatus("error") + setMessage("An error occurred. Please try again.") + }) + }, [token, email]) + + if (status === "loading" || status === "idle") { + return ( +
+ +

Verifying your email...

+
+ ) + } + + if (status === "success") { + return ( +
+
+ +
+

Email verified

+

Your email has been verified successfully. You can now sign in.

+ + Go to login + +
+ ) + } + + return ( +
+
+ +
+

Verification failed

+

{message}

+ + Back to login + +
+ ) +} + +export default function VerifyEmailPage() { + return ( +
+ +
+
+
+
+ +
+

JUNOHUB

+
+

+ Verify Email +

+
+ + Loading...
}> + + +
+ +
+ ) +} diff --git a/src/auth.ts b/src/auth.ts index 2ecfdfe..422e607 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -26,6 +26,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (token.role) { session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN" } + // Load chatPublicKey from DB so it stays current after chat-setup + const user = await prisma.user.findUnique({ + where: { id: token.id as string }, + select: { chatPublicKey: true }, + }) + if (user) session.user.chatPublicKey = user.chatPublicKey ?? undefined } return session }, diff --git a/src/components/MobileSidebar.tsx b/src/components/MobileSidebar.tsx index cddc534..edf5fb6 100644 --- a/src/components/MobileSidebar.tsx +++ b/src/components/MobileSidebar.tsx @@ -18,12 +18,14 @@ export function MobileSidebar() { const pathname = usePathname() useEffect(() => { - setMounted(true) + const timer = setTimeout(() => setMounted(true), 0) + return () => clearTimeout(timer) }, []) // Close sidebar when route changes useEffect(() => { - setIsOpen(false) + const timer = setTimeout(() => setIsOpen(false), 0) + return () => clearTimeout(timer) }, [pathname]) // Prevent body scroll when sidebar is open diff --git a/src/components/MobileSupplierSidebar.tsx b/src/components/MobileSupplierSidebar.tsx index 5910586..10fff0d 100644 --- a/src/components/MobileSupplierSidebar.tsx +++ b/src/components/MobileSupplierSidebar.tsx @@ -12,7 +12,8 @@ export function MobileSupplierSidebar() { // Close sidebar when route changes useEffect(() => { - setIsOpen(false) + const timer = setTimeout(() => setIsOpen(false), 0) + return () => clearTimeout(timer) }, [pathname]) // Prevent body scroll when sidebar is open diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index 5a9a4a9..e7a2a18 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -2,12 +2,14 @@ import { motion } from "framer-motion" import { ReactNode } from "react" +import { cn } from "@/lib/utils" interface PageTransitionProps { children: ReactNode + className?: string } -export default function PageTransition({ children }: PageTransitionProps) { +export default function PageTransition({ children, className }: PageTransitionProps) { return ( {children} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 61aa676..1e6e54c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -73,6 +73,12 @@ const routes = [ { label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag }, { label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail }, ] + }, + { + label: "JUNO CHAT", + icon: Mail, + href: "/dashboard/chat", + color: "text-pink-500", } ] diff --git a/src/components/SupplierSidebar.tsx b/src/components/SupplierSidebar.tsx index c468d7f..8467f7f 100644 --- a/src/components/SupplierSidebar.tsx +++ b/src/components/SupplierSidebar.tsx @@ -7,10 +7,8 @@ import { Package, ShoppingCart, Users, - Settings, + Mail, Zap, - FileText, - TrendingUp } from "lucide-react" import { cn } from "@/lib/utils" @@ -40,11 +38,11 @@ const supplierRoutes = [ color: "text-purple-500", }, { - label: "Settings", - icon: Settings, - href: "/supplier/settings", - color: "text-slate-400", - } + label: "JUNO CHAT", + icon: Mail, + href: "/supplier/chat", + color: "text-pink-500", + }, ] export function SupplierSidebar() { diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 73bf4f1..39b5ca3 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -40,8 +40,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) { | ThemeMode | null if (stored === "light" || stored === "default") { - setThemeState(stored) - applyTheme(stored) + const timer = setTimeout(() => { + setThemeState(stored) + applyTheme(stored) + }, 0) + return () => clearTimeout(timer) } else { applyTheme("default") } diff --git a/src/components/UserAccountMenu.tsx b/src/components/UserAccountMenu.tsx index f74cc99..104a2a7 100644 --- a/src/components/UserAccountMenu.tsx +++ b/src/components/UserAccountMenu.tsx @@ -31,7 +31,8 @@ export default function UserAccountMenu() { const handleSettings = () => { setIsOpen(false) - router.push("/dashboard/settings") + const settingsPath = session?.user?.role === "SUPPLIER" ? "/supplier/settings" : "/dashboard/settings" + router.push(settingsPath) } const handleLogout = async () => { diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx new file mode 100644 index 0000000..55d4ed3 --- /dev/null +++ b/src/components/chat/ChatView.tsx @@ -0,0 +1,805 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { usePathname } from "next/navigation" +import { getSession } from "next-auth/react" +import { useChatCrypto, PRIVATE_KEY_STORAGE_KEY, PUBLIC_KEY_STORAGE_KEY } from "@/hooks/useChatCrypto" +import { ChatCrypto } from "@/lib/chat-crypto" +import { useTheme } from "@/components/ThemeProvider" +import { + Send, + Search, + ShieldCheck, + Loader2, + Plus, + Lock, + MessageSquare, + CheckCheck, + Package, + ShoppingCart, + X, + Settings +} from "lucide-react" +import { cn } from "@/lib/utils" +import { format } from "date-fns" + +interface Contact { + id: string + name: string + email: string + image: string | null + publicKey: string | null + role: string +} + +interface Message { + id: string + senderId: string + receiverId: string + content: string + createdAt: string +} + +function AlertCircle(props: React.SVGProps) { + return ( + + + + + + ) +} + +type AttachOrder = { id: number; name: string; total_price: string; currency?: string; storeId?: string; storeName?: string } +type AttachProduct = { id: number | string; title: string; storeId?: string; storeName?: string } + +export function ChatView() { + const pathname = usePathname() + const isSupplier = pathname.startsWith("/supplier") + const { theme } = useTheme() + const isLight = theme === "light" + const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto() + + const [contacts, setContacts] = useState([]) + const [selectedContact, setSelectedContact] = useState(null) + const [messages, setMessages] = useState([]) + const [newMessage, setNewMessage] = useState("") + const [loadingContacts, setLoadingContacts] = useState(true) + const [loadingMessages, setLoadingMessages] = useState(false) + const [sending, setSending] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [contactsError, setContactsError] = useState(null) + const [messagesError, setMessagesError] = useState(null) + const [sendError, setSendError] = useState(null) + + const [attachOpen, setAttachOpen] = useState(false) + const [attachLoading, setAttachLoading] = useState(false) + const [attachOrders, setAttachOrders] = useState([]) + const [attachProducts, setAttachProducts] = useState([]) + const attachRef = useRef(null) + + const messagesEndRef = useRef(null) + const autoSetupAttemptedRef = useRef(false) + + const handleSetup = async () => { + try { + setContactsError(null) + await setupKeys() + await getSession() + fetchContacts() + } catch (error) { + console.error("Setup failed", error) + setContactsError("Failed to set up encryption. Please try again.") + } + } + + // Auto-setup chat keys when user has none (e.g. supplier who just accepted invitation) + useEffect(() => { + if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current) return + autoSetupAttemptedRef.current = true + setContactsError(null) + setupKeys() + .then(() => getSession()) + .then(() => fetchContacts()) + .catch((err) => { + console.error("Auto setup failed", err) + autoSetupAttemptedRef.current = false + setContactsError("Encryption setup failed. Click below to try again.") + }) + }, [cryptoLoading, hasKeys, setupKeys]) + + const fetchContacts = async () => { + try { + setLoadingContacts(true) + setContactsError(null) + const res = await fetch("/api/chat/contacts") + if (res.ok) { + const data = await res.json() + setContacts(data.contacts) + } else { + setContactsError("Failed to load contacts. Please try again.") + } + } catch (error) { + console.error("Fetch contacts error", error) + setContactsError("Failed to load contacts. Please try again.") + } finally { + setLoadingContacts(false) + } + } + + const fetchMessages = async (contact: Contact, silent = false) => { + if (!privateKey) return + try { + if (!silent) { + setLoadingMessages(true) + setMessagesError(null) + } + const res = await fetch(`/api/chat/messages?contactId=${contact.id}`) + if (res.ok) { + const data = await res.json() + const decryptedMessages = await Promise.all( + data.messages.map(async (m: any) => { + try { + const decrypted = await ChatCrypto.decryptMessage( + m.encryptedContent, + m.senderId === contact.id ? m.encryptedKeyForReceiver : m.encryptedKeyForSender, + m.iv, + privateKey + ) + return { + id: m.id, + senderId: m.senderId, + receiverId: m.receiverId, + content: decrypted, + createdAt: m.createdAt + } + } catch (err) { + return { + id: m.id, + senderId: m.senderId, + receiverId: m.receiverId, + content: "[Encrypted Message - Error Decrypting]", + createdAt: m.createdAt + } + } + }) + ) + setMessages(decryptedMessages) + } else if (!silent) { + setMessagesError("Failed to load messages. Please try again.") + } + } catch (error) { + console.error("Fetch messages error", error) + if (!silent) setMessagesError("Failed to load messages. Please try again.") + } finally { + if (!silent) setLoadingMessages(false) + } + } + + useEffect(() => { + if (hasKeys) fetchContacts() + }, [hasKeys]) + + useEffect(() => { + if (selectedContact) { + fetchMessages(selectedContact) + } else { + setMessages([]) + } + }, [selectedContact, privateKey]) + + // Poll for new messages when a contact is selected + const POLL_INTERVAL_MS = 8000 + useEffect(() => { + if (!selectedContact || !privateKey) return + const interval = setInterval(() => { + fetchMessages(selectedContact, true) + }, POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, [selectedContact?.id, privateKey]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages]) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (attachRef.current && !attachRef.current.contains(e.target as Node)) { + setAttachOpen(false) + } + } + if (attachOpen) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [attachOpen]) + + const openAttach = async () => { + setAttachOpen(true) + setAttachLoading(true) + setAttachOrders([]) + setAttachProducts([]) + try { + if (isSupplier) { + const res = await fetch("/api/supplier/products") + if (res.ok) { + const data = await res.json() + const list = (data.products || []).map((p: { id: string; title?: string; shopifyProductTitle?: string }) => ({ + id: p.id, + title: p.title || p.shopifyProductTitle || "Product" + })) + setAttachProducts(list) + } + } else { + const [ordersRes, productsRes] = await Promise.all([ + fetch("/api/chat/attach-orders"), + fetch("/api/chat/attach-products") + ]) + if (ordersRes.ok) { + const ordersData = await ordersRes.json() + setAttachOrders(ordersData.orders || []) + } + if (productsRes.ok) { + const productsData = await productsRes.json() + setAttachProducts(productsData.products || []) + } + } + } catch { + // Silent fail + } finally { + setAttachLoading(false) + } + } + + const insertOrder = (order: AttachOrder) => { + const storePart = order.storeName ? ` · ${order.storeName}` : "" + const line = `[Order ${order.name}${storePart} - ${order.currency || ""} ${order.total_price}]\n` + setNewMessage(prev => (prev ? prev + line : line.trim())) + setAttachOpen(false) + } + + const insertProduct = (product: AttachProduct) => { + const storePart = product.storeName ? ` · ${product.storeName}` : "" + const line = `[Product: ${product.title}${storePart}${typeof product.id === "number" ? ` (#${product.id})` : ""}]\n` + setNewMessage(prev => (prev ? prev + line : line.trim())) + setAttachOpen(false) + } + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault() + if (!newMessage.trim() || !selectedContact || !selectedContact.publicKey || !privateKey) return + + try { + setSending(true) + setSendError(null) + let meData: { user?: { chatPublicKey?: string } } = {} + try { + const session = await getSession() + meData = { user: session?.user ? { chatPublicKey: (session.user as { chatPublicKey?: string }).chatPublicKey } : {} } + } catch { + const res = await fetch("/api/auth/session") + meData = await res.json() + } + let myPublicKey = meData.user?.chatPublicKey + + if (!myPublicKey && getStoredPublicKey) { + const storedPublic = getStoredPublicKey() + if (storedPublic) { + const syncRes = await fetch("/api/user/chat-setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ publicKey: storedPublic }) + }) + if (syncRes.ok) myPublicKey = storedPublic + } + } + + if (!myPublicKey) { + setSendError("Encryption key not ready. Please refresh the page and try again.") + return + } + + const encrypted = await ChatCrypto.encryptMessage( + newMessage, + myPublicKey, + selectedContact.publicKey + ) + + const res = await fetch("/api/chat/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + receiverId: selectedContact.id, + ...encrypted + }) + }) + + if (res.ok) { + const msg = await res.json() + const optimiticMsg: Message = { + id: msg.id, + senderId: msg.senderId, + receiverId: msg.receiverId, + content: newMessage, + createdAt: msg.createdAt + } + setMessages(prev => [...prev, optimiticMsg]) + setNewMessage("") + } else { + setSendError("Failed to send message. Please try again.") + } + } catch (error) { + console.error("Send message error", error) + setSendError("Failed to send message. Please try again.") + } finally { + setSending(false) + } + } + + if (cryptoLoading) { + return ( +
+ +

Initializing JUNO CHAT...

+
+ ) + } + + if (!hasKeys) { + return ( +
+
+ +
+

Enable JUNO CHAT

+

+ JUNO CHAT uses industry-standard AES-256 E2E Encryption. + Set up your secure keys to start chatting with your connections. +

+ +

+ Private keys never leave your browser +

+
+ ) + } + + const filteredContacts = contacts.filter(c => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.email.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + return ( +
+ {/* Sidebar - Contacts (fixed; only inner list scrolls) */} +
+
+

JUNO CHAT

+
+
+ +
+
+
+ +
+
+ + setSearchQuery(e.target.value)} + className={cn( + "w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all font-medium", + isLight ? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400" : "bg-white/5 border border-white/10 text-white placeholder:text-slate-600" + )} + /> +
+ {contactsError && ( +
+ {contactsError} + +
+ )} +
+ +
+ {loadingContacts ? ( +
+ + Loading... +
+ ) : filteredContacts.length === 0 ? ( +
+ +

No Contacts

+
+ ) : ( +
+ {filteredContacts.map(contact => ( + + ))} +
+ )} +
+ +
+
+
+ Y +
+
+

YOU

+

+ Encrypted Ready +

+
+ +
+
+
+ + {/* Chat Area - fixed header and input; only messages scroll */} +
+ {selectedContact ? ( + <> +
+
+ +
+ {selectedContact.name[0].toUpperCase()} +
+
+

+ {selectedContact.name} +

+
+
+ Active Now +
+ + End-to-End + +
+
+
+
+ +
+ {messagesError && ( +
+ {messagesError} + +
+ )} + {loadingMessages ? ( +
+ +

Syncing History...

+
+ ) : messages.length === 0 ? ( +
+ +

Secure Connection Started

+

Messages are encrypted before leaving your device.

+
+ ) : ( + messages.map((msg, idx) => { + const isMe = msg.senderId !== selectedContact.id + const prevMsg = idx > 0 ? messages[idx - 1] : null + const showTime = !prevMsg || format(new Date(msg.createdAt), "HH:mm") !== format(new Date(prevMsg.createdAt), "HH:mm") + + return ( +
+
+ {msg.content} +
+ {isMe && } +
+
+ {showTime && ( + + {format(new Date(msg.createdAt), "HH:mm")} + + )} +
+ ) + }) + )} +
+
+ +
+ {sendError && ( +
+ {sendError} +
+ {sendError.includes("Encryption key not ready") && ( + + )} + +
+
+ )} + {!selectedContact.publicKey ? ( +
+

+ Connection Not Ready +

+

+ This contact hasn't set up their JUNO CHAT keys yet. +

+

+ Ask them to open JUNO CHAT once; keys are set up automatically. +

+
+ ) : ( +
+
+ + {attachOpen && ( +
+
+ + {isSupplier ? "Attach product" : "Attach order or product"} + + +
+
+ {attachLoading ? ( +
+ +
+ ) : ( + <> + {!isSupplier && attachOrders.length > 0 && ( +
+

+ Orders +

+
+ {attachOrders.map((order) => ( + + ))} +
+
+ )} + {attachProducts.length > 0 && ( +
+

+ Products +

+
+ {attachProducts.map((product) => ( + + ))} +
+
+ )} + {!attachLoading && attachOrders.length === 0 && attachProducts.length === 0 && ( +

+ {isSupplier ? "No products to attach." : "No orders or products found."} +

+ )} + + )} +
+
+ )} +
+