fix: so many fixed and others
This commit is contained in:
parent
bbeb43adb1
commit
a86c805cda
37
.dockerignore
Normal file
37
.dockerignore
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Never copy host install over image `npm ci` (causes "cannot replace directory with file" on @auth/core, etc.)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Next / caches
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.turbo
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# VCS & editor
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Logs & debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Test & coverage
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|
||||||
|
# Local env only — CI may still place `.env` in the build context for NEXT_PUBLIC_* / build
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Large / runtime-only paths (keep build context small)
|
||||||
|
public/uploads
|
||||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
}
|
||||||
67
docs/qa/input-inventory.md
Normal file
67
docs/qa/input-inventory.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# JUNOHUB input inventory matrix (living document)
|
||||||
|
|
||||||
|
Columns: **Surface** | **Field / action** | **Type** | **API route** | **Method** | **Risk** | **Test ID** | **Server validation** | **Client validation**
|
||||||
|
|
||||||
|
Risk: `L` low, `M` medium, `H` high (PII, public, money, files).
|
||||||
|
|
||||||
|
## A. Auth and marketing
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| login | email, password, mfaCode | string | `/api/auth/login` | POST | H | AUTH-LOGIN | Zod `authLoginSchema` | Basic required |
|
||||||
|
| signup | name, email, password, role | mixed | `/api/auth/register` | POST | H | AUTH-REG | Zod `authRegisterSchema` | Client `authRegisterSchema.safeParse` + maxLength |
|
||||||
|
| forgot-password | email | string | `/api/auth/password-reset/request` | POST | M | AUTH-PW-REQ | `emailSchema` + rate limit | email format |
|
||||||
|
| reset-password | email, password, token | string | `/api/auth/password-reset/reset` | POST | H | AUTH-PW-RESET | Zod | min length UI |
|
||||||
|
| verify-email | token | string | `/api/auth/verify-email/verify` | POST | M | AUTH-VERIFY | rate limit + handler | — |
|
||||||
|
| LandingFooter | newsletter email | string | `/api/newsletter/subscribe` | POST | M | NEWS-01 | Zod + rate limit | maxLength 254 + server mirror |
|
||||||
|
|
||||||
|
## B. Onboarding
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| onboarding-client | business name, etc. | string | `/api/onboarding/complete` | POST | H | ONB-01 | Handler checks | required fields |
|
||||||
|
| shopify onboarding | domain, tokens | string | `/api/onboarding/shopify` | POST | H | ONB-SHOPIFY | Handler | trim |
|
||||||
|
|
||||||
|
## C. Vendor dashboard
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| settings/account | businessName, passwords | string | `/api/settings/account` | PUT | H | SET-ACCT | Zod `accountUpdateSchema` | `accountUpdateSchema` + maxLength + `isStrongPassword` |
|
||||||
|
| settings/security | MFA toggles | mixed | `/api/settings/mfa/*` | POST | H | SET-MFA | rate limit + handlers | — |
|
||||||
|
| inventory | sync product | JSON | `/api/products/sync` | POST | M | SYNC-POST | Zod `productSyncRequestSchema` + JSON size cap | supplier selected |
|
||||||
|
| stores | add store | mixed | `/api/stores` | POST | H | STORE-POST | Handler | — |
|
||||||
|
| suppliers invite | connectToAllStores | bool | `/api/vendors/suppliers/[id]/invite` | POST | M | INV-POST | authz + handler | — |
|
||||||
|
| juno-engine templates | name, body, html | string | `/api/juno-engine/templates` | POST | M | TPL-POST | Zod `junoTemplateCreateSchema` | max length |
|
||||||
|
| KB query | query, topK | string/num | `/api/stores/[id]/kb/query` | POST | M | KB-Q | topK capped | — |
|
||||||
|
| knowledge-template | JSON | JSON | `/api/stores/[id]/knowledge-template` | PUT | M | KB-TPL | Zod | UI maxLength matches `KNOWLEDGE_TEMPLATE_*_MAX` |
|
||||||
|
| storefront customize | colors, text | string | `/api/stores/[id]` | PATCH | M | SF-CHAT | Handler | — |
|
||||||
|
| chat | message, attachment | mixed | `/api/chat/messages` | POST | M | CHAT-POST | Zod + rate limit | max length |
|
||||||
|
| uploads | logo / images | file | `/api/uploads/*` | POST | H | UPL-01 | MIME/size | accept attr |
|
||||||
|
|
||||||
|
## D. Supplier portal
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| supplier/products | title, price, sku, … | mixed | `/api/supplier/products` | POST | M | SUP-P-POST | Zod `supplierProductCreateSchema` | maxLength mirror |
|
||||||
|
| supplier/products | edit synced / own | mixed | `/api/supplier/products/[id]` | PUT | M | SUP-P-PUT | Zod `supplierProductUpdateSchema` | maxLength mirror |
|
||||||
|
| supplier settings | company | string | `/api/supplier/profile` | POST | M | SUP-PROF | Zod `supplierProfileUpsertSchema` | — |
|
||||||
|
| product sync decision | accept / reject | string | `/api/products/sync/[syncId]` | PUT | M | SYNC-DEC | Zod `productSyncDecisionSchema` | — |
|
||||||
|
| notifications | mark read | mixed | `/api/notifications` | POST | L | NOTIF-READ | Zod `notificationsMarkReadSchema` | — |
|
||||||
|
| push | subscribe / unsubscribe | JSON | `/api/notifications/subscribe` | POST/DELETE | M | PUSH-01 | Zod push schemas | browser API |
|
||||||
|
|
||||||
|
## E. Admin
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| admin/* | varies | mixed | `/api/admin/*` | * | H | ADM-* | Per-route auth + validation | role gate |
|
||||||
|
|
||||||
|
## F. Public widget
|
||||||
|
|
||||||
|
| Surface | Field | Type | API | Method | Risk | Test ID | Server | Client |
|
||||||
|
|---------|-------|------|-----|--------|------|---------|--------|--------|
|
||||||
|
| storefront widget | shop, visitorId, content | string | `/api/storefront-chat/messages` | POST | H | SF-W-MSG | Zod + rate limit | length |
|
||||||
|
|
||||||
|
## G. Follow-up
|
||||||
|
|
||||||
|
- Extend this table for every new form.
|
||||||
|
- Link failing tests to **Test ID** in PR descriptions when fixing validation bugs.
|
||||||
11
docs/qa/pr-validation-checklist.md
Normal file
11
docs/qa/pr-validation-checklist.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# PR checklist: validation and API safety
|
||||||
|
|
||||||
|
Use this for any change that adds or modifies user input, public endpoints, or tenant-scoped data.
|
||||||
|
|
||||||
|
1. **Server schema**: Mutating handlers (`POST` / `PUT` / `PATCH` / `DELETE`) that read JSON should validate with Zod (`safeParse`) and return structured `400` responses (see `validationErrorResponse` in `src/lib/api-schemas/validate.ts`).
|
||||||
|
2. **Rate limits**: Unauthenticated or abuse-prone endpoints should use `applyRateLimit` from `src/lib/rate-limit.ts` with an explicit window and max.
|
||||||
|
3. **Authorization**: For routes with `storeId`, `supplierId`, `connectionId`, `orderId`, etc., confirm the authenticated user owns or is linked to that resource before mutating.
|
||||||
|
4. **Client parity**: High-traffic forms should mirror server limits (`maxLength`, optional Zod pre-check) for UX; never rely on client checks alone for security.
|
||||||
|
5. **HTML / XSS**: If user content is rendered as HTML, sanitize or use `textContent` / escaped strings; see `src/lib/sanitize/htmlPolicy.ts`.
|
||||||
|
6. **Tests**: Add or extend Zod unit tests in `src/lib/api-schemas/schemas.test.ts` for new schemas; add route-level tests when authz behavior is non-obvious.
|
||||||
|
7. **E2E (optional)**: With the dev server running, `PLAYWRIGHT_BASE_URL=http://127.0.0.1:3000 pnpm exec playwright test`.
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// API / form validation governance: see docs/qa/pr-validation-checklist.md
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|||||||
127
package-lock.json
generated
127
package-lock.json
generated
@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "pdc-poc",
|
"name": "pdc-poc",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.41.0",
|
"@auth/core": "^0.41.0",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
@ -46,8 +47,9 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "20.19.39",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@ -1464,6 +1466,7 @@
|
|||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
|
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.1.0",
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
@ -1483,6 +1486,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
|
||||||
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
|
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
@ -2757,6 +2761,7 @@
|
|||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "^1.4.3",
|
"@emnapi/core": "^1.4.3",
|
||||||
@ -3011,6 +3016,21 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/adapter-pg": {
|
"node_modules/@prisma/adapter-pg": {
|
||||||
"version": "7.6.0",
|
"version": "7.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz",
|
||||||
@ -4614,6 +4634,60 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
@ -4660,6 +4734,7 @@
|
|||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
@ -4748,9 +4823,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.33",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@ -10064,6 +10139,50 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
@ -3,12 +3,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "prisma generate",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"prebuild": "npx prisma generate",
|
"prebuild": "npx prisma generate",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
|
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
|
||||||
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "prisma migrate reset",
|
||||||
@ -55,8 +57,9 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "20.19.39",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
17
playwright.config.ts
Normal file
17
playwright.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test"
|
||||||
|
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
})
|
||||||
8711
pnpm-lock.yaml
generated
Normal file
8711
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable (IF NOT EXISTS: safe when column was added earlier via `db push` or manual SQL)
|
||||||
|
ALTER TABLE "CachedProduct" ADD COLUMN IF NOT EXISTS "metafields" JSONB;
|
||||||
@ -155,6 +155,8 @@ model CachedProduct {
|
|||||||
status String?
|
status String?
|
||||||
variants Json // Store variants as JSON
|
variants Json // Store variants as JSON
|
||||||
images Json // Store images as JSON
|
images Json // Store images as JSON
|
||||||
|
/** Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search. */
|
||||||
|
metafields Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastSyncedAt DateTime @default(now())
|
lastSyncedAt DateTime @default(now())
|
||||||
|
|||||||
39
scripts/prisma-baseline-existing-db.sh
Executable file
39
scripts/prisma-baseline-existing-db.sh
Executable file
@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Use when `prisma migrate deploy` fails on the FIRST migration with errors like:
|
||||||
|
# type "UserRole" already exists
|
||||||
|
# That means the database was created with `db push`, SQL dumps, or old tooling — not via
|
||||||
|
# migrate history — but `_prisma_migrations` is empty or out of sync.
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Clears the failed migration record (if any).
|
||||||
|
# 2. Marks every migration folder EXCEPT the last one listed below as already applied
|
||||||
|
# (adjust SKIP_DEPLOY if your DB is missing columns — then run deploy manually).
|
||||||
|
# 3. Runs `prisma migrate deploy` so pending migrations (e.g. CachedProduct.metafields) apply.
|
||||||
|
#
|
||||||
|
# If your database is EMPTY or disposable, prefer: wipe the Postgres volume and run only
|
||||||
|
# pnpm prisma migrate deploy
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Last migration to actually run via `deploy` (must match a folder under prisma/migrations).
|
||||||
|
# Change if you need deploy to apply more than one pending migration.
|
||||||
|
LAST_TO_APPLY="20260411210000_cached_product_metafields"
|
||||||
|
|
||||||
|
echo "==> Resolving failed init migration (ignore errors if none pending)"
|
||||||
|
pnpm exec prisma migrate resolve --rolled-back "20260216152711_init_schema" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> Marking historical migrations as already applied (DB already matches)"
|
||||||
|
for dir in $(ls -1 prisma/migrations | grep -E '^[0-9]{14}_' | sort); do
|
||||||
|
if [[ "$dir" == "$LAST_TO_APPLY" ]]; then
|
||||||
|
echo " (skip) $dir — will be applied by migrate deploy"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo " --applied $dir"
|
||||||
|
pnpm exec prisma migrate resolve --applied "$dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> prisma migrate deploy"
|
||||||
|
pnpm exec prisma migrate deploy
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
import { handleApiError } from "@/lib/errors"
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
import { authVerifyEmailBodySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -13,15 +14,12 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { token, email } = body
|
const parsed = authVerifyEmailBodySchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
if (!token || !email) {
|
return validationErrorResponse(parsed.error)
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Token and email are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { token, email } = parsed.data
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
const verificationToken = await prisma.verificationToken.findUnique({
|
const verificationToken = await prisma.verificationToken.findUnique({
|
||||||
|
|||||||
@ -3,10 +3,16 @@ import { prisma } from "@/lib/prisma"
|
|||||||
import { verify } from "otplib"
|
import { verify } from "otplib"
|
||||||
import { decryptString } from "@/lib/encryption"
|
import { decryptString } from "@/lib/encryption"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { authVerifyMfaSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { email, code } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = authVerifyMfaSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { email, code } = parsed.data
|
||||||
|
|
||||||
// Rate limit MFA verification attempts per IP
|
// Rate limit MFA verification attempts per IP
|
||||||
const rate = await applyRateLimit(req, "auth:mfa-verify")
|
const rate = await applyRateLimit(req, "auth:mfa-verify")
|
||||||
@ -17,13 +23,6 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email || !code) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Email and verification code are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use raw query to access MFA fields until Prisma client is fully regenerated
|
// Use raw query to access MFA fields until Prisma client is fully regenerated
|
||||||
const userResult = await prisma.$queryRaw<
|
const userResult = await prisma.$queryRaw<
|
||||||
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
|
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
|
||||||
|
|||||||
58
src/app/api/chat/messages/post.test.ts
Normal file
58
src/app/api/chat/messages/post.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import { POST } from "./route"
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({
|
||||||
|
auth: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/push", () => ({
|
||||||
|
sendPushNotification: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
applyRateLimit: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
user: { findUnique: vi.fn() },
|
||||||
|
chatMessage: { create: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/lib/chat/chatThreadAccess", () => ({
|
||||||
|
resolveB2bChatPair: vi.fn(),
|
||||||
|
hasChatConnectionForStore: vi.fn(),
|
||||||
|
countConnectionsForPair: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/chat/messages authorization", () => {
|
||||||
|
it("returns 403 when the chat pair cannot use the requested store", async () => {
|
||||||
|
const { auth } = await import("@/auth")
|
||||||
|
const access = await import("@/lib/chat/chatThreadAccess")
|
||||||
|
|
||||||
|
vi.mocked(auth).mockResolvedValue({ user: { id: "user-1" } } as never)
|
||||||
|
vi.mocked(access.resolveB2bChatPair).mockResolvedValue({
|
||||||
|
vendorUserId: "v",
|
||||||
|
supplierUserId: "s",
|
||||||
|
} as never)
|
||||||
|
vi.mocked(access.hasChatConnectionForStore).mockResolvedValue(false)
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/api/chat/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
receiverId: "other-user",
|
||||||
|
storeId: "store-foreign",
|
||||||
|
content: "hello",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await POST(req)
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -7,6 +7,9 @@ import {
|
|||||||
hasChatConnectionForStore,
|
hasChatConnectionForStore,
|
||||||
resolveB2bChatPair,
|
resolveB2bChatPair,
|
||||||
} from "@/lib/chat/chatThreadAccess"
|
} from "@/lib/chat/chatThreadAccess"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { chatMessagePostSchema } from "@/lib/api-schemas/public"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -15,17 +18,26 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Unauthorized", { status: 401 })
|
return new NextResponse("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const rate = await applyRateLimit(req, "chat:messages", {
|
||||||
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = body
|
windowMs: 60_000,
|
||||||
|
maxRequests: 120,
|
||||||
if (!receiverId || typeof receiverId !== "string") {
|
})
|
||||||
return new NextResponse("Missing fields", { status: 400 })
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storeIdRaw || typeof storeIdRaw !== "string") {
|
const raw = await req.json().catch(() => ({}))
|
||||||
return new NextResponse("Missing storeId", { status: 400 })
|
const parsed = chatMessagePostSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = parsed.data
|
||||||
|
const normalizedKind = kind ?? "TEXT"
|
||||||
|
|
||||||
const pair = await resolveB2bChatPair(session.user.id, receiverId)
|
const pair = await resolveB2bChatPair(session.user.id, receiverId)
|
||||||
if (!pair) {
|
if (!pair) {
|
||||||
return new NextResponse("Invalid chat participant", { status: 403 })
|
return new NextResponse("Invalid chat participant", { status: 403 })
|
||||||
@ -40,20 +52,6 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Store not connected for this chat", { status: 403 })
|
return new NextResponse("Store not connected for this chat", { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAT_KINDS = ["TEXT", "PRODUCT", "ORDER"] as const
|
|
||||||
const normalizedKind =
|
|
||||||
typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT"
|
|
||||||
|
|
||||||
if (normalizedKind === "TEXT") {
|
|
||||||
if (content != null && typeof content !== "string") {
|
|
||||||
return new NextResponse("Invalid content", { status: 400 })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!attachment || typeof attachment !== "object") {
|
|
||||||
return new NextResponse("Missing attachment for typed message", { status: 400 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUser = await prisma.user.findUnique({
|
const sessionUser = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
@ -75,7 +73,7 @@ export async function POST(req: Request) {
|
|||||||
receiverId,
|
receiverId,
|
||||||
storeId: storeIdRaw,
|
storeId: storeIdRaw,
|
||||||
kind: normalizedKind as any,
|
kind: normalizedKind as any,
|
||||||
content: (typeof content === "string" ? content : "") as any,
|
content: content as any,
|
||||||
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { flaggedEmailCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
async function getActiveStoreId(userId: string): Promise<string | null> {
|
async function getActiveStoreId(userId: string): Promise<string | null> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@ -72,25 +73,14 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "No active store found" }, { status: 400 })
|
return NextResponse.json({ message: "No active store found" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { subject, from, reason, priority, messageId } = body as {
|
const parsed = flaggedEmailCreateSchema.safeParse(raw)
|
||||||
subject?: string
|
if (!parsed.success) {
|
||||||
from?: string
|
return validationErrorResponse(parsed.error)
|
||||||
reason?: string
|
|
||||||
priority?: "LOW" | "MEDIUM" | "HIGH"
|
|
||||||
messageId?: string
|
|
||||||
}
|
}
|
||||||
|
const { subject, from, reason, priority, messageId } = parsed.data
|
||||||
|
|
||||||
if (!subject || !from || !reason) {
|
const priorityVal = priority ?? "MEDIUM"
|
||||||
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({
|
const created = await prisma.flaggedEmail.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { auth } from "@/auth"
|
|||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { junoTemplatePatchSchema } from "@/lib/api-schemas/vendorMutations"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
type TemplateRow = {
|
type TemplateRow = {
|
||||||
id: string
|
id: string
|
||||||
@ -130,11 +132,11 @@ export async function PUT(
|
|||||||
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => null)
|
const raw = await req.json().catch(() => ({}))
|
||||||
if (!body) {
|
const parsed = junoTemplatePatchSchema.safeParse(raw)
|
||||||
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
intent,
|
intent,
|
||||||
@ -143,15 +145,7 @@ export async function PUT(
|
|||||||
body: templateBody,
|
body: templateBody,
|
||||||
variables,
|
variables,
|
||||||
isHtml,
|
isHtml,
|
||||||
} = body as {
|
} = parsed.data
|
||||||
name?: string
|
|
||||||
intent?: string
|
|
||||||
mood?: string
|
|
||||||
description?: string | null
|
|
||||||
body?: string
|
|
||||||
variables?: unknown
|
|
||||||
isHtml?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||||
UPDATE "Template"
|
UPDATE "Template"
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
|
import { junoTemplateCreateSchema } from "@/lib/api-schemas/vendorMutations"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
type TemplateRow = {
|
type TemplateRow = {
|
||||||
id: string
|
id: string
|
||||||
@ -122,28 +124,13 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => null)
|
const raw = await req.json().catch(() => ({}))
|
||||||
if (!body) {
|
const parsed = junoTemplateCreateSchema.safeParse(raw)
|
||||||
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, intent, mood, description, body: templateBody, variables, isHtml } =
|
const { name, intent, mood, description, body: templateBody, variables, isHtml } =
|
||||||
body as {
|
parsed.data
|
||||||
name?: string
|
|
||||||
intent?: string
|
|
||||||
mood?: string
|
|
||||||
description?: string | null
|
|
||||||
body?: string
|
|
||||||
variables?: unknown
|
|
||||||
isHtml?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name || !intent || !mood || !templateBody) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing required fields (name, intent, mood, body)" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +1,39 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { newsletterSubscribeSchema } from "@/lib/api-schemas/public"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
import { escapeHtmlText } from "@/lib/sanitize/escapeHtml"
|
||||||
|
|
||||||
// This is a simple mock-up of a newsletter subscription API
|
// This is a simple mock-up of a newsletter subscription API
|
||||||
// In a real app, you would save the email to a database (e.g., Prisma)
|
// In a real app, you would save the email to a database (e.g., Prisma)
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { email } = await req.json()
|
const rate = await applyRateLimit(req, "public:newsletter", {
|
||||||
|
windowMs: 60_000,
|
||||||
if (!email || !email.includes("@")) {
|
maxRequests: 20,
|
||||||
|
})
|
||||||
|
if (!rate.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Invalid email address" },
|
{ message: "Too many requests. Please try again later." },
|
||||||
{ status: 400 }
|
{ status: 429 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = newsletterSubscribeSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { email } = parsed.data
|
||||||
|
|
||||||
// 1. In a production app, you would check if the email already exists
|
// 1. In a production app, you would check if the email already exists
|
||||||
// and then save it to your 'Newsletter' table.
|
// and then save it to your 'Newsletter' table.
|
||||||
// For now, we'll proceed to sending the welcome email.
|
// For now, we'll proceed to sending the welcome email.
|
||||||
|
|
||||||
const subject = "Welcome to the JUNO Newsletter! ⚡"
|
const subject = "Welcome to the JUNO Newsletter! ⚡"
|
||||||
const emailHtml = generateNewsletterWelcomeHtml(email)
|
const emailHtml = generateNewsletterWelcomeHtml(escapeHtmlText(email))
|
||||||
|
|
||||||
const result = await sendEmail({
|
const result = await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { notificationsMarkReadSchema } from "@/lib/api-schemas/vendorMutations"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/notifications
|
* GET /api/notifications
|
||||||
@ -61,8 +63,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { notificationIds, markAllAsRead } = body
|
const parsed = notificationsMarkReadSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { notificationIds, markAllAsRead } = parsed.data
|
||||||
|
|
||||||
if (markAllAsRead) {
|
if (markAllAsRead) {
|
||||||
// Mark all notifications as read for this user
|
// Mark all notifications as read for this user
|
||||||
@ -80,26 +86,22 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "All notifications marked as read" })
|
return NextResponse.json({ message: "All notifications marked as read" })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationIds && Array.isArray(notificationIds) && notificationIds.length > 0) {
|
if (notificationIds && notificationIds.length > 0) {
|
||||||
// Mark specific notifications as read
|
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: notificationIds },
|
id: { in: notificationIds },
|
||||||
userId: session.user.id
|
userId: session.user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
read: true,
|
read: true,
|
||||||
readAt: new Date()
|
readAt: new Date(),
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ message: "Notifications marked as read" })
|
return NextResponse.json({ message: "Notifications marked as read" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json({ message: "Invalid request" }, { status: 400 })
|
||||||
{ message: "Invalid request. Provide notificationIds array or markAllAsRead: true" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating notifications:", error)
|
console.error("Error updating notifications:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { handleApiError } from '@/lib/errors'
|
import { handleApiError } from '@/lib/errors'
|
||||||
|
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from '@/lib/api-schemas/notificationsPush'
|
||||||
|
import { validationErrorResponse } from '@/lib/api-schemas/validate'
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -10,11 +12,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = webPushSubscribeSchema.safeParse(raw)
|
||||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ message: 'Invalid subscription data' }, { status: 400 })
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
const subscription = parsed.data
|
||||||
|
|
||||||
// Upsert the subscription
|
// Upsert the subscription
|
||||||
await prisma.pushSubscription.upsert({
|
await prisma.pushSubscription.upsert({
|
||||||
@ -49,11 +52,12 @@ export async function DELETE(req: Request) {
|
|||||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { endpoint } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = webPushUnsubscribeSchema.safeParse(raw)
|
||||||
if (!endpoint) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ message: 'Endpoint is required' }, { status: 400 })
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
const { endpoint } = parsed.data
|
||||||
|
|
||||||
await prisma.pushSubscription.delete({
|
await prisma.pushSubscription.delete({
|
||||||
where: { endpoint }
|
where: { endpoint }
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { auth } from "@/auth"
|
|||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { onboardingCompleteSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -17,7 +18,11 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = onboardingCompleteSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
businessName,
|
businessName,
|
||||||
businessEmail,
|
businessEmail,
|
||||||
@ -27,13 +32,8 @@ export async function POST(req: NextRequest) {
|
|||||||
domain,
|
domain,
|
||||||
apiKey,
|
apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
accessToken
|
accessToken,
|
||||||
} = body
|
} = parsed.data
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!businessName || !domain || !accessToken) {
|
|
||||||
return NextResponse.json({ message: "Missing required onboarding data." }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { onboardingShopifySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -17,19 +18,12 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const raw = await req.json().catch(() => ({}))
|
||||||
businessName,
|
const parsed = onboardingShopifySchema.safeParse(raw)
|
||||||
storeName,
|
if (!parsed.success) {
|
||||||
domain,
|
return validationErrorResponse(parsed.error)
|
||||||
apiKey,
|
|
||||||
apiSecret,
|
|
||||||
accessToken
|
|
||||||
} = await req.json()
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!businessName || !storeName || !domain || !apiKey || !apiSecret || !accessToken) {
|
|
||||||
return NextResponse.json({ message: "Missing required fields" }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
const { businessName, storeName, domain, apiKey, apiSecret, accessToken } = parsed.data
|
||||||
|
|
||||||
// Multi-store: create a new store; set as active if first store
|
// Multi-store: create a new store; set as active if first store
|
||||||
const existingCount = await prisma.store.count({ where: { userId: session.user.id } })
|
const existingCount = await prisma.store.count({ where: { userId: session.user.id } })
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||||
|
import { productSyncDecisionSchema } from "@/lib/api-schemas/business"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/products/sync/[syncId]
|
* PUT /api/products/sync/[syncId]
|
||||||
@ -19,15 +21,12 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { syncId } = await params
|
const { syncId } = await params
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { action } = body // "accept" or "reject"
|
const parsed = productSyncDecisionSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
if (!action || !["accept", "reject"].includes(action)) {
|
return validationErrorResponse(parsed.error)
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Invalid action. Use 'accept' or 'reject'" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { action } = parsed.data
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { auth } from "@/auth"
|
|||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
import { productSyncRequestSchema } from "@/lib/api-schemas/business"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/products/sync
|
* GET /api/products/sync
|
||||||
@ -82,15 +84,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = body
|
const parsed = productSyncRequestSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
if (!shopifyProductId || !supplierId || !shopifyProductTitle) {
|
return validationErrorResponse(parsed.error)
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Product ID, title, and supplier ID are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = parsed.data
|
||||||
|
|
||||||
// Get vendor's active store
|
// Get vendor's active store
|
||||||
const vendorStore = await prisma.store.findFirst({
|
const vendorStore = await prisma.store.findFirst({
|
||||||
@ -150,7 +149,7 @@ export async function POST(req: Request) {
|
|||||||
storeId_supplierId_shopifyProductId: {
|
storeId_supplierId_shopifyProductId: {
|
||||||
storeId: vendorStore.id,
|
storeId: vendorStore.id,
|
||||||
supplierId: supplier.id,
|
supplierId: supplier.id,
|
||||||
shopifyProductId: shopifyProductId.toString()
|
shopifyProductId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -211,7 +210,7 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
storeId: vendorStore.id,
|
storeId: vendorStore.id,
|
||||||
supplierId: supplier.id,
|
supplierId: supplier.id,
|
||||||
shopifyProductId: shopifyProductId.toString(),
|
shopifyProductId,
|
||||||
shopifyProductTitle,
|
shopifyProductTitle,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined,
|
shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decryptString } from "@/lib/encryption"
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
import { mfaToggleSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -10,7 +11,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { enabled } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = mfaToggleSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { enabled } = parsed.data
|
||||||
|
|
||||||
// Use raw query to check mfaSecret until Prisma client is fully regenerated
|
// Use raw query to check mfaSecret until Prisma client is fully regenerated
|
||||||
const result = await prisma.$queryRaw<
|
const result = await prisma.$queryRaw<
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
|
|||||||
import { verify } from "otplib"
|
import { verify } from "otplib"
|
||||||
import { decryptString } from "@/lib/encryption"
|
import { decryptString } from "@/lib/encryption"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { mfaSetupVerifySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -12,7 +13,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = mfaSetupVerifySchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { code } = parsed.data
|
||||||
|
|
||||||
// Rate limit MFA setup verification attempts per IP
|
// Rate limit MFA setup verification attempts per IP
|
||||||
const rate = await applyRateLimit(req, "settings:mfa-verify")
|
const rate = await applyRateLimit(req, "settings:mfa-verify")
|
||||||
@ -23,13 +29,6 @@ export async function POST(req: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || typeof code !== "string") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Verification code is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use raw query to access mfaSecret until Prisma client is fully regenerated
|
// Use raw query to access mfaSecret until Prisma client is fully regenerated
|
||||||
const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
|
const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export async function GET() {
|
|||||||
status: product.status,
|
status: product.status,
|
||||||
variants: product.variants,
|
variants: product.variants,
|
||||||
images: images,
|
images: images,
|
||||||
image: images.length > 0 ? { src: images[0].src } : null
|
image: images.length > 0 ? { src: images[0].src } : null,
|
||||||
|
metafields: product.metafields ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
import { shopifyValidateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
import { cleanShopifyDomain } from "@/lib/shopify/adminPing"
|
||||||
|
|
||||||
const TRANSIENT_ERROR_CODES = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]
|
const TRANSIENT_ERROR_CODES = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]
|
||||||
const MAX_RETRIES = 3
|
const MAX_RETRIES = 3
|
||||||
@ -16,15 +18,14 @@ function sleep(ms: number): Promise<void> {
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { domain, accessToken } = body
|
const parsed = shopifyValidateSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
if (!domain || !accessToken) {
|
return validationErrorResponse(parsed.error)
|
||||||
return NextResponse.json({ message: "Store domain and access token are required." }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
const { domain, accessToken } = parsed.data
|
||||||
|
|
||||||
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
const cleanDomain = cleanShopifyDomain(domain)
|
||||||
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
|
||||||
|
|
||||||
let lastError: unknown = null
|
let lastError: unknown = null
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
type StorefrontMessagePayload = {
|
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
||||||
shop: string
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
visitorId: string
|
|
||||||
content: string
|
|
||||||
customerEmail?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeShopDomain(input: string) {
|
function normalizeShopDomain(input: string) {
|
||||||
return input
|
return input
|
||||||
@ -29,35 +25,27 @@ function shopVariants(cleanDomain: string) {
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
const rate = await applyRateLimit(req, "public:storefront-chat", {
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxRequests: 40,
|
||||||
|
})
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const p = prisma as any
|
const p = prisma as any
|
||||||
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { shop, visitorId, content } = payload
|
const parsed = storefrontChatPostSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { shop, visitorId, content: trimmedContent, customerEmail } = parsed.data
|
||||||
|
|
||||||
if (typeof shop !== "string" || !shop.trim()) {
|
|
||||||
return NextResponse.json({ message: "Missing shop" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (typeof visitorId !== "string" || !visitorId.trim()) {
|
|
||||||
return NextResponse.json({ message: "Missing visitorId" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
return NextResponse.json({ message: "Missing content" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedContent = content.trim()
|
|
||||||
const trimmedVisitorId = visitorId.trim()
|
|
||||||
const normalizedShop = normalizeShopDomain(shop)
|
const normalizedShop = normalizeShopDomain(shop)
|
||||||
|
|
||||||
// Guardrails (v1): keep it small, avoid empty spam.
|
|
||||||
if (trimmedContent.length < 1) {
|
|
||||||
return NextResponse.json({ message: "Empty message" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (trimmedContent.length > 2000) {
|
|
||||||
return NextResponse.json({ message: "Message too long" }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (trimmedVisitorId.length > 300) {
|
|
||||||
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const variants = shopVariants(normalizedShop)
|
const variants = shopVariants(normalizedShop)
|
||||||
const stores = await p.store.findMany({
|
const stores = await p.store.findMany({
|
||||||
where: { shopifyDomain: { in: variants } },
|
where: { shopifyDomain: { in: variants } },
|
||||||
@ -74,7 +62,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Find or create conversation for this anonymous visitor.
|
// Find or create conversation for this anonymous visitor.
|
||||||
let conversation = await p.storefrontChatConversation.findFirst({
|
let conversation = await p.storefrontChatConversation.findFirst({
|
||||||
where: { storeId: store.id, visitorId: trimmedVisitorId },
|
where: { storeId: store.id, visitorId },
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -95,8 +83,8 @@ export async function POST(req: Request) {
|
|||||||
conversation = await p.storefrontChatConversation.create({
|
conversation = await p.storefrontChatConversation.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
visitorId: trimmedVisitorId,
|
visitorId,
|
||||||
customerEmail: payload.customerEmail ?? null
|
customerEmail: customerEmail ?? null,
|
||||||
},
|
},
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
})
|
})
|
||||||
@ -121,7 +109,7 @@ export async function POST(req: Request) {
|
|||||||
const ai = await generateStorefrontReply({
|
const ai = await generateStorefrontReply({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
query: trimmedContent,
|
query: trimmedContent,
|
||||||
visitorId: trimmedVisitorId,
|
visitorId,
|
||||||
history: recentHistory.map((m: any) => ({
|
history: recentHistory.map((m: any) => ({
|
||||||
senderType: m.senderType,
|
senderType: m.senderType,
|
||||||
content: m.content
|
content: m.content
|
||||||
|
|||||||
@ -5,12 +5,7 @@ import { logger } from "@/lib/logger"
|
|||||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||||
import type { FaqItem } from "@prisma/client"
|
import type { FaqItem } from "@prisma/client"
|
||||||
|
import { kbFaqImportArraySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
type FaqImportItem = {
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
tags?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -46,7 +41,7 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let items: FaqImportItem[] = []
|
let items: unknown[] = []
|
||||||
|
|
||||||
const contentType = request.headers.get("content-type") || ""
|
const contentType = request.headers.get("content-type") || ""
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
@ -57,7 +52,7 @@ export async function POST(
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items = body as FaqImportItem[]
|
items = body
|
||||||
} else if (contentType.includes("multipart/form-data")) {
|
} else if (contentType.includes("multipart/form-data")) {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const file = formData.get("file")
|
const file = formData.get("file")
|
||||||
@ -76,7 +71,7 @@ export async function POST(
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items = parsed as FaqImportItem[]
|
items = parsed
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Invalid JSON in uploaded file" },
|
{ message: "Invalid JSON in uploaded file" },
|
||||||
@ -90,19 +85,14 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const validItems = items.filter(
|
const validated = kbFaqImportArraySchema.safeParse(items)
|
||||||
(item) => item.question && item.answer,
|
if (!validated.success) {
|
||||||
) as FaqImportItem[]
|
return validationErrorResponse(validated.error)
|
||||||
|
|
||||||
if (!validItems.length) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "No valid FAQs to import" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const validItems = validated.data
|
||||||
|
|
||||||
const createdFaqs = await prisma.$transaction(
|
const createdFaqs = await prisma.$transaction(
|
||||||
validItems.map((item: FaqImportItem) =>
|
validItems.map((item) =>
|
||||||
prisma.faqItem.create({
|
prisma.faqItem.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
|
|||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||||
|
import { kbFaqCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@ -78,19 +79,12 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const raw = await request.json().catch(() => ({}))
|
||||||
const { question, answer, tags } = body as {
|
const parsed = kbFaqCreateSchema.safeParse(raw)
|
||||||
question?: string
|
if (!parsed.success) {
|
||||||
answer?: string
|
return validationErrorResponse(parsed.error)
|
||||||
tags?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!question || !answer) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Question and answer are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { question, answer, tags } = parsed.data
|
||||||
|
|
||||||
const faq = await (prisma as any).faqItem.create({
|
const faq = await (prisma as any).faqItem.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -5,14 +5,7 @@ import { logger } from "@/lib/logger"
|
|||||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||||
import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta"
|
import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta"
|
||||||
import { generateKbRagAnswer } from "@/lib/kb/kbAnswer"
|
import { generateKbRagAnswer } from "@/lib/kb/kbAnswer"
|
||||||
|
import { kbQuerySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
type QueryBody = {
|
|
||||||
query?: string
|
|
||||||
topK?: number
|
|
||||||
includeFaq?: boolean
|
|
||||||
includeDocuments?: boolean
|
|
||||||
includeStructured?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type KnowledgeChunkRow = {
|
type KnowledgeChunkRow = {
|
||||||
id: string
|
id: string
|
||||||
@ -48,14 +41,13 @@ export async function POST(
|
|||||||
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as QueryBody
|
const raw = await request.json().catch(() => ({}))
|
||||||
const question = body.query?.trim()
|
const parsed = kbQuerySchema.safeParse(raw)
|
||||||
if (!question) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return validationErrorResponse(parsed.error)
|
||||||
{ message: "Query is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const question = body.query
|
||||||
|
|
||||||
const topK = Math.min(Math.max(body.topK ?? 8, 1), 20)
|
const topK = Math.min(Math.max(body.topK ?? 8, 1), 20)
|
||||||
const includeFaq = body.includeFaq ?? true
|
const includeFaq = body.includeFaq ?? true
|
||||||
|
|||||||
@ -268,11 +268,23 @@ function buildStorefrontScript(params: {
|
|||||||
|
|
||||||
var headerText = document.createElement("div");
|
var headerText = document.createElement("div");
|
||||||
headerText.style.minWidth = "0";
|
headerText.style.minWidth = "0";
|
||||||
headerText.innerHTML = "<div style=\\"font-size:13px;font-weight:800;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
var titleLine = document.createElement("div");
|
||||||
String(chatName || "Chat") +
|
titleLine.style.fontSize = "13px";
|
||||||
"</div><div style=\\"font-size:11px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
titleLine.style.fontWeight = "800";
|
||||||
String(tagline || "") +
|
titleLine.style.color = "#111827";
|
||||||
"</div>";
|
titleLine.style.whiteSpace = "nowrap";
|
||||||
|
titleLine.style.overflow = "hidden";
|
||||||
|
titleLine.style.textOverflow = "ellipsis";
|
||||||
|
titleLine.textContent = String(chatName || "Chat");
|
||||||
|
var subLine = document.createElement("div");
|
||||||
|
subLine.style.fontSize = "11px";
|
||||||
|
subLine.style.color = "#64748b";
|
||||||
|
subLine.style.whiteSpace = "nowrap";
|
||||||
|
subLine.style.overflow = "hidden";
|
||||||
|
subLine.style.textOverflow = "ellipsis";
|
||||||
|
subLine.textContent = String(tagline || "");
|
||||||
|
headerText.appendChild(titleLine);
|
||||||
|
headerText.appendChild(subLine);
|
||||||
header.appendChild(headerText);
|
header.appendChild(headerText);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
|||||||
86
src/app/api/stores/[storeId]/shopify-access-token/route.ts
Normal file
86
src/app/api/stores/[storeId]/shopify-access-token/route.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { cleanShopifyDomain, pingShopifyAdminApi } from "@/lib/shopify/adminPing"
|
||||||
|
import { storeShopifyAccessTokenUpdateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/stores/[storeId]/shopify-access-token
|
||||||
|
* Update only the Shopify Admin API access token (e.g. after reinstalling the app or changing scopes).
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await applyRateLimit(req, "settings:shopify-access-token", {
|
||||||
|
maxRequests: 12,
|
||||||
|
windowMs: 60_000,
|
||||||
|
})
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many attempts. Please wait a minute and try again." },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = storeShopifyAccessTokenUpdateSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { id: storeId, userId: session.user.id },
|
||||||
|
select: { id: true, shopifyDomain: true, onboardingComplete: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.onboardingComplete) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Complete store onboarding before updating the access token." },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanDomain = cleanShopifyDomain(store.shopifyDomain)
|
||||||
|
if (!cleanDomain) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This store has no Shopify domain on file." },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ping = await pingShopifyAdminApi(cleanDomain, parsed.data.accessToken)
|
||||||
|
if (!ping.ok) {
|
||||||
|
return NextResponse.json({ message: ping.message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.store.update({
|
||||||
|
where: { id: store.id },
|
||||||
|
data: { shopifyAccessToken: parsed.data.accessToken },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Shopify access token updated successfully.",
|
||||||
|
storeId: store.id,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[shopify-access-token PATCH]", error)
|
||||||
|
return NextResponse.json({ message: "Failed to update access token" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { storeSwitchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/stores/switch
|
* POST /api/stores/switch
|
||||||
@ -14,11 +15,12 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storeId } = await request.json()
|
const raw = await request.json().catch(() => ({}))
|
||||||
|
const parsed = storeSwitchSchema.safeParse(raw)
|
||||||
if (!storeId) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
const { storeId } = parsed.data
|
||||||
|
|
||||||
// Verify the store belongs to the user
|
// Verify the store belongs to the user
|
||||||
const store = await prisma.store.findFirst({
|
const store = await prisma.store.findFirst({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { notifyInvitationAccepted } from "@/lib/notifications"
|
import { notifyInvitationAccepted } from "@/lib/notifications"
|
||||||
|
import { supplierInvitationAcceptSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/supplier/invitations/accept
|
* POST /api/supplier/invitations/accept
|
||||||
@ -15,14 +16,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierInvitationAcceptSchema.safeParse(raw)
|
||||||
if (!token || typeof token !== "string") {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return validationErrorResponse(parsed.error)
|
||||||
{ message: "Invitation token is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { token } = parsed.data
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
async function getSupplierContext(userId: string) {
|
async function getSupplierContext(userId: string) {
|
||||||
const profile = await prisma.supplierProfile.findUnique({
|
const profile = await prisma.supplierProfile.findUnique({
|
||||||
@ -398,13 +399,16 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
const { orderId } = await params
|
const { orderId } = await params
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierOrderPatchSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { fulfillmentStatus: fs, trackingNumber, trackingCompany, trackingUrl } = parsed.data
|
||||||
|
|
||||||
const fulfillmentStatus = normalizeFulfillmentStatus(body.fulfillmentStatus)
|
const fulfillmentStatus = normalizeFulfillmentStatus(
|
||||||
|
fs === undefined || fs === null ? undefined : String(fs),
|
||||||
const trackingNumber = String(body.trackingNumber || "").trim() || null
|
)
|
||||||
const trackingCompany = String(body.trackingCompany || "").trim() || null
|
|
||||||
const trackingUrl = String(body.trackingUrl || "").trim() || null
|
|
||||||
|
|
||||||
const ctx = await getSupplierContext(session.user.id)
|
const ctx = await getSupplierContext(session.user.id)
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierProductUpdateSchema } from "@/lib/api-schemas/business"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/supplier/products/[productId]
|
* PUT /api/supplier/products/[productId]
|
||||||
@ -18,7 +20,12 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { productId } = await params
|
const { productId } = await params
|
||||||
const { title, description, price, sku, imageUrl, quantity } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierProductUpdateSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const { title, description, price, sku, imageUrl, quantity } = parsed.data
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
@ -58,26 +65,33 @@ export async function PUT(
|
|||||||
// Update the shopifyProductData with new values (including optional supplier-specific quantity)
|
// Update the shopifyProductData with new values (including optional supplier-specific quantity)
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
...(productSync.shopifyProductData || {}),
|
...(productSync.shopifyProductData || {}),
|
||||||
title: title || productSync.shopifyProductTitle,
|
title: title ?? productSync.shopifyProductTitle,
|
||||||
body_html: description !== undefined ? description : (productSync.shopifyProductData?.body_html || ""),
|
body_html:
|
||||||
variants: productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
|
description !== undefined
|
||||||
|
? description
|
||||||
|
: productSync.shopifyProductData?.body_html || "",
|
||||||
|
variants:
|
||||||
|
productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
|
||||||
...v,
|
...v,
|
||||||
price: idx === 0 && price !== undefined ? price.toString() : v.price,
|
price: idx === 0 && price !== undefined ? String(price) : v.price,
|
||||||
sku: idx === 0 && sku !== undefined ? sku : v.sku
|
sku: idx === 0 && sku !== undefined ? sku : v.sku,
|
||||||
})) || [],
|
})) || [],
|
||||||
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image,
|
image:
|
||||||
|
imageUrl != null && String(imageUrl).trim() !== ""
|
||||||
|
? { src: String(imageUrl).trim() }
|
||||||
|
: productSync.shopifyProductData?.image,
|
||||||
supplier_quantity:
|
supplier_quantity:
|
||||||
quantity !== undefined
|
quantity !== undefined
|
||||||
? quantity
|
? quantity
|
||||||
: productSync.shopifyProductData?.supplier_quantity
|
: productSync.shopifyProductData?.supplier_quantity,
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const updatedSync = await (prisma as any).productSync.update({
|
const updatedSync = await (prisma as any).productSync.update({
|
||||||
where: { id: syncId },
|
where: { id: syncId },
|
||||||
data: {
|
data: {
|
||||||
shopifyProductTitle: title || productSync.shopifyProductTitle,
|
shopifyProductTitle: title ?? productSync.shopifyProductTitle,
|
||||||
shopifyProductData: updatedData
|
shopifyProductData: updatedData,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -107,9 +121,12 @@ export async function PUT(
|
|||||||
product: {
|
product: {
|
||||||
id: productId,
|
id: productId,
|
||||||
syncId,
|
syncId,
|
||||||
title: title || productSync.shopifyProductTitle,
|
title: title ?? productSync.shopifyProductTitle,
|
||||||
description,
|
description,
|
||||||
price: price !== undefined ? parseFloat(price) : parseFloat(updatedData.variants?.[0]?.price || "0"),
|
price:
|
||||||
|
price !== undefined
|
||||||
|
? price
|
||||||
|
: parseFloat(updatedData.variants?.[0]?.price || "0"),
|
||||||
sku,
|
sku,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
availableQuantity: updatedData.supplier_quantity,
|
availableQuantity: updatedData.supplier_quantity,
|
||||||
@ -133,11 +150,11 @@ export async function PUT(
|
|||||||
const product = await prisma.product.update({
|
const product = await prisma.product.update({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
data: {
|
data: {
|
||||||
title: title || existingProduct.title,
|
title: title ?? existingProduct.title,
|
||||||
description: description !== undefined ? description : existingProduct.description,
|
description: description !== undefined ? description : existingProduct.description,
|
||||||
price: price !== undefined ? parseFloat(price) : existingProduct.price,
|
price: price !== undefined ? price : existingProduct.price,
|
||||||
sku: sku !== undefined ? sku : existingProduct.sku,
|
sku: sku !== undefined ? sku : existingProduct.sku,
|
||||||
imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl
|
imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
import { supplierProductCreateSchema } from "@/lib/api-schemas/business"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/products
|
* GET /api/supplier/products
|
||||||
@ -106,14 +108,12 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, price, sku, imageUrl } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierProductCreateSchema.safeParse(raw)
|
||||||
if (!title || price === undefined) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return validationErrorResponse(parsed.error)
|
||||||
{ message: "Title and price are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { title, description, price, sku, imageUrl } = parsed.data
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
@ -128,10 +128,10 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
supplierId: supplierProfile.id,
|
supplierId: supplierProfile.id,
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description?.trim() ? description : null,
|
||||||
price: parseFloat(price),
|
price,
|
||||||
sku: sku || null,
|
sku: sku?.trim() ? sku : null,
|
||||||
imageUrl: imageUrl || null
|
imageUrl: imageUrl?.trim() ? imageUrl : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierProfileUpsertSchema } from "@/lib/api-schemas/business"
|
||||||
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/profile
|
* GET /api/supplier/profile
|
||||||
@ -58,23 +60,24 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { companyName, description } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierProfileUpsertSchema.safeParse(raw)
|
||||||
if (!companyName) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ message: "Company name is required" }, { status: 400 })
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
|
const { companyName, description } = parsed.data
|
||||||
|
|
||||||
// Upsert supplier profile
|
// Upsert supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.upsert({
|
const supplierProfile = await prisma.supplierProfile.upsert({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
update: {
|
update: {
|
||||||
companyName,
|
companyName,
|
||||||
description: description || null
|
description: description?.trim() ? description : null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
companyName,
|
companyName,
|
||||||
description: description || null
|
description: description?.trim() ? description : null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { createNotification } from "@/lib/notifications"
|
import { createNotification } from "@/lib/notifications"
|
||||||
|
import { supplierOfferCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/vendors/[connectionId]/offers
|
* GET /api/supplier/vendors/[connectionId]/offers
|
||||||
@ -97,26 +98,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { connectionId } = await params
|
const { connectionId } = await params
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { productId, price, quantity } = body as {
|
const parsed = supplierOfferCreateSchema.safeParse(raw)
|
||||||
productId?: string
|
if (!parsed.success) {
|
||||||
price?: number
|
return validationErrorResponse(parsed.error)
|
||||||
quantity?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!productId || price === undefined || quantity === undefined) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "productId, price and quantity are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quantity <= 0 || price < 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Price must be non-negative and quantity must be greater than zero" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { productId, price, quantity } = parsed.data
|
||||||
|
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
where: { userId: session.user.id }
|
where: { userId: session.user.id }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierConnectionStatusSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/vendors/[connectionId]
|
* GET /api/supplier/vendors/[connectionId]
|
||||||
@ -81,14 +82,12 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { connectionId } = await params
|
const { connectionId } = await params
|
||||||
const { status } = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
|
const parsed = supplierConnectionStatusSchema.safeParse(raw)
|
||||||
if (!status || !["CONNECTED", "REJECTED"].includes(status)) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return validationErrorResponse(parsed.error)
|
||||||
{ message: "Valid status (CONNECTED or REJECTED) is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { status } = parsed.data
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { createNotification } from "@/lib/notifications"
|
import { createNotification } from "@/lib/notifications"
|
||||||
|
import { supplierOfferUpdateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/supplier/vendors/offers/[offerId]
|
* PUT /api/supplier/vendors/offers/[offerId]
|
||||||
@ -19,32 +20,12 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { offerId } = await params
|
const { offerId } = await params
|
||||||
const body = await req.json()
|
const raw = await req.json().catch(() => ({}))
|
||||||
const { price, quantity } = body as {
|
const parsed = supplierOfferUpdateSchema.safeParse(raw)
|
||||||
price?: number
|
if (!parsed.success) {
|
||||||
quantity?: number
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
|
||||||
|
|
||||||
if (price === undefined && quantity === undefined) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "At least one of price or quantity must be provided" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quantity !== undefined && quantity <= 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Quantity must be greater than zero" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (price !== undefined && price < 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Price must be non-negative" },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const { price, quantity } = parsed.data
|
||||||
|
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
where: { userId: session.user.id }
|
where: { userId: session.user.id }
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
|
|||||||
import { notifyInvitationReceived } from "@/lib/notifications"
|
import { notifyInvitationReceived } from "@/lib/notifications"
|
||||||
import { getBaseUrl } from "@/lib/utils"
|
import { getBaseUrl } from "@/lib/utils"
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
|
import { vendorInviteDeleteSchema, vendorInvitePostSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/vendors/suppliers/[supplierId]/invite
|
* DELETE /api/vendors/suppliers/[supplierId]/invite
|
||||||
@ -23,8 +24,12 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { supplierId } = await params
|
const { supplierId } = await params
|
||||||
const body = await req.json().catch(() => ({}))
|
const raw = await req.json().catch(() => ({}))
|
||||||
const storeId = body.storeId // Optional: specific store to cancel, otherwise cancel all
|
const parsed = vendorInviteDeleteSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const storeId = parsed.data.storeId ?? undefined
|
||||||
|
|
||||||
// Get vendor's stores
|
// Get vendor's stores
|
||||||
const vendorStores = await prisma.store.findMany({
|
const vendorStores = await prisma.store.findMany({
|
||||||
@ -101,8 +106,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { supplierId } = await params
|
const { supplierId } = await params
|
||||||
const body = await req.json().catch(() => ({}))
|
const raw = await req.json().catch(() => ({}))
|
||||||
const connectToAllStores = body.connectToAllStores === true
|
const parsed = vendorInvitePostSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error)
|
||||||
|
}
|
||||||
|
const connectToAllStores = parsed.data.connectToAllStores === true
|
||||||
|
|
||||||
// Get vendor's stores
|
// Get vendor's stores
|
||||||
const vendorStores = await prisma.store.findMany({
|
const vendorStores = await prisma.store.findMany({
|
||||||
|
|||||||
@ -289,7 +289,12 @@ export default function DashboardPage() {
|
|||||||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
||||||
backdropFilter: isLight ? "none" : "blur(12px)",
|
backdropFilter: isLight ? "none" : "blur(12px)",
|
||||||
}}
|
}}
|
||||||
formatter={(value: number | undefined) => [value !== undefined ? `$${value.toLocaleString()}` : "$0", "Revenue"]}
|
formatter={(value) => {
|
||||||
|
const num =
|
||||||
|
typeof value === "number" ? value : Number(value)
|
||||||
|
const v = Number.isFinite(num) ? num : 0
|
||||||
|
return [`$${v.toLocaleString()}`, "Revenue"]
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|||||||
@ -1,9 +1,31 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { User, Mail, Building, Save, Loader2, CheckCircle2, AlertCircle, Bell, BellOff } from "lucide-react"
|
import {
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Building,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
KeyRound,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react"
|
||||||
import { usePushNotifications } from "@/hooks/usePushNotifications"
|
import { usePushNotifications } from "@/hooks/usePushNotifications"
|
||||||
|
import { accountUpdateSchema, isStrongPassword } from "@/lib/validation"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type ActiveStoreMeta = {
|
||||||
|
id: string
|
||||||
|
businessName: string
|
||||||
|
shopifyDomain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOPIFY_TOKEN_MAX = 4000
|
||||||
|
|
||||||
export default function AccountSettingsPage() {
|
export default function AccountSettingsPage() {
|
||||||
const { data: session, update } = useSession()
|
const { data: session, update } = useSession()
|
||||||
@ -19,26 +41,100 @@ export default function AccountSettingsPage() {
|
|||||||
confirmPassword: ""
|
confirmPassword: ""
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const [activeStoreMeta, setActiveStoreMeta] = useState<ActiveStoreMeta | null>(null)
|
||||||
// Fetch user's business name (from first store or user name)
|
const [shopifyTokenOpen, setShopifyTokenOpen] = useState(false)
|
||||||
const fetchBusinessName = async () => {
|
const [newShopifyToken, setNewShopifyToken] = useState("")
|
||||||
|
const [tokenLoading, setTokenLoading] = useState(false)
|
||||||
|
const [tokenSuccess, setTokenSuccess] = useState("")
|
||||||
|
const [tokenError, setTokenError] = useState("")
|
||||||
|
|
||||||
|
const refreshActiveStore = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/stores")
|
const res = await fetch("/api/stores")
|
||||||
if (res.ok) {
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive)
|
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) as
|
||||||
if (activeStore?.businessName) {
|
| {
|
||||||
setFormData(prev => ({ ...prev, businessName: activeStore.businessName }))
|
id?: string
|
||||||
} else if (session?.user?.name) {
|
businessName?: string
|
||||||
setFormData(prev => ({ ...prev, businessName: session.user.name || "" }))
|
shopifyDomain?: string
|
||||||
|
onboardingComplete?: boolean
|
||||||
}
|
}
|
||||||
|
| undefined
|
||||||
|
if (activeStore?.businessName) {
|
||||||
|
setFormData((prev) => ({ ...prev, businessName: activeStore.businessName || "" }))
|
||||||
|
} else if (session?.user?.name) {
|
||||||
|
setFormData((prev) => ({ ...prev, businessName: session.user.name || "" }))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
activeStore?.id &&
|
||||||
|
activeStore.shopifyDomain &&
|
||||||
|
activeStore.onboardingComplete
|
||||||
|
) {
|
||||||
|
setActiveStoreMeta({
|
||||||
|
id: activeStore.id,
|
||||||
|
businessName: activeStore.businessName || "Store",
|
||||||
|
shopifyDomain: activeStore.shopifyDomain,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setActiveStoreMeta(null)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silent fail
|
// Silent fail
|
||||||
}
|
}
|
||||||
|
}, [session?.user?.name])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshActiveStore()
|
||||||
|
}, [refreshActiveStore])
|
||||||
|
|
||||||
|
const toggleShopifyTokenPanel = () => {
|
||||||
|
const next = !shopifyTokenOpen
|
||||||
|
setShopifyTokenOpen(next)
|
||||||
|
setTokenError("")
|
||||||
|
setTokenSuccess("")
|
||||||
|
if (next) void refreshActiveStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveShopifyAccessToken = async () => {
|
||||||
|
const trimmed = newShopifyToken.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setTokenError("Paste your new Admin API access token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!activeStoreMeta) {
|
||||||
|
setTokenError("No connected Shopify store found for your active store.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTokenLoading(true)
|
||||||
|
setTokenError("")
|
||||||
|
setTokenSuccess("")
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/stores/${encodeURIComponent(activeStoreMeta.id)}/shopify-access-token`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ accessToken: trimmed }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
setTokenError(
|
||||||
|
typeof data?.message === "string"
|
||||||
|
? data.message
|
||||||
|
: "Could not update token. Try again.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTokenSuccess("Token saved and verified with Shopify.")
|
||||||
|
setNewShopifyToken("")
|
||||||
|
} catch {
|
||||||
|
setTokenError("Something went wrong. Try again.")
|
||||||
|
} finally {
|
||||||
|
setTokenLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchBusinessName()
|
|
||||||
}, [session])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -59,7 +155,7 @@ export default function AccountSettingsPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (formData.newPassword.length < 8) {
|
if (!isStrongPassword(formData.newPassword)) {
|
||||||
setError("New password must be at least 8 characters and include upper, lower, number and special character")
|
setError("New password must be at least 8 characters and include upper, lower, number and special character")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@ -77,10 +173,17 @@ export default function AccountSettingsPage() {
|
|||||||
updateData.newPassword = formData.newPassword
|
updateData.newPassword = formData.newPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyCheck = accountUpdateSchema.safeParse(updateData)
|
||||||
|
if (!bodyCheck.success) {
|
||||||
|
setError(bodyCheck.error.issues[0]?.message ?? "Invalid input")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch("/api/settings/account", {
|
const res = await fetch("/api/settings/account", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updateData)
|
body: JSON.stringify(bodyCheck.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -149,6 +252,7 @@ export default function AccountSettingsPage() {
|
|||||||
value={formData.businessName}
|
value={formData.businessName}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))}
|
||||||
placeholder="Enter your business name"
|
placeholder="Enter your business name"
|
||||||
|
maxLength={200}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -167,6 +271,7 @@ export default function AccountSettingsPage() {
|
|||||||
value={formData.currentPassword}
|
value={formData.currentPassword}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, currentPassword: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
|
maxLength={128}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -177,6 +282,7 @@ export default function AccountSettingsPage() {
|
|||||||
value={formData.newPassword}
|
value={formData.newPassword}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
|
maxLength={128}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -187,6 +293,7 @@ export default function AccountSettingsPage() {
|
|||||||
value={formData.confirmPassword}
|
value={formData.confirmPassword}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
|
maxLength={128}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -214,6 +321,96 @@ export default function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Shopify access token (collapsed by default — reinstall / new scopes) */}
|
||||||
|
{activeStoreMeta ? (
|
||||||
|
<div className="border-t border-white/10 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleShopifyTokenPanel}
|
||||||
|
className="flex w-full items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left transition-colors hover:bg-white/[0.07]"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0 text-indigo-400" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-bold text-white">
|
||||||
|
Shopify access token
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-slate-500">
|
||||||
|
{activeStoreMeta.businessName} · {activeStoreMeta.shopifyDomain}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-slate-400 transition-transform",
|
||||||
|
shopifyTokenOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{shopifyTokenOpen ? (
|
||||||
|
<div className="mt-3 space-y-4 rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Reinstalled your app in Shopify or added API permissions? Shopify issues a
|
||||||
|
new <strong className="text-slate-300">Admin API access token</strong>.
|
||||||
|
Paste it here—your store domain stays the same; we verify the token with
|
||||||
|
Shopify before saving.
|
||||||
|
</p>
|
||||||
|
{tokenSuccess ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
{tokenSuccess}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{tokenError ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{tokenError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-slate-400">
|
||||||
|
New Admin API access token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={newShopifyToken}
|
||||||
|
maxLength={SHOPIFY_TOKEN_MAX}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewShopifyToken(
|
||||||
|
e.target.value.slice(0, SHOPIFY_TOKEN_MAX),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="shpat_…"
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-sm text-white placeholder:text-slate-600 focus:border-indigo-500/50 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tokenLoading || !newShopifyToken.trim()}
|
||||||
|
onClick={() => void saveShopifyAccessToken()}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-indigo-500 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{tokenLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Verifying…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
Save token
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Push notifications */}
|
{/* Push notifications */}
|
||||||
<div className="pt-6 border-t border-white/10">
|
<div className="pt-6 border-t border-white/10">
|
||||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { authRegisterSchema } from "@/lib/validation"
|
||||||
|
|
||||||
function SignupFormContent() {
|
function SignupFormContent() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -43,6 +44,13 @@ function SignupFormContent() {
|
|||||||
const email = formData.get("email") as string
|
const email = formData.get("email") as string
|
||||||
const password = formData.get("password") as string
|
const password = formData.get("password") as string
|
||||||
|
|
||||||
|
const precheck = authRegisterSchema.safeParse({ name, email, password, role })
|
||||||
|
if (!precheck.success) {
|
||||||
|
setError(precheck.error.issues[0]?.message ?? "Please check your details")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -151,6 +159,7 @@ function SignupFormContent() {
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
maxLength={100}
|
||||||
placeholder="John Carter"
|
placeholder="John Carter"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@ -167,6 +176,7 @@ function SignupFormContent() {
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
maxLength={254}
|
||||||
placeholder="john@example.com"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@ -183,6 +193,7 @@ function SignupFormContent() {
|
|||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
|
maxLength={128}
|
||||||
placeholder="••••••••"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -257,7 +257,11 @@ export default function SupplierDashboardPage() {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
|
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
|
||||||
labelStyle={{ color: "rgb(248 250 252)" }}
|
labelStyle={{ color: "rgb(248 250 252)" }}
|
||||||
formatter={(value: number | undefined) => [value ?? 0, "Count"]}
|
formatter={(value) => {
|
||||||
|
const num =
|
||||||
|
typeof value === "number" ? value : Number(value)
|
||||||
|
return [Number.isFinite(num) ? num : 0, "Count"]
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
|||||||
@ -364,6 +364,7 @@ export default function SupplierProductsPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
maxLength={500}
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
@ -377,6 +378,7 @@ export default function SupplierProductsPage() {
|
|||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={50_000}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
placeholder="Product description"
|
placeholder="Product description"
|
||||||
/>
|
/>
|
||||||
@ -403,6 +405,7 @@ export default function SupplierProductsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.sku}
|
value={formData.sku}
|
||||||
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
|
||||||
|
maxLength={200}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
placeholder="SKU"
|
placeholder="SKU"
|
||||||
/>
|
/>
|
||||||
@ -428,6 +431,7 @@ export default function SupplierProductsPage() {
|
|||||||
type="url"
|
type="url"
|
||||||
value={formData.imageUrl}
|
value={formData.imageUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
||||||
|
maxLength={4000}
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
placeholder="https://example.com/image.jpg"
|
placeholder="https://example.com/image.jpg"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
|
import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
|
||||||
|
import { CHAT_MESSAGE_CONTENT_MAX } from "@/lib/api-schemas/public"
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
id: string
|
id: string
|
||||||
@ -43,27 +44,6 @@ interface Message {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertCircle(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
{...props}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttachOrder = {
|
type AttachOrder = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@ -287,8 +267,7 @@ export function ChatView() {
|
|||||||
setAttachOpen(false)
|
setAttachOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendMessage = async (e: React.FormEvent) => {
|
const sendMessage = async () => {
|
||||||
e.preventDefault()
|
|
||||||
if (!selectedContact) return
|
if (!selectedContact) return
|
||||||
const hasText = !!newMessage.trim()
|
const hasText = !!newMessage.trim()
|
||||||
const hasAttachment = !!draftKind && !!draftAttachment
|
const hasAttachment = !!draftKind && !!draftAttachment
|
||||||
@ -297,10 +276,10 @@ export function ChatView() {
|
|||||||
try {
|
try {
|
||||||
setSending(true)
|
setSending(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
const payload: any = {
|
const payload: Record<string, unknown> = {
|
||||||
receiverId: selectedContact.id,
|
receiverId: selectedContact.id,
|
||||||
storeId: selectedContact.storeId,
|
storeId: selectedContact.storeId,
|
||||||
content: newMessage
|
content: newMessage,
|
||||||
}
|
}
|
||||||
if (hasAttachment) {
|
if (hasAttachment) {
|
||||||
payload.kind = draftKind
|
payload.kind = draftKind
|
||||||
@ -309,12 +288,12 @@ export function ChatView() {
|
|||||||
const res = await fetch("/api/chat/messages", {
|
const res = await fetch("/api/chat/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const msg = await res.json()
|
const msg = await res.json()
|
||||||
const optimiticMsg: Message = {
|
const optimisticMsg: Message = {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
senderId: msg.senderId,
|
senderId: msg.senderId,
|
||||||
receiverId: msg.receiverId,
|
receiverId: msg.receiverId,
|
||||||
@ -322,14 +301,19 @@ export function ChatView() {
|
|||||||
content: newMessage,
|
content: newMessage,
|
||||||
kind: msg.kind ?? "TEXT",
|
kind: msg.kind ?? "TEXT",
|
||||||
attachment: msg.attachment ?? null,
|
attachment: msg.attachment ?? null,
|
||||||
createdAt: msg.createdAt
|
createdAt: msg.createdAt,
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev, optimiticMsg])
|
setMessages((prev) => [...prev, optimisticMsg])
|
||||||
setNewMessage("")
|
setNewMessage("")
|
||||||
setDraftKind(null)
|
setDraftKind(null)
|
||||||
setDraftAttachment(null)
|
setDraftAttachment(null)
|
||||||
} else {
|
} else {
|
||||||
setSendError("Failed to send message. Please try again.")
|
const errJson = await res.json().catch(() => ({}))
|
||||||
|
const errMsg =
|
||||||
|
typeof errJson?.message === "string"
|
||||||
|
? errJson.message
|
||||||
|
: "Failed to send message. Please try again."
|
||||||
|
setSendError(errMsg)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Send message error", error)
|
console.error("Send message error", error)
|
||||||
@ -339,6 +323,11 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendMessage = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void sendMessage()
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort compatibility for legacy bracket-style chat messages.
|
// Best-effort compatibility for legacy bracket-style chat messages.
|
||||||
// Example legacy content:
|
// Example legacy content:
|
||||||
// - [Product: Title · Store (#123)]
|
// - [Product: Title · Store (#123)]
|
||||||
@ -461,7 +450,9 @@ export function ChatView() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Find connections..."
|
placeholder="Find connections..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
}
|
||||||
className={cn(
|
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",
|
"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"
|
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"
|
||||||
@ -916,11 +907,16 @@ export function ChatView() {
|
|||||||
rows={1}
|
rows={1}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
value={newMessage}
|
value={newMessage}
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
maxLength={CHAT_MESSAGE_CONTENT_MAX}
|
||||||
onKeyDown={(e) => {
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
|
setNewMessage(
|
||||||
|
e.target.value.slice(0, CHAT_MESSAGE_CONTENT_MAX),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSendMessage(e)
|
void sendMessage()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"
|
|||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import {
|
import {
|
||||||
KNOWLEDGE_TEMPLATE_FIELDS,
|
KNOWLEDGE_TEMPLATE_FIELDS,
|
||||||
|
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
|
||||||
|
KNOWLEDGE_TEMPLATE_MISC_MAX,
|
||||||
type KnowledgeTemplateFieldKey,
|
type KnowledgeTemplateFieldKey,
|
||||||
type KnowledgeTemplateStored,
|
type KnowledgeTemplateStored,
|
||||||
} from "@/lib/kb/knowledgeTemplate"
|
} from "@/lib/kb/knowledgeTemplate"
|
||||||
@ -286,11 +288,15 @@ export function StructuredKnowledgeModal({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setAnswers((a) => ({
|
setAnswers((a) => ({
|
||||||
...a,
|
...a,
|
||||||
[field.key]: e.target.value,
|
[field.key]: e.target.value.slice(
|
||||||
|
0,
|
||||||
|
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||||
isLight
|
isLight
|
||||||
@ -320,9 +326,14 @@ export function StructuredKnowledgeModal({
|
|||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={misc}
|
value={misc}
|
||||||
onChange={(e) => setMisc(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setMisc(
|
||||||
|
e.target.value.slice(0, KNOWLEDGE_TEMPLATE_MISC_MAX),
|
||||||
|
)
|
||||||
|
}
|
||||||
placeholder="Optional…"
|
placeholder="Optional…"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={KNOWLEDGE_TEMPLATE_MISC_MAX}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||||
isLight
|
isLight
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export function LandingFooter() {
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
maxLength={254}
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
className="bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-indigo-500 transition-colors flex-1"
|
className="bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-indigo-500 transition-colors flex-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
87
src/lib/api-schemas/business.ts
Normal file
87
src/lib/api-schemas/business.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const requiredPriceField = z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.transform((v) => (typeof v === "string" ? parseFloat(v.trim()) : v))
|
||||||
|
.refine((n) => Number.isFinite(n) && n >= 0 && n <= 1e12, "Invalid price")
|
||||||
|
|
||||||
|
const optionalPriceField = z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined) return undefined
|
||||||
|
return typeof v === "string" ? parseFloat(v.trim()) : v
|
||||||
|
})
|
||||||
|
.refine((n) => n === undefined || (Number.isFinite(n) && n >= 0 && n <= 1e12), "Invalid price")
|
||||||
|
|
||||||
|
/** POST /api/supplier/products — create catalog product */
|
||||||
|
export const supplierProductCreateSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "Title is required").max(500),
|
||||||
|
description: z.string().trim().max(50_000).optional().nullable(),
|
||||||
|
price: requiredPriceField,
|
||||||
|
sku: z.string().trim().max(200).optional().nullable(),
|
||||||
|
imageUrl: z.string().trim().max(4000).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PUT /api/supplier/products/[productId] */
|
||||||
|
export const supplierProductUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1).max(500).optional(),
|
||||||
|
description: z.string().trim().max(50_000).optional().nullable(),
|
||||||
|
price: optionalPriceField,
|
||||||
|
sku: z.string().trim().max(200).optional().nullable(),
|
||||||
|
imageUrl: z.string().trim().max(4000).optional().nullable(),
|
||||||
|
quantity: z.number().int().min(0).max(1e9).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const shopifyProductIdSchema = z.union([
|
||||||
|
z.number().int().positive(),
|
||||||
|
z.string().trim().min(1).max(64),
|
||||||
|
])
|
||||||
|
|
||||||
|
const PRODUCT_SYNC_JSON_MAX = 2_000_000
|
||||||
|
|
||||||
|
/** POST /api/products/sync */
|
||||||
|
export const productSyncRequestSchema = z
|
||||||
|
.object({
|
||||||
|
shopifyProductId: shopifyProductIdSchema.transform((v) => String(v)),
|
||||||
|
shopifyProductTitle: z.string().trim().min(1).max(500),
|
||||||
|
shopifyProductData: z.unknown().optional(),
|
||||||
|
supplierId: z.string().trim().min(1).max(128),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.shopifyProductData === undefined) return
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(data.shopifyProductData)
|
||||||
|
if (s.length > PRODUCT_SYNC_JSON_MAX) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["shopifyProductData"],
|
||||||
|
message: "shopifyProductData too large",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["shopifyProductData"],
|
||||||
|
message: "Invalid shopifyProductData",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/supplier/profile */
|
||||||
|
export const supplierProfileUpsertSchema = z.object({
|
||||||
|
companyName: z.string().trim().min(1, "Company name is required").max(200),
|
||||||
|
description: z.string().trim().max(10_000).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PUT /api/products/sync/[syncId] */
|
||||||
|
export const productSyncDecisionSchema = z.object({
|
||||||
|
action: z.enum(["accept", "reject"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SupplierProductCreateInput = z.infer<typeof supplierProductCreateSchema>
|
||||||
|
export type SupplierProductUpdateInput = z.infer<typeof supplierProductUpdateSchema>
|
||||||
|
export type ProductSyncRequestInput = z.infer<typeof productSyncRequestSchema>
|
||||||
|
export type SupplierProfileUpsertInput = z.infer<typeof supplierProfileUpsertSchema>
|
||||||
|
export type ProductSyncDecisionInput = z.infer<typeof productSyncDecisionSchema>
|
||||||
180
src/lib/api-schemas/extraMutations.ts
Normal file
180
src/lib/api-schemas/extraMutations.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { emailSchema } from "@/lib/validation"
|
||||||
|
|
||||||
|
const totpCodeSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(6, "Verification code must be 6 digits")
|
||||||
|
.regex(/^\d+$/, "Verification code must be numeric")
|
||||||
|
|
||||||
|
/** POST /api/auth/verify-mfa */
|
||||||
|
export const authVerifyMfaSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
code: totpCodeSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/auth/verify-email/verify */
|
||||||
|
export const authVerifyEmailBodySchema = z.object({
|
||||||
|
token: z.string().trim().min(1).max(512),
|
||||||
|
email: emailSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/settings/mfa/toggle */
|
||||||
|
export const mfaToggleSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/settings/mfa/verify */
|
||||||
|
export const mfaSetupVerifySchema = z.object({
|
||||||
|
code: totpCodeSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/stores/switch */
|
||||||
|
export const storeSwitchSchema = z.object({
|
||||||
|
storeId: z.string().trim().min(1).max(128),
|
||||||
|
})
|
||||||
|
|
||||||
|
const shopifyDomainField = z.string().trim().min(1).max(512)
|
||||||
|
const accessTokenField = z.string().trim().min(1).max(4000)
|
||||||
|
|
||||||
|
/** POST /api/onboarding/complete */
|
||||||
|
export const onboardingCompleteSchema = z.object({
|
||||||
|
businessName: z.string().trim().min(1).max(200),
|
||||||
|
businessEmail: z.union([emailSchema, z.literal("")]).optional(),
|
||||||
|
country: z.string().trim().max(100).optional(),
|
||||||
|
timezone: z.string().trim().max(100).optional(),
|
||||||
|
storeName: z.string().trim().max(200).optional(),
|
||||||
|
domain: shopifyDomainField,
|
||||||
|
apiKey: z.string().trim().max(500).optional(),
|
||||||
|
apiSecret: z.string().trim().max(500).optional(),
|
||||||
|
accessToken: accessTokenField,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/onboarding/shopify */
|
||||||
|
export const onboardingShopifySchema = z.object({
|
||||||
|
businessName: z.string().trim().min(1).max(200),
|
||||||
|
storeName: z.string().trim().min(1).max(200),
|
||||||
|
domain: shopifyDomainField,
|
||||||
|
apiKey: z.string().trim().min(1).max(500),
|
||||||
|
apiSecret: z.string().trim().min(1).max(500),
|
||||||
|
accessToken: accessTokenField,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/stores/[storeId]/kb/query */
|
||||||
|
export const kbQuerySchema = z
|
||||||
|
.object({
|
||||||
|
query: z.string().max(16_000),
|
||||||
|
topK: z.number().int().min(1).max(20).optional(),
|
||||||
|
includeFaq: z.boolean().optional(),
|
||||||
|
includeDocuments: z.boolean().optional(),
|
||||||
|
includeStructured: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.transform((d) => ({
|
||||||
|
...d,
|
||||||
|
query: d.query.trim(),
|
||||||
|
}))
|
||||||
|
.refine((d) => d.query.length >= 1, { message: "Query is required", path: ["query"] })
|
||||||
|
|
||||||
|
/** POST /api/stores/[storeId]/kb/faqs */
|
||||||
|
export const kbFaqCreateSchema = z.object({
|
||||||
|
question: z.string().trim().min(1).max(4000),
|
||||||
|
answer: z.string().trim().min(1).max(50_000),
|
||||||
|
tags: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const FAQ_IMPORT_MAX_ITEMS = 500
|
||||||
|
|
||||||
|
/** JSON array for POST /api/stores/[storeId]/kb/faqs/import */
|
||||||
|
export const kbFaqImportArraySchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string().trim().min(1).max(4000),
|
||||||
|
answer: z.string().trim().min(1).max(50_000),
|
||||||
|
tags: z.unknown().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "At least one FAQ is required")
|
||||||
|
.max(FAQ_IMPORT_MAX_ITEMS, `At most ${FAQ_IMPORT_MAX_ITEMS} FAQs per import`)
|
||||||
|
|
||||||
|
/** PUT /api/supplier/vendors/[connectionId] */
|
||||||
|
export const supplierConnectionStatusSchema = z.object({
|
||||||
|
status: z.enum(["CONNECTED", "REJECTED"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const finitePrice = z.number().finite().min(0).max(1e12)
|
||||||
|
const positiveQty = z.number().int().min(1).max(1e9)
|
||||||
|
|
||||||
|
/** PUT /api/supplier/vendors/offers/[offerId] */
|
||||||
|
export const supplierOfferUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
price: finitePrice.optional(),
|
||||||
|
quantity: positiveQty.optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => d.price !== undefined || d.quantity !== undefined, {
|
||||||
|
message: "At least one of price or quantity must be provided",
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/supplier/vendors/[connectionId]/offers */
|
||||||
|
export const supplierOfferCreateSchema = z.object({
|
||||||
|
productId: z.string().trim().min(1).max(128),
|
||||||
|
price: finitePrice,
|
||||||
|
quantity: positiveQty,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/vendors/suppliers/[supplierId]/invite */
|
||||||
|
export const vendorInvitePostSchema = z
|
||||||
|
.object({
|
||||||
|
connectToAllStores: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
/** DELETE /api/vendors/suppliers/[supplierId]/invite */
|
||||||
|
export const vendorInviteDeleteSchema = z
|
||||||
|
.object({
|
||||||
|
storeId: z.string().trim().min(1).max(128).optional().nullable(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
/** POST /api/supplier/invitations/accept */
|
||||||
|
export const supplierInvitationAcceptSchema = z.object({
|
||||||
|
token: z.string().trim().min(1).max(128),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/juno-engine/flagged-emails */
|
||||||
|
export const flaggedEmailCreateSchema = z.object({
|
||||||
|
subject: z.string().trim().min(1).max(500),
|
||||||
|
from: z.string().trim().min(1).max(500),
|
||||||
|
reason: z.string().trim().min(1).max(8000),
|
||||||
|
priority: z.enum(["LOW", "MEDIUM", "HIGH"]).optional(),
|
||||||
|
messageId: z.string().trim().max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/shopify/validate */
|
||||||
|
export const shopifyValidateSchema = z.object({
|
||||||
|
domain: shopifyDomainField,
|
||||||
|
accessToken: accessTokenField,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PATCH /api/stores/[storeId]/shopify-access-token — vendor updates token after app reinstall / scope change */
|
||||||
|
export const storeShopifyAccessTokenUpdateSchema = z.object({
|
||||||
|
accessToken: accessTokenField,
|
||||||
|
})
|
||||||
|
|
||||||
|
function optionalTrackingField(maxLen: number) {
|
||||||
|
return z
|
||||||
|
.union([z.string(), z.number(), z.null(), z.undefined()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === null) return null
|
||||||
|
const t = String(v).trim()
|
||||||
|
return t === "" ? null : t
|
||||||
|
})
|
||||||
|
.refine((v) => v === null || v.length <= maxLen, "Tracking value too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/supplier/orders/[orderId] */
|
||||||
|
export const supplierOrderPatchSchema = z.object({
|
||||||
|
fulfillmentStatus: z.union([z.string(), z.number(), z.null(), z.undefined()]).optional(),
|
||||||
|
trackingNumber: optionalTrackingField(500),
|
||||||
|
trackingCompany: optionalTrackingField(500),
|
||||||
|
trackingUrl: optionalTrackingField(2000),
|
||||||
|
})
|
||||||
6
src/lib/api-schemas/index.ts
Normal file
6
src/lib/api-schemas/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./public"
|
||||||
|
export * from "./business"
|
||||||
|
export * from "./notificationsPush"
|
||||||
|
export * from "./vendorMutations"
|
||||||
|
export * from "./extraMutations"
|
||||||
|
export { validationErrorResponse } from "./validate"
|
||||||
14
src/lib/api-schemas/notificationsPush.ts
Normal file
14
src/lib/api-schemas/notificationsPush.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/** Web Push subscription body (authenticated). */
|
||||||
|
export const webPushSubscribeSchema = z.object({
|
||||||
|
endpoint: z.string().url().max(4000),
|
||||||
|
keys: z.object({
|
||||||
|
p256dh: z.string().min(1).max(4000),
|
||||||
|
auth: z.string().min(1).max(500),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const webPushUnsubscribeSchema = z.object({
|
||||||
|
endpoint: z.string().url().max(4000),
|
||||||
|
})
|
||||||
111
src/lib/api-schemas/public.ts
Normal file
111
src/lib/api-schemas/public.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { emailSchema } from "@/lib/validation"
|
||||||
|
|
||||||
|
function optionalEmailField() {
|
||||||
|
return z
|
||||||
|
.union([z.string(), z.null(), z.undefined()])
|
||||||
|
.optional()
|
||||||
|
.transform((s) => {
|
||||||
|
if (s === undefined || s === null) return undefined
|
||||||
|
const t = s.trim()
|
||||||
|
return t === "" ? undefined : t
|
||||||
|
})
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (val === undefined) return
|
||||||
|
const r = emailSchema.safeParse(val)
|
||||||
|
if (!r.success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: r.error.issues[0]?.message ?? "Invalid email",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newsletter footer subscription */
|
||||||
|
export const newsletterSubscribeSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
const storefrontShopSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Missing shop")
|
||||||
|
.max(512, "Shop identifier too long")
|
||||||
|
|
||||||
|
const storefrontVisitorIdSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Missing visitorId")
|
||||||
|
.max(300, "visitorId too long")
|
||||||
|
|
||||||
|
/** Anonymous storefront widget message */
|
||||||
|
export const storefrontChatPostSchema = z.object({
|
||||||
|
shop: storefrontShopSchema,
|
||||||
|
visitorId: storefrontVisitorIdSchema,
|
||||||
|
content: z
|
||||||
|
.string()
|
||||||
|
.max(2000, "Message too long")
|
||||||
|
.transform((s) => s.trim())
|
||||||
|
.refine((s) => s.length >= 1, "Empty message"),
|
||||||
|
customerEmail: optionalEmailField(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CHAT_MESSAGE_CONTENT_MAX = 16_000
|
||||||
|
|
||||||
|
/** Serialized JSON size cap for PRODUCT/ORDER attachments (DoS guard). */
|
||||||
|
export const CHAT_ATTACHMENT_JSON_MAX = 100_000
|
||||||
|
|
||||||
|
const chatKindSchema = z.enum(["TEXT", "PRODUCT", "ORDER"])
|
||||||
|
|
||||||
|
/** B2B JUNO chat POST body */
|
||||||
|
export const chatMessagePostSchema = z
|
||||||
|
.object({
|
||||||
|
receiverId: z.string().trim().min(1).max(128),
|
||||||
|
storeId: z.string().trim().min(1).max(128),
|
||||||
|
content: z.string().max(CHAT_MESSAGE_CONTENT_MAX).default(""),
|
||||||
|
kind: chatKindSchema.optional(),
|
||||||
|
attachment: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const kind = data.kind ?? "TEXT"
|
||||||
|
if (kind === "TEXT") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.attachment === undefined || data.attachment === null) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["attachment"],
|
||||||
|
message: "Missing attachment for typed message",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof data.attachment !== "object" || Array.isArray(data.attachment)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["attachment"],
|
||||||
|
message: "Invalid attachment",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(data.attachment)
|
||||||
|
if (serialized.length > CHAT_ATTACHMENT_JSON_MAX) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["attachment"],
|
||||||
|
message: "attachment too large",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["attachment"],
|
||||||
|
message: "Invalid attachment",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema>
|
||||||
|
export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema>
|
||||||
|
export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema>
|
||||||
222
src/lib/api-schemas/schemas.test.ts
Normal file
222
src/lib/api-schemas/schemas.test.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
newsletterSubscribeSchema,
|
||||||
|
storefrontChatPostSchema,
|
||||||
|
chatMessagePostSchema,
|
||||||
|
CHAT_MESSAGE_CONTENT_MAX,
|
||||||
|
CHAT_ATTACHMENT_JSON_MAX,
|
||||||
|
} from "./public"
|
||||||
|
import {
|
||||||
|
supplierProductCreateSchema,
|
||||||
|
supplierProductUpdateSchema,
|
||||||
|
productSyncRequestSchema,
|
||||||
|
supplierProfileUpsertSchema,
|
||||||
|
productSyncDecisionSchema,
|
||||||
|
} from "./business"
|
||||||
|
import {
|
||||||
|
junoTemplateCreateSchema,
|
||||||
|
junoTemplatePatchSchema,
|
||||||
|
notificationsMarkReadSchema,
|
||||||
|
} from "./vendorMutations"
|
||||||
|
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from "./notificationsPush"
|
||||||
|
import {
|
||||||
|
authVerifyMfaSchema,
|
||||||
|
kbFaqImportArraySchema,
|
||||||
|
supplierOfferUpdateSchema,
|
||||||
|
supplierOrderPatchSchema,
|
||||||
|
vendorInvitePostSchema,
|
||||||
|
} from "./extraMutations"
|
||||||
|
|
||||||
|
describe("newsletterSubscribeSchema", () => {
|
||||||
|
it("accepts valid email", () => {
|
||||||
|
const r = newsletterSubscribeSchema.safeParse({ email: " a@b.co " })
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
if (r.success) expect(r.data.email).toContain("@")
|
||||||
|
})
|
||||||
|
it("rejects invalid email", () => {
|
||||||
|
expect(newsletterSubscribeSchema.safeParse({ email: "nope" }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("storefrontChatPostSchema", () => {
|
||||||
|
it("rejects empty content after trim", () => {
|
||||||
|
expect(
|
||||||
|
storefrontChatPostSchema.safeParse({
|
||||||
|
shop: "x.myshopify.com",
|
||||||
|
visitorId: "v1",
|
||||||
|
content: " ",
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("chatMessagePostSchema", () => {
|
||||||
|
it("rejects oversize content", () => {
|
||||||
|
expect(
|
||||||
|
chatMessagePostSchema.safeParse({
|
||||||
|
receiverId: "u",
|
||||||
|
storeId: "s",
|
||||||
|
content: "x".repeat(CHAT_MESSAGE_CONTENT_MAX + 1),
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
it("requires attachment for PRODUCT", () => {
|
||||||
|
expect(
|
||||||
|
chatMessagePostSchema.safeParse({
|
||||||
|
receiverId: "u",
|
||||||
|
storeId: "s",
|
||||||
|
kind: "PRODUCT",
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
it("rejects huge attachment JSON", () => {
|
||||||
|
const big = { x: "y".repeat(CHAT_ATTACHMENT_JSON_MAX) }
|
||||||
|
expect(
|
||||||
|
chatMessagePostSchema.safeParse({
|
||||||
|
receiverId: "u",
|
||||||
|
storeId: "s",
|
||||||
|
kind: "PRODUCT",
|
||||||
|
attachment: big,
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supplierProductCreateSchema", () => {
|
||||||
|
it("parses string price", () => {
|
||||||
|
const r = supplierProductCreateSchema.safeParse({
|
||||||
|
title: "T",
|
||||||
|
price: "12.50",
|
||||||
|
})
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
if (r.success) expect(r.data.price).toBe(12.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supplierProductUpdateSchema", () => {
|
||||||
|
it("allows empty patch object", () => {
|
||||||
|
expect(supplierProductUpdateSchema.safeParse({}).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("productSyncRequestSchema", () => {
|
||||||
|
it("coerces numeric shopify id to string", () => {
|
||||||
|
const r = productSyncRequestSchema.safeParse({
|
||||||
|
shopifyProductId: 12345,
|
||||||
|
shopifyProductTitle: "Hi",
|
||||||
|
supplierId: "sup",
|
||||||
|
})
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
if (r.success) expect(r.data.shopifyProductId).toBe("12345")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supplierProfileUpsertSchema", () => {
|
||||||
|
it("requires company name", () => {
|
||||||
|
expect(supplierProfileUpsertSchema.safeParse({ companyName: "" }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("productSyncDecisionSchema", () => {
|
||||||
|
it("only allows accept/reject", () => {
|
||||||
|
expect(productSyncDecisionSchema.safeParse({ action: "accept" }).success).toBe(true)
|
||||||
|
expect(productSyncDecisionSchema.safeParse({ action: "hold" }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("junoTemplateCreateSchema", () => {
|
||||||
|
it("requires core fields", () => {
|
||||||
|
expect(
|
||||||
|
junoTemplateCreateSchema.safeParse({
|
||||||
|
name: "N",
|
||||||
|
intent: "i",
|
||||||
|
mood: "m",
|
||||||
|
body: "b",
|
||||||
|
}).success,
|
||||||
|
).toBe(true)
|
||||||
|
expect(junoTemplateCreateSchema.safeParse({ name: "N" }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("junoTemplatePatchSchema", () => {
|
||||||
|
it("allows partial body", () => {
|
||||||
|
expect(junoTemplatePatchSchema.safeParse({ body: "x" }).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("notificationsMarkReadSchema", () => {
|
||||||
|
it("requires markAll or ids", () => {
|
||||||
|
expect(notificationsMarkReadSchema.safeParse({ markAllAsRead: true }).success).toBe(true)
|
||||||
|
expect(
|
||||||
|
notificationsMarkReadSchema.safeParse({ notificationIds: ["a"] }).success,
|
||||||
|
).toBe(true)
|
||||||
|
expect(notificationsMarkReadSchema.safeParse({}).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("webPushSubscribeSchema", () => {
|
||||||
|
it("validates endpoint URL", () => {
|
||||||
|
expect(
|
||||||
|
webPushSubscribeSchema.safeParse({
|
||||||
|
endpoint: "not-a-url",
|
||||||
|
keys: { p256dh: "x", auth: "y" },
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
webPushSubscribeSchema.safeParse({
|
||||||
|
endpoint: "https://example.com/push/123",
|
||||||
|
keys: { p256dh: "x", auth: "y" },
|
||||||
|
}).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("webPushUnsubscribeSchema", () => {
|
||||||
|
it("requires endpoint URL", () => {
|
||||||
|
expect(webPushUnsubscribeSchema.safeParse({ endpoint: "https://x.test/e" }).success).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("authVerifyMfaSchema", () => {
|
||||||
|
it("requires 6-digit numeric code", () => {
|
||||||
|
expect(
|
||||||
|
authVerifyMfaSchema.safeParse({ email: "a@b.co", code: "123456" }).success,
|
||||||
|
).toBe(true)
|
||||||
|
expect(authVerifyMfaSchema.safeParse({ email: "a@b.co", code: "12345" }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("kbFaqImportArraySchema", () => {
|
||||||
|
it("rejects empty array", () => {
|
||||||
|
expect(kbFaqImportArraySchema.safeParse([]).success).toBe(false)
|
||||||
|
})
|
||||||
|
it("accepts one valid item", () => {
|
||||||
|
expect(
|
||||||
|
kbFaqImportArraySchema.safeParse([{ question: "Q?", answer: "A." }]).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supplierOfferUpdateSchema", () => {
|
||||||
|
it("requires at least one field", () => {
|
||||||
|
expect(supplierOfferUpdateSchema.safeParse({}).success).toBe(false)
|
||||||
|
expect(supplierOfferUpdateSchema.safeParse({ price: 1 }).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supplierOrderPatchSchema", () => {
|
||||||
|
it("allows empty patch", () => {
|
||||||
|
expect(supplierOrderPatchSchema.safeParse({}).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("vendorInvitePostSchema", () => {
|
||||||
|
it("rejects unknown keys", () => {
|
||||||
|
expect(vendorInvitePostSchema.safeParse({ connectToAllStores: true }).success).toBe(true)
|
||||||
|
expect(vendorInvitePostSchema.safeParse({ extra: 1 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/lib/api-schemas/validate.ts
Normal file
14
src/lib/api-schemas/validate.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { ZodError } from "zod"
|
||||||
|
|
||||||
|
/** Standard 400 for Zod failures (field + message for client UX). */
|
||||||
|
export function validationErrorResponse(error: ZodError) {
|
||||||
|
const first = error.issues[0]
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: first?.message ?? "Invalid input",
|
||||||
|
field: first?.path?.length ? first.path.join(".") : undefined,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/lib/api-schemas/vendorMutations.ts
Normal file
75
src/lib/api-schemas/vendorMutations.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const TEMPLATE_VARS_JSON_MAX = 50_000
|
||||||
|
|
||||||
|
function refineTemplateVariables(variables: unknown, ctx: z.RefinementCtx, path: (string | number)[]) {
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(variables)
|
||||||
|
if (s.length > TEMPLATE_VARS_JSON_MAX) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path,
|
||||||
|
message: "variables JSON too large",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path,
|
||||||
|
message: "Invalid variables",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/juno-engine/templates */
|
||||||
|
export const junoTemplateCreateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
intent: z.string().trim().min(1).max(100),
|
||||||
|
mood: z.string().trim().min(1).max(100),
|
||||||
|
description: z.string().trim().max(2000).nullable().optional(),
|
||||||
|
body: z.string().max(200_000),
|
||||||
|
variables: z.unknown().optional(),
|
||||||
|
isHtml: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.variables !== undefined) {
|
||||||
|
refineTemplateVariables(data.variables, ctx, ["variables"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PUT /api/juno-engine/templates/[templateId] — partial update */
|
||||||
|
export const junoTemplatePatchSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(200).optional(),
|
||||||
|
intent: z.string().trim().min(1).max(100).optional(),
|
||||||
|
mood: z.string().trim().min(1).max(100).optional(),
|
||||||
|
description: z.string().trim().max(2000).nullable().optional(),
|
||||||
|
body: z.string().max(200_000).optional(),
|
||||||
|
variables: z.unknown().optional(),
|
||||||
|
isHtml: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.variables !== undefined) {
|
||||||
|
refineTemplateVariables(data.variables, ctx, ["variables"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /api/notifications — mark read */
|
||||||
|
export const notificationsMarkReadSchema = z
|
||||||
|
.object({
|
||||||
|
markAllAsRead: z.boolean().optional(),
|
||||||
|
notificationIds: z.array(z.string().trim().min(1).max(128)).max(200).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.markAllAsRead === true) return
|
||||||
|
if (data.notificationIds && data.notificationIds.length > 0) return
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Provide markAllAsRead: true or a non-empty notificationIds array",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export type JunoTemplateCreateInput = z.infer<typeof junoTemplateCreateSchema>
|
||||||
|
export type JunoTemplatePatchInput = z.infer<typeof junoTemplatePatchSchema>
|
||||||
|
export type NotificationsMarkReadInput = z.infer<typeof notificationsMarkReadSchema>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Prisma } from "@prisma/client"
|
import type { Prisma } from "@prisma/client"
|
||||||
import { prisma } from "./prisma"
|
import { prisma } from "./prisma"
|
||||||
|
import { fetchProductMetafieldsMap } from "@/lib/shopify/productMetafieldsGraphql"
|
||||||
|
|
||||||
/** Minimal Shopify API shapes for cache updates */
|
/** Minimal Shopify API shapes for cache updates */
|
||||||
interface ShopifyProductRecord {
|
interface ShopifyProductRecord {
|
||||||
@ -130,6 +131,28 @@ export async function getCachedCustomers(storeId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) {
|
export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) {
|
||||||
try {
|
try {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let metafieldsByProductId = new Map<string, Prisma.InputJsonValue>()
|
||||||
|
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
|
||||||
|
try {
|
||||||
|
const ids = products.map((p) => p.id)
|
||||||
|
const map = await fetchProductMetafieldsMap(
|
||||||
|
store.shopifyDomain,
|
||||||
|
store.shopifyAccessToken,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
for (const [pid, rows] of map) {
|
||||||
|
metafieldsByProductId.set(pid, rows as Prisma.InputJsonValue)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[updateProductCache] Metafields GraphQL fetch failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete existing cached products
|
// Delete existing cached products
|
||||||
await prisma.cachedProduct.deleteMany({
|
await prisma.cachedProduct.deleteMany({
|
||||||
where: { storeId }
|
where: { storeId }
|
||||||
@ -144,7 +167,9 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
|
|||||||
productType: product.product_type || null,
|
productType: product.product_type || null,
|
||||||
status: product.status || null,
|
status: product.status || null,
|
||||||
variants: (product.variants || []) as import("@prisma/client").Prisma.InputJsonValue,
|
variants: (product.variants || []) as import("@prisma/client").Prisma.InputJsonValue,
|
||||||
images: (product.images || []) as import("@prisma/client").Prisma.InputJsonValue
|
images: (product.images || []) as import("@prisma/client").Prisma.InputJsonValue,
|
||||||
|
metafields: (metafieldsByProductId.get(product.id.toString()) ??
|
||||||
|
[]) as Prisma.InputJsonValue,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await prisma.cachedProduct.createMany({
|
await prisma.cachedProduct.createMany({
|
||||||
@ -398,6 +423,28 @@ export async function updateCustomerCache(storeId: string, customers: ShopifyCus
|
|||||||
* Update a single product in cache (for webhook updates)
|
* Update a single product in cache (for webhook updates)
|
||||||
*/
|
*/
|
||||||
export async function updateSingleProduct(storeId: string, product: ShopifyProductRecord) {
|
export async function updateSingleProduct(storeId: string, product: ShopifyProductRecord) {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let metafieldsPatch: Prisma.InputJsonValue | undefined
|
||||||
|
if (store?.shopifyDomain && store?.shopifyAccessToken) {
|
||||||
|
try {
|
||||||
|
const map = await fetchProductMetafieldsMap(
|
||||||
|
store.shopifyDomain,
|
||||||
|
store.shopifyAccessToken,
|
||||||
|
[product.id],
|
||||||
|
)
|
||||||
|
const rows = map.get(product.id.toString())
|
||||||
|
if (rows !== undefined) {
|
||||||
|
metafieldsPatch = rows as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[updateSingleProduct] Metafields fetch failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.cachedProduct.upsert({
|
await prisma.cachedProduct.upsert({
|
||||||
where: {
|
where: {
|
||||||
shopifyProductId_storeId: {
|
shopifyProductId_storeId: {
|
||||||
@ -414,6 +461,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
|
|||||||
status: product.status || null,
|
status: product.status || null,
|
||||||
variants: (product.variants || []) as Prisma.InputJsonValue,
|
variants: (product.variants || []) as Prisma.InputJsonValue,
|
||||||
images: (product.images || []) as Prisma.InputJsonValue,
|
images: (product.images || []) as Prisma.InputJsonValue,
|
||||||
|
metafields: (metafieldsPatch ?? []) as Prisma.InputJsonValue,
|
||||||
lastSyncedAt: new Date()
|
lastSyncedAt: new Date()
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@ -423,6 +471,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
|
|||||||
status: product.status || null,
|
status: product.status || null,
|
||||||
variants: (product.variants || []) as Prisma.InputJsonValue,
|
variants: (product.variants || []) as Prisma.InputJsonValue,
|
||||||
images: (product.images || []) as Prisma.InputJsonValue,
|
images: (product.images || []) as Prisma.InputJsonValue,
|
||||||
|
...(metafieldsPatch !== undefined ? { metafields: metafieldsPatch } : {}),
|
||||||
lastSyncedAt: new Date()
|
lastSyncedAt: new Date()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -68,16 +68,18 @@ export const KNOWLEDGE_TEMPLATE_FIELDS: {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const answerMax = 12_000
|
/** Per-field answer cap (mirrored on structured KB form). */
|
||||||
const miscMax = 16_000
|
export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000
|
||||||
|
/** Misc notes cap. */
|
||||||
|
export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000
|
||||||
|
|
||||||
export const knowledgeTemplatePutSchema = z.object({
|
export const knowledgeTemplatePutSchema = z.object({
|
||||||
knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
|
knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
|
||||||
confirmDestructiveSwitch: z.boolean().optional(),
|
confirmDestructiveSwitch: z.boolean().optional(),
|
||||||
template: z
|
template: z
|
||||||
.object({
|
.object({
|
||||||
answers: z.record(z.string().max(answerMax)).optional(),
|
answers: z.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)).optional(),
|
||||||
misc: z.string().max(miscMax).optional(),
|
misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,14 +2,23 @@ type RateLimitResult = {
|
|||||||
success: boolean
|
success: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RateLimitOptions = {
|
||||||
|
/** Window length in ms (default 60_000). */
|
||||||
|
windowMs?: number
|
||||||
|
/** Max requests per window per IP (default 10). */
|
||||||
|
maxRequests?: number
|
||||||
|
}
|
||||||
|
|
||||||
// Simple in-memory sliding window rate limiter per-process.
|
// Simple in-memory sliding window rate limiter per-process.
|
||||||
// This avoids external dependencies while still providing basic protection.
|
// This avoids external dependencies while still providing basic protection.
|
||||||
const WINDOW_MS = 60_000 // 1 minute
|
const DEFAULT_WINDOW_MS = 60_000 // 1 minute
|
||||||
const MAX_REQUESTS = 10
|
const DEFAULT_MAX_REQUESTS = 10
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
count: number
|
count: number
|
||||||
windowStart: number
|
windowStart: number
|
||||||
|
windowMs: number
|
||||||
|
maxRequests: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const buckets = new Map<string, Entry>()
|
const buckets = new Map<string, Entry>()
|
||||||
@ -23,19 +32,33 @@ function getClientIp(req: Request): string {
|
|||||||
return req.headers.get("x-real-ip") ?? "unknown"
|
return req.headers.get("x-real-ip") ?? "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyRateLimit(req: Request, keyPrefix: string): Promise<RateLimitResult> {
|
/**
|
||||||
|
* Sliding-window rate limit by client IP. Key includes window/max so presets do not share wrong counters.
|
||||||
|
*/
|
||||||
|
export async function applyRateLimit(
|
||||||
|
req: Request,
|
||||||
|
keyPrefix: string,
|
||||||
|
options?: RateLimitOptions,
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS
|
||||||
|
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS
|
||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
const key = `${keyPrefix}:${ip}`
|
const key = `${keyPrefix}:w${windowMs}:m${maxRequests}:${ip}`
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
const existing = buckets.get(key)
|
const existing = buckets.get(key)
|
||||||
|
|
||||||
if (!existing || now - existing.windowStart > WINDOW_MS) {
|
if (!existing || now - existing.windowStart > existing.windowMs) {
|
||||||
buckets.set(key, { count: 1, windowStart: now })
|
buckets.set(key, {
|
||||||
|
count: 1,
|
||||||
|
windowStart: now,
|
||||||
|
windowMs,
|
||||||
|
maxRequests,
|
||||||
|
})
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.count >= MAX_REQUESTS) {
|
if (existing.count >= existing.maxRequests) {
|
||||||
return { success: false }
|
return { success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
src/lib/sanitize/escapeHtml.ts
Normal file
9
src/lib/sanitize/escapeHtml.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** Escape for safe insertion into HTML text nodes (newsletter, previews). */
|
||||||
|
export function escapeHtmlText(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
14
src/lib/sanitize/htmlPolicy.ts
Normal file
14
src/lib/sanitize/htmlPolicy.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* HTML / rich-text policy (JUNOHUB)
|
||||||
|
*
|
||||||
|
* - **User-supplied HTML** (email templates, KB HTML mode, product body_html previews):
|
||||||
|
* Prefer storing as-is only when rendered through a strict sanitizer in a follow-up.
|
||||||
|
* For email footers and any interpolated user strings, use `escapeHtmlText` from `./escapeHtml`.
|
||||||
|
* - **Widget script** (`src/app/api/stores/[storeId]/route.ts`):
|
||||||
|
* Branding strings are sanitized server-side (`sanitizeShortText`, `sanitizeColor`, JSON-stringified in script).
|
||||||
|
* Header title/tagline are applied with `textContent` in the injected script (not HTML concatenation).
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { escapeHtmlText } from "./escapeHtml"
|
||||||
42
src/lib/shopify/adminPing.ts
Normal file
42
src/lib/shopify/adminPing.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/** Normalizes Shopify admin host (e.g. strip https:// and trailing slash). */
|
||||||
|
export function cleanShopifyDomain(domain: string): string {
|
||||||
|
return domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an Admin API access token against the store domain (GET shop.json).
|
||||||
|
* Single attempt; callers may retry for transient errors if needed.
|
||||||
|
*/
|
||||||
|
export async function pingShopifyAdminApi(
|
||||||
|
cleanDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; message: string; status?: number }> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12_000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/shop.json`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
message:
|
||||||
|
"Shopify did not accept this token for your store. Paste the Admin API access token from the reinstalled app (with the right scopes).",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return { ok: false, message: "Connection to Shopify timed out. Try again." }
|
||||||
|
}
|
||||||
|
return { ok: false, message: "Could not reach Shopify. Check your network and store domain." }
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/lib/shopify/productMetafieldsGraphql.ts
Normal file
114
src/lib/shopify/productMetafieldsGraphql.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { cleanShopifyDomain } from "@/lib/shopify/adminPing"
|
||||||
|
|
||||||
|
/** One metafield row stored in CachedProduct.metafields JSON (category attributes + custom). */
|
||||||
|
export type CachedShopifyMetafield = {
|
||||||
|
namespace: string
|
||||||
|
key: string
|
||||||
|
value: string | null
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphQLProductNode = {
|
||||||
|
legacyResourceId?: string | number | null
|
||||||
|
metafields?: {
|
||||||
|
nodes?: Array<{
|
||||||
|
namespace?: string
|
||||||
|
key?: string
|
||||||
|
value?: string | null
|
||||||
|
type?: string
|
||||||
|
} | null> | null
|
||||||
|
} | null
|
||||||
|
} | null
|
||||||
|
|
||||||
|
type GraphQLResponse = {
|
||||||
|
data?: { nodes?: GraphQLProductNode[] | null }
|
||||||
|
errors?: Array<{ message?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRAPHQL_API_VERSION = "2024-10"
|
||||||
|
/** Keep batches small to stay under GraphQL cost limits (many metafields per product). */
|
||||||
|
const PRODUCT_IDS_PER_REQUEST = 35
|
||||||
|
|
||||||
|
function toRows(metafields: NonNullable<GraphQLProductNode>["metafields"]): CachedShopifyMetafield[] {
|
||||||
|
const nodes = metafields?.nodes
|
||||||
|
if (!Array.isArray(nodes)) return []
|
||||||
|
const out: CachedShopifyMetafield[] = []
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!n || typeof n.namespace !== "string" || typeof n.key !== "string") continue
|
||||||
|
out.push({
|
||||||
|
namespace: n.namespace,
|
||||||
|
key: n.key,
|
||||||
|
value: n.value ?? null,
|
||||||
|
type: typeof n.type === "string" ? n.type : "unknown",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches product metafields via Admin GraphQL (`nodes` query).
|
||||||
|
* Covers category / taxonomy metafields (often `shopify.*` keys) shown in Shopify admin.
|
||||||
|
*/
|
||||||
|
export async function fetchProductMetafieldsMap(
|
||||||
|
shopifyDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
productNumericIds: number[],
|
||||||
|
): Promise<Map<string, CachedShopifyMetafield[]>> {
|
||||||
|
const result = new Map<string, CachedShopifyMetafield[]>()
|
||||||
|
const cleanDomain = cleanShopifyDomain(shopifyDomain)
|
||||||
|
if (!cleanDomain || !accessToken || productNumericIds.length === 0) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://${cleanDomain}/admin/api/${GRAPHQL_API_VERSION}/graphql.json`
|
||||||
|
|
||||||
|
for (let i = 0; i < productNumericIds.length; i += PRODUCT_IDS_PER_REQUEST) {
|
||||||
|
const chunk = productNumericIds.slice(i, i + PRODUCT_IDS_PER_REQUEST)
|
||||||
|
const gidList = chunk.map((id) => `"gid://shopify/Product/${id}"`).join(", ")
|
||||||
|
const query = `
|
||||||
|
query ProductMetafieldsChunk {
|
||||||
|
nodes(ids: [${gidList}]) {
|
||||||
|
... on Product {
|
||||||
|
legacyResourceId
|
||||||
|
metafields(first: 250) {
|
||||||
|
nodes {
|
||||||
|
namespace
|
||||||
|
key
|
||||||
|
value
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = (await res.json()) as GraphQLResponse
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Shopify GraphQL HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
if (json.errors?.length) {
|
||||||
|
throw new Error(json.errors.map((e) => e.message).filter(Boolean).join("; ") || "GraphQL error")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = json.data?.nodes
|
||||||
|
if (!Array.isArray(nodes)) continue
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node || node.legacyResourceId == null) continue
|
||||||
|
const idStr = String(node.legacyResourceId)
|
||||||
|
result.set(idStr, toRows(node.metafields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -30,13 +30,33 @@ function compact(value: string, max = 420) {
|
|||||||
return `${value.slice(0, max - 1)}...`
|
return `${value.slice(0, max - 1)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metafieldsForText(metafields: unknown): string {
|
||||||
|
if (!Array.isArray(metafields) || metafields.length === 0) return ""
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const row of metafields) {
|
||||||
|
if (!row || typeof row !== "object") continue
|
||||||
|
const r = row as { namespace?: string; key?: string; value?: string | null }
|
||||||
|
const k = r.key
|
||||||
|
const v = r.value
|
||||||
|
if (!k || v === null || v === undefined || String(v).trim() === "") continue
|
||||||
|
const ns = r.namespace ? `${r.namespace}.` : ""
|
||||||
|
parts.push(`${ns}${k}: ${String(v).slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
return parts.join(" | ")
|
||||||
|
}
|
||||||
|
|
||||||
function productPreview(product: any) {
|
function productPreview(product: any) {
|
||||||
const variants = Array.isArray(product?.variants) ? product.variants : []
|
const variants = Array.isArray(product?.variants) ? product.variants : []
|
||||||
const firstVariant = variants[0] || {}
|
const firstVariant = variants[0] || {}
|
||||||
const price = firstVariant?.price ?? null
|
const price = firstVariant?.price ?? null
|
||||||
const sku = firstVariant?.sku ?? null
|
const sku = firstVariant?.sku ?? null
|
||||||
|
const weight =
|
||||||
|
firstVariant?.weight !== undefined && firstVariant?.weight !== null
|
||||||
|
? `${String(firstVariant.weight)}${firstVariant.weight_unit ? ` ${firstVariant.weight_unit}` : ""}`
|
||||||
|
: ""
|
||||||
const inventory = firstVariant?.inventory_quantity ?? null
|
const inventory = firstVariant?.inventory_quantity ?? null
|
||||||
const status = product?.status || "unknown"
|
const status = product?.status || "unknown"
|
||||||
|
const mfLine = metafieldsForText(product?.metafields)
|
||||||
|
|
||||||
const bits = [
|
const bits = [
|
||||||
`Title: ${product?.title || "Unknown"}`,
|
`Title: ${product?.title || "Unknown"}`,
|
||||||
@ -45,7 +65,9 @@ function productPreview(product: any) {
|
|||||||
`Status: ${status}`,
|
`Status: ${status}`,
|
||||||
price !== null && price !== undefined ? `Price: ${String(price)}` : "",
|
price !== null && price !== undefined ? `Price: ${String(price)}` : "",
|
||||||
sku ? `SKU: ${String(sku)}` : "",
|
sku ? `SKU: ${String(sku)}` : "",
|
||||||
|
weight ? `Weight: ${weight}` : "",
|
||||||
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
|
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
|
||||||
|
mfLine ? `Details: ${mfLine}` : "",
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
return bits.join(" | ")
|
return bits.join(" | ")
|
||||||
@ -99,12 +121,13 @@ export async function getBlendedStorefrontContext(params: {
|
|||||||
status: true,
|
status: true,
|
||||||
variants: true,
|
variants: true,
|
||||||
images: true,
|
images: true,
|
||||||
|
metafields: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const rankedProducts: RetrievedContextItem[] = (products || [])
|
const rankedProducts: RetrievedContextItem[] = (products || [])
|
||||||
.map((prod: any) => {
|
.map((prod: any) => {
|
||||||
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""}`
|
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""} ${metafieldsForText(prod?.metafields)}`
|
||||||
const score = scoreByTerms(text, terms)
|
const score = scoreByTerms(text, terms)
|
||||||
return {
|
return {
|
||||||
source: "PRODUCT",
|
source: "PRODUCT",
|
||||||
|
|||||||
15
tests/e2e/smoke.spec.ts
Normal file
15
tests/e2e/smoke.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke checks against a running app (`pnpm dev` + PLAYWRIGHT_BASE_URL).
|
||||||
|
* Skipped by default so `pnpm test` / CI unit runs stay self-contained.
|
||||||
|
*/
|
||||||
|
test.describe("public smoke", () => {
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, "set PLAYWRIGHT_BASE_URL to run E2E")
|
||||||
|
|
||||||
|
test("login page responds", async ({ page }) => {
|
||||||
|
const res = await page.goto("/login")
|
||||||
|
expect(res?.ok()).toBeTruthy()
|
||||||
|
await expect(page.locator("body")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -26,10 +26,7 @@
|
|||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user