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 nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
127
package-lock.json
generated
127
package-lock.json
generated
@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "pdc-poc",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.41.0",
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@ -46,8 +47,9 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "20.19.39",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@ -1464,6 +1466,7 @@
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
@ -1483,6 +1486,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
|
||||
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
@ -2757,6 +2761,7 @@
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
@ -3011,6 +3016,21 @@
|
||||
"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": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz",
|
||||
@ -4614,6 +4634,60 @@
|
||||
"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": {
|
||||
"version": "4.1.18",
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
@ -4748,9 +4823,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@ -10064,6 +10139,50 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"dev": "next dev",
|
||||
"prebuild": "npx prisma generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"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",
|
||||
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||
"db:reset": "prisma migrate reset",
|
||||
@ -55,8 +57,9 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "20.19.39",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^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?
|
||||
variants Json // Store variants 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())
|
||||
updatedAt DateTime @updatedAt
|
||||
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 { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { handleApiError } from "@/lib/errors"
|
||||
import { authVerifyEmailBodySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@ -13,15 +14,12 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { token, email } = body
|
||||
|
||||
if (!token || !email) {
|
||||
return NextResponse.json(
|
||||
{ message: "Token and email are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = authVerifyEmailBodySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { token, email } = parsed.data
|
||||
|
||||
// Verify token
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
|
||||
@ -3,10 +3,16 @@ import { prisma } from "@/lib/prisma"
|
||||
import { verify } from "otplib"
|
||||
import { decryptString } from "@/lib/encryption"
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { authVerifyMfaSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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
|
||||
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
|
||||
const userResult = await prisma.$queryRaw<
|
||||
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,
|
||||
resolveB2bChatPair,
|
||||
} 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) {
|
||||
try {
|
||||
@ -15,17 +18,26 @@ export async function POST(req: Request) {
|
||||
return new NextResponse("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = body
|
||||
|
||||
if (!receiverId || typeof receiverId !== "string") {
|
||||
return new NextResponse("Missing fields", { status: 400 })
|
||||
const rate = await applyRateLimit(req, "chat:messages", {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 120,
|
||||
})
|
||||
if (!rate.success) {
|
||||
return NextResponse.json(
|
||||
{ message: "Too many requests. Please try again later." },
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!storeIdRaw || typeof storeIdRaw !== "string") {
|
||||
return new NextResponse("Missing storeId", { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
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)
|
||||
if (!pair) {
|
||||
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 })
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
@ -75,7 +73,7 @@ export async function POST(req: Request) {
|
||||
receiverId,
|
||||
storeId: storeIdRaw,
|
||||
kind: normalizedKind as any,
|
||||
content: (typeof content === "string" ? content : "") as any,
|
||||
content: content as any,
|
||||
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
||||
} as any,
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { flaggedEmailCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
async function getActiveStoreId(userId: string): Promise<string | null> {
|
||||
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 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { subject, from, reason, priority, messageId } = body as {
|
||||
subject?: string
|
||||
from?: string
|
||||
reason?: string
|
||||
priority?: "LOW" | "MEDIUM" | "HIGH"
|
||||
messageId?: string
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = flaggedEmailCreateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { subject, from, reason, priority, messageId } = parsed.data
|
||||
|
||||
if (!subject || !from || !reason) {
|
||||
return NextResponse.json(
|
||||
{ message: "subject, from, and reason are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const priorityVal = priority && ["LOW", "MEDIUM", "HIGH"].includes(priority)
|
||||
? priority
|
||||
: "MEDIUM"
|
||||
const priorityVal = priority ?? "MEDIUM"
|
||||
|
||||
const created = await prisma.flaggedEmail.create({
|
||||
data: {
|
||||
|
||||
@ -3,6 +3,8 @@ import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { junoTemplatePatchSchema } from "@/lib/api-schemas/vendorMutations"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
type TemplateRow = {
|
||||
id: string
|
||||
@ -130,11 +132,11 @@ export async function PUT(
|
||||
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body) {
|
||||
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = junoTemplatePatchSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
intent,
|
||||
@ -143,15 +145,7 @@ export async function PUT(
|
||||
body: templateBody,
|
||||
variables,
|
||||
isHtml,
|
||||
} = body as {
|
||||
name?: string
|
||||
intent?: string
|
||||
mood?: string
|
||||
description?: string | null
|
||||
body?: string
|
||||
variables?: unknown
|
||||
isHtml?: boolean
|
||||
}
|
||||
} = parsed.data
|
||||
|
||||
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||
UPDATE "Template"
|
||||
|
||||
@ -4,6 +4,8 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { randomUUID } from "crypto"
|
||||
import { junoTemplateCreateSchema } from "@/lib/api-schemas/vendorMutations"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
type TemplateRow = {
|
||||
id: string
|
||||
@ -122,28 +124,13 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body) {
|
||||
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = junoTemplateCreateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
|
||||
const { name, intent, mood, description, body: templateBody, variables, isHtml } =
|
||||
body as {
|
||||
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 },
|
||||
)
|
||||
}
|
||||
parsed.data
|
||||
|
||||
const id = randomUUID()
|
||||
|
||||
|
||||
@ -1,26 +1,39 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||
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
|
||||
// In a real app, you would save the email to a database (e.g., Prisma)
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { email } = await req.json()
|
||||
|
||||
if (!email || !email.includes("@")) {
|
||||
const rate = await applyRateLimit(req, "public:newsletter", {
|
||||
windowMs: 60_000,
|
||||
maxRequests: 20,
|
||||
})
|
||||
if (!rate.success) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid email address" },
|
||||
{ status: 400 }
|
||||
{ message: "Too many requests. Please try again later." },
|
||||
{ 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
|
||||
// and then save it to your 'Newsletter' table.
|
||||
// For now, we'll proceed to sending the welcome email.
|
||||
|
||||
const subject = "Welcome to the JUNO Newsletter! ⚡"
|
||||
const emailHtml = generateNewsletterWelcomeHtml(email)
|
||||
const emailHtml = generateNewsletterWelcomeHtml(escapeHtmlText(email))
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { notificationsMarkReadSchema } from "@/lib/api-schemas/vendorMutations"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
/**
|
||||
* GET /api/notifications
|
||||
@ -61,8 +63,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { notificationIds, markAllAsRead } = body
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = notificationsMarkReadSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { notificationIds, markAllAsRead } = parsed.data
|
||||
|
||||
if (markAllAsRead) {
|
||||
// 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" })
|
||||
}
|
||||
|
||||
if (notificationIds && Array.isArray(notificationIds) && notificationIds.length > 0) {
|
||||
// Mark specific notifications as read
|
||||
if (notificationIds && notificationIds.length > 0) {
|
||||
await prisma.notification.updateMany({
|
||||
where: {
|
||||
id: { in: notificationIds },
|
||||
userId: session.user.id
|
||||
userId: session.user.id,
|
||||
},
|
||||
data: {
|
||||
read: true,
|
||||
readAt: new Date()
|
||||
}
|
||||
readAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: "Notifications marked as read" })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid request. Provide notificationIds array or markAllAsRead: true" },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ message: "Invalid request" }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error("Error updating notifications:", error)
|
||||
return NextResponse.json(
|
||||
|
||||
@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
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) {
|
||||
try {
|
||||
@ -10,11 +12,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const subscription = await req.json()
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return NextResponse.json({ message: 'Invalid subscription data' }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = webPushSubscribeSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const subscription = parsed.data
|
||||
|
||||
// Upsert the subscription
|
||||
await prisma.pushSubscription.upsert({
|
||||
@ -49,11 +52,12 @@ export async function DELETE(req: Request) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { endpoint } = await req.json()
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ message: 'Endpoint is required' }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = webPushUnsubscribeSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { endpoint } = parsed.data
|
||||
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { endpoint }
|
||||
|
||||
@ -3,6 +3,7 @@ import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { onboardingCompleteSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
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 {
|
||||
businessName,
|
||||
businessEmail,
|
||||
@ -27,13 +32,8 @@ export async function POST(req: NextRequest) {
|
||||
domain,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
accessToken
|
||||
} = body
|
||||
|
||||
// Validate required fields
|
||||
if (!businessName || !domain || !accessToken) {
|
||||
return NextResponse.json({ message: "Missing required onboarding data." }, { status: 400 })
|
||||
}
|
||||
accessToken,
|
||||
} = parsed.data
|
||||
|
||||
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||
|
||||
@ -3,6 +3,7 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { NextResponse } from "next/server"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { onboardingShopifySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@ -17,19 +18,12 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
businessName,
|
||||
storeName,
|
||||
domain,
|
||||
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 raw = await req.json().catch(() => ({}))
|
||||
const parsed = onboardingShopifySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { businessName, storeName, domain, apiKey, apiSecret, accessToken } = parsed.data
|
||||
|
||||
// Multi-store: create a new store; set as active if first store
|
||||
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 { prisma } from "@/lib/prisma"
|
||||
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]
|
||||
@ -19,15 +21,12 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const { syncId } = await params
|
||||
const body = await req.json()
|
||||
const { action } = body // "accept" or "reject"
|
||||
|
||||
if (!action || !["accept", "reject"].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid action. Use 'accept' or 'reject'" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = productSyncDecisionSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { action } = parsed.data
|
||||
|
||||
// Get supplier profile
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
|
||||
@ -3,6 +3,8 @@ import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
import { productSyncRequestSchema } from "@/lib/api-schemas/business"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
/**
|
||||
* GET /api/products/sync
|
||||
@ -82,15 +84,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = body
|
||||
|
||||
if (!shopifyProductId || !supplierId || !shopifyProductTitle) {
|
||||
return NextResponse.json(
|
||||
{ message: "Product ID, title, and supplier ID are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = productSyncRequestSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = parsed.data
|
||||
|
||||
// Get vendor's active store
|
||||
const vendorStore = await prisma.store.findFirst({
|
||||
@ -150,7 +149,7 @@ export async function POST(req: Request) {
|
||||
storeId_supplierId_shopifyProductId: {
|
||||
storeId: vendorStore.id,
|
||||
supplierId: supplier.id,
|
||||
shopifyProductId: shopifyProductId.toString()
|
||||
shopifyProductId
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -211,7 +210,7 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
storeId: vendorStore.id,
|
||||
supplierId: supplier.id,
|
||||
shopifyProductId: shopifyProductId.toString(),
|
||||
shopifyProductId,
|
||||
shopifyProductTitle,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined,
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { decryptString } from "@/lib/encryption"
|
||||
import { mfaToggleSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@ -10,7 +11,12 @@ export async function POST(req: Request) {
|
||||
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
|
||||
const result = await prisma.$queryRaw<
|
||||
|
||||
@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
|
||||
import { verify } from "otplib"
|
||||
import { decryptString } from "@/lib/encryption"
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { mfaSetupVerifySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@ -12,7 +13,12 @@ export async function POST(req: Request) {
|
||||
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
|
||||
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
|
||||
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,
|
||||
variants: product.variants,
|
||||
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({
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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 MAX_RETRIES = 3
|
||||
@ -16,15 +18,14 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { domain, accessToken } = body
|
||||
|
||||
if (!domain || !accessToken) {
|
||||
return NextResponse.json({ message: "Store domain and access token are required." }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = shopifyValidateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { domain, accessToken } = parsed.data
|
||||
|
||||
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||
const cleanDomain = cleanShopifyDomain(domain)
|
||||
|
||||
let lastError: unknown = null
|
||||
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
||||
|
||||
type StorefrontMessagePayload = {
|
||||
shop: string
|
||||
visitorId: string
|
||||
content: string
|
||||
customerEmail?: string | null
|
||||
}
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
function normalizeShopDomain(input: string) {
|
||||
return input
|
||||
@ -29,35 +25,27 @@ function shopVariants(cleanDomain: string) {
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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 payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
|
||||
const { shop, visitorId, content } = payload
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
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)
|
||||
|
||||
// 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 stores = await p.store.findMany({
|
||||
where: { shopifyDomain: { in: variants } },
|
||||
@ -74,7 +62,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// Find or create conversation for this anonymous visitor.
|
||||
let conversation = await p.storefrontChatConversation.findFirst({
|
||||
where: { storeId: store.id, visitorId: trimmedVisitorId },
|
||||
where: { storeId: store.id, visitorId },
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
@ -95,8 +83,8 @@ export async function POST(req: Request) {
|
||||
conversation = await p.storefrontChatConversation.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
visitorId: trimmedVisitorId,
|
||||
customerEmail: payload.customerEmail ?? null
|
||||
visitorId,
|
||||
customerEmail: customerEmail ?? null,
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
@ -121,7 +109,7 @@ export async function POST(req: Request) {
|
||||
const ai = await generateStorefrontReply({
|
||||
storeId: store.id,
|
||||
query: trimmedContent,
|
||||
visitorId: trimmedVisitorId,
|
||||
visitorId,
|
||||
history: recentHistory.map((m: any) => ({
|
||||
senderType: m.senderType,
|
||||
content: m.content
|
||||
|
||||
@ -5,12 +5,7 @@ import { logger } from "@/lib/logger"
|
||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||
import type { FaqItem } from "@prisma/client"
|
||||
|
||||
type FaqImportItem = {
|
||||
question: string
|
||||
answer: string
|
||||
tags?: unknown
|
||||
}
|
||||
import { kbFaqImportArraySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@ -46,7 +41,7 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
let items: FaqImportItem[] = []
|
||||
let items: unknown[] = []
|
||||
|
||||
const contentType = request.headers.get("content-type") || ""
|
||||
if (contentType.includes("application/json")) {
|
||||
@ -57,7 +52,7 @@ export async function POST(
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
items = body as FaqImportItem[]
|
||||
items = body
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("file")
|
||||
@ -76,7 +71,7 @@ export async function POST(
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
items = parsed as FaqImportItem[]
|
||||
items = parsed
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid JSON in uploaded file" },
|
||||
@ -90,19 +85,14 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const validItems = items.filter(
|
||||
(item) => item.question && item.answer,
|
||||
) as FaqImportItem[]
|
||||
|
||||
if (!validItems.length) {
|
||||
return NextResponse.json(
|
||||
{ message: "No valid FAQs to import" },
|
||||
{ status: 400 },
|
||||
)
|
||||
const validated = kbFaqImportArraySchema.safeParse(items)
|
||||
if (!validated.success) {
|
||||
return validationErrorResponse(validated.error)
|
||||
}
|
||||
const validItems = validated.data
|
||||
|
||||
const createdFaqs = await prisma.$transaction(
|
||||
validItems.map((item: FaqImportItem) =>
|
||||
validItems.map((item) =>
|
||||
prisma.faqItem.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
|
||||
@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||
import { kbFaqCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@ -78,19 +79,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { question, answer, tags } = body as {
|
||||
question?: string
|
||||
answer?: string
|
||||
tags?: unknown
|
||||
}
|
||||
|
||||
if (!question || !answer) {
|
||||
return NextResponse.json(
|
||||
{ message: "Question and answer are required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = kbFaqCreateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { question, answer, tags } = parsed.data
|
||||
|
||||
const faq = await (prisma as any).faqItem.create({
|
||||
data: {
|
||||
|
||||
@ -5,14 +5,7 @@ import { logger } from "@/lib/logger"
|
||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||
import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta"
|
||||
import { generateKbRagAnswer } from "@/lib/kb/kbAnswer"
|
||||
|
||||
type QueryBody = {
|
||||
query?: string
|
||||
topK?: number
|
||||
includeFaq?: boolean
|
||||
includeDocuments?: boolean
|
||||
includeStructured?: boolean
|
||||
}
|
||||
import { kbQuerySchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
type KnowledgeChunkRow = {
|
||||
id: string
|
||||
@ -48,14 +41,13 @@ export async function POST(
|
||||
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as QueryBody
|
||||
const question = body.query?.trim()
|
||||
if (!question) {
|
||||
return NextResponse.json(
|
||||
{ message: "Query is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = kbQuerySchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const body = parsed.data
|
||||
const question = body.query
|
||||
|
||||
const topK = Math.min(Math.max(body.topK ?? 8, 1), 20)
|
||||
const includeFaq = body.includeFaq ?? true
|
||||
|
||||
@ -268,11 +268,23 @@ function buildStorefrontScript(params: {
|
||||
|
||||
var headerText = document.createElement("div");
|
||||
headerText.style.minWidth = "0";
|
||||
headerText.innerHTML = "<div style=\\"font-size:13px;font-weight:800;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
||||
String(chatName || "Chat") +
|
||||
"</div><div style=\\"font-size:11px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" +
|
||||
String(tagline || "") +
|
||||
"</div>";
|
||||
var titleLine = document.createElement("div");
|
||||
titleLine.style.fontSize = "13px";
|
||||
titleLine.style.fontWeight = "800";
|
||||
titleLine.style.color = "#111827";
|
||||
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);
|
||||
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 { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { storeSwitchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* POST /api/stores/switch
|
||||
@ -14,11 +15,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { storeId } = await request.json()
|
||||
|
||||
if (!storeId) {
|
||||
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
||||
const raw = await request.json().catch(() => ({}))
|
||||
const parsed = storeSwitchSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { storeId } = parsed.data
|
||||
|
||||
// Verify the store belongs to the user
|
||||
const store = await prisma.store.findFirst({
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { notifyInvitationAccepted } from "@/lib/notifications"
|
||||
import { supplierInvitationAcceptSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* POST /api/supplier/invitations/accept
|
||||
@ -15,14 +16,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { token } = await req.json()
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json(
|
||||
{ message: "Invitation token is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierInvitationAcceptSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { token } = parsed.data
|
||||
|
||||
// Get supplier profile
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
async function getSupplierContext(userId: string) {
|
||||
const profile = await prisma.supplierProfile.findUnique({
|
||||
@ -398,13 +399,16 @@ export async function PATCH(
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
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 trackingNumber = String(body.trackingNumber || "").trim() || null
|
||||
const trackingCompany = String(body.trackingCompany || "").trim() || null
|
||||
const trackingUrl = String(body.trackingUrl || "").trim() || null
|
||||
const fulfillmentStatus = normalizeFulfillmentStatus(
|
||||
fs === undefined || fs === null ? undefined : String(fs),
|
||||
)
|
||||
|
||||
const ctx = await getSupplierContext(session.user.id)
|
||||
if (!ctx) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierProductUpdateSchema } from "@/lib/api-schemas/business"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
/**
|
||||
* PUT /api/supplier/products/[productId]
|
||||
@ -18,7 +20,12 @@ export async function PUT(
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
const updatedData = {
|
||||
...(productSync.shopifyProductData || {}),
|
||||
title: title || productSync.shopifyProductTitle,
|
||||
body_html: description !== undefined ? description : (productSync.shopifyProductData?.body_html || ""),
|
||||
variants: productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
|
||||
...v,
|
||||
price: idx === 0 && price !== undefined ? price.toString() : v.price,
|
||||
sku: idx === 0 && sku !== undefined ? sku : v.sku
|
||||
})) || [],
|
||||
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image,
|
||||
title: title ?? productSync.shopifyProductTitle,
|
||||
body_html:
|
||||
description !== undefined
|
||||
? description
|
||||
: productSync.shopifyProductData?.body_html || "",
|
||||
variants:
|
||||
productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
|
||||
...v,
|
||||
price: idx === 0 && price !== undefined ? String(price) : v.price,
|
||||
sku: idx === 0 && sku !== undefined ? sku : v.sku,
|
||||
})) || [],
|
||||
image:
|
||||
imageUrl != null && String(imageUrl).trim() !== ""
|
||||
? { src: String(imageUrl).trim() }
|
||||
: productSync.shopifyProductData?.image,
|
||||
supplier_quantity:
|
||||
quantity !== undefined
|
||||
? quantity
|
||||
: productSync.shopifyProductData?.supplier_quantity
|
||||
: productSync.shopifyProductData?.supplier_quantity,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updatedSync = await (prisma as any).productSync.update({
|
||||
where: { id: syncId },
|
||||
data: {
|
||||
shopifyProductTitle: title || productSync.shopifyProductTitle,
|
||||
shopifyProductData: updatedData
|
||||
shopifyProductTitle: title ?? productSync.shopifyProductTitle,
|
||||
shopifyProductData: updatedData,
|
||||
}
|
||||
})
|
||||
|
||||
@ -107,9 +121,12 @@ export async function PUT(
|
||||
product: {
|
||||
id: productId,
|
||||
syncId,
|
||||
title: title || productSync.shopifyProductTitle,
|
||||
title: title ?? productSync.shopifyProductTitle,
|
||||
description,
|
||||
price: price !== undefined ? parseFloat(price) : parseFloat(updatedData.variants?.[0]?.price || "0"),
|
||||
price:
|
||||
price !== undefined
|
||||
? price
|
||||
: parseFloat(updatedData.variants?.[0]?.price || "0"),
|
||||
sku,
|
||||
imageUrl,
|
||||
availableQuantity: updatedData.supplier_quantity,
|
||||
@ -133,11 +150,11 @@ export async function PUT(
|
||||
const product = await prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
title: title || existingProduct.title,
|
||||
title: title ?? existingProduct.title,
|
||||
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,
|
||||
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 { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
import { supplierProductCreateSchema } from "@/lib/api-schemas/business"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/products
|
||||
@ -106,14 +108,12 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { title, description, price, sku, imageUrl } = await req.json()
|
||||
|
||||
if (!title || price === undefined) {
|
||||
return NextResponse.json(
|
||||
{ message: "Title and price are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierProductCreateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { title, description, price, sku, imageUrl } = parsed.data
|
||||
|
||||
// Get supplier profile
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
@ -128,10 +128,10 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
supplierId: supplierProfile.id,
|
||||
title,
|
||||
description: description || null,
|
||||
price: parseFloat(price),
|
||||
sku: sku || null,
|
||||
imageUrl: imageUrl || null
|
||||
description: description?.trim() ? description : null,
|
||||
price,
|
||||
sku: sku?.trim() ? sku : null,
|
||||
imageUrl: imageUrl?.trim() ? imageUrl : null,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierProfileUpsertSchema } from "@/lib/api-schemas/business"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/profile
|
||||
@ -58,23 +60,24 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { companyName, description } = await req.json()
|
||||
|
||||
if (!companyName) {
|
||||
return NextResponse.json({ message: "Company name is required" }, { status: 400 })
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierProfileUpsertSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { companyName, description } = parsed.data
|
||||
|
||||
// Upsert supplier profile
|
||||
const supplierProfile = await prisma.supplierProfile.upsert({
|
||||
where: { userId: session.user.id },
|
||||
update: {
|
||||
companyName,
|
||||
description: description || null
|
||||
description: description?.trim() ? description : null,
|
||||
},
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
companyName,
|
||||
description: description || null
|
||||
description: description?.trim() ? description : null,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createNotification } from "@/lib/notifications"
|
||||
import { supplierOfferCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/vendors/[connectionId]/offers
|
||||
@ -97,26 +98,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
const { connectionId } = await params
|
||||
const body = await req.json()
|
||||
const { productId, price, quantity } = body as {
|
||||
productId?: string
|
||||
price?: number
|
||||
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 raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierOfferCreateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { productId, price, quantity } = parsed.data
|
||||
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierConnectionStatusSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/vendors/[connectionId]
|
||||
@ -81,14 +82,12 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const { connectionId } = await params
|
||||
const { status } = await req.json()
|
||||
|
||||
if (!status || !["CONNECTED", "REJECTED"].includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Valid status (CONNECTED or REJECTED) is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierConnectionStatusSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { status } = parsed.data
|
||||
|
||||
// Get supplier profile
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createNotification } from "@/lib/notifications"
|
||||
import { supplierOfferUpdateSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* PUT /api/supplier/vendors/offers/[offerId]
|
||||
@ -19,32 +20,12 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const { offerId } = await params
|
||||
const body = await req.json()
|
||||
const { price, quantity } = body as {
|
||||
price?: number
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
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 raw = await req.json().catch(() => ({}))
|
||||
const parsed = supplierOfferUpdateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { price, quantity } = parsed.data
|
||||
|
||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
|
||||
@ -6,6 +6,7 @@ import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
|
||||
import { notifyInvitationReceived } from "@/lib/notifications"
|
||||
import { getBaseUrl } from "@/lib/utils"
|
||||
import crypto from "crypto"
|
||||
import { vendorInviteDeleteSchema, vendorInvitePostSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||
|
||||
/**
|
||||
* DELETE /api/vendors/suppliers/[supplierId]/invite
|
||||
@ -23,8 +24,12 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const { supplierId } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const storeId = body.storeId // Optional: specific store to cancel, otherwise cancel all
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = vendorInviteDeleteSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const storeId = parsed.data.storeId ?? undefined
|
||||
|
||||
// Get vendor's stores
|
||||
const vendorStores = await prisma.store.findMany({
|
||||
@ -101,8 +106,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
const { supplierId } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const connectToAllStores = body.connectToAllStores === true
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = vendorInvitePostSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const connectToAllStores = parsed.data.connectToAllStores === true
|
||||
|
||||
// Get vendor's stores
|
||||
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)",
|
||||
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
|
||||
type="monotone"
|
||||
|
||||
@ -1,9 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "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 { 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() {
|
||||
const { data: session, update } = useSession()
|
||||
@ -19,26 +41,100 @@ export default function AccountSettingsPage() {
|
||||
confirmPassword: ""
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch user's business name (from first store or user name)
|
||||
const fetchBusinessName = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/stores")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive)
|
||||
if (activeStore?.businessName) {
|
||||
setFormData(prev => ({ ...prev, businessName: activeStore.businessName }))
|
||||
} else if (session?.user?.name) {
|
||||
setFormData(prev => ({ ...prev, businessName: session.user.name || "" }))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
const [activeStoreMeta, setActiveStoreMeta] = useState<ActiveStoreMeta | null>(null)
|
||||
const [shopifyTokenOpen, setShopifyTokenOpen] = useState(false)
|
||||
const [newShopifyToken, setNewShopifyToken] = useState("")
|
||||
const [tokenLoading, setTokenLoading] = useState(false)
|
||||
const [tokenSuccess, setTokenSuccess] = useState("")
|
||||
const [tokenError, setTokenError] = useState("")
|
||||
|
||||
const refreshActiveStore = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/stores")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) as
|
||||
| {
|
||||
id?: string
|
||||
businessName?: string
|
||||
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 {
|
||||
// Silent fail
|
||||
}
|
||||
fetchBusinessName()
|
||||
}, [session])
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@ -59,7 +155,7 @@ export default function AccountSettingsPage() {
|
||||
setLoading(false)
|
||||
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")
|
||||
setLoading(false)
|
||||
return
|
||||
@ -67,20 +163,27 @@ export default function AccountSettingsPage() {
|
||||
}
|
||||
|
||||
const updateData: { businessName?: string; currentPassword?: string; newPassword?: string } = {}
|
||||
|
||||
|
||||
if (formData.businessName) {
|
||||
updateData.businessName = formData.businessName
|
||||
}
|
||||
|
||||
|
||||
if (formData.newPassword) {
|
||||
updateData.currentPassword = formData.currentPassword
|
||||
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", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updateData)
|
||||
body: JSON.stringify(bodyCheck.data),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@ -149,6 +252,7 @@ export default function AccountSettingsPage() {
|
||||
value={formData.businessName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -167,6 +271,7 @@ export default function AccountSettingsPage() {
|
||||
value={formData.currentPassword}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -177,6 +282,7 @@ export default function AccountSettingsPage() {
|
||||
value={formData.newPassword}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -187,6 +293,7 @@ export default function AccountSettingsPage() {
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -214,6 +321,96 @@ export default function AccountSettingsPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<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 { useRouter, useSearchParams } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import { authRegisterSchema } from "@/lib/validation"
|
||||
|
||||
function SignupFormContent() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -43,6 +44,13 @@ function SignupFormContent() {
|
||||
const email = formData.get("email") 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 {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
@ -151,6 +159,7 @@ function SignupFormContent() {
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={100}
|
||||
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"
|
||||
/>
|
||||
@ -167,6 +176,7 @@ function SignupFormContent() {
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
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"
|
||||
/>
|
||||
@ -183,6 +193,7 @@ function SignupFormContent() {
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
maxLength={128}
|
||||
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"
|
||||
/>
|
||||
|
||||
@ -257,7 +257,11 @@ export default function SupplierDashboardPage() {
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
|
||||
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]}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
|
||||
@ -364,6 +364,7 @@ export default function SupplierProductsPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={500}
|
||||
value={formData.title}
|
||||
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"
|
||||
@ -377,6 +378,7 @@ export default function SupplierProductsPage() {
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
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"
|
||||
placeholder="Product description"
|
||||
/>
|
||||
@ -403,6 +405,7 @@ export default function SupplierProductsPage() {
|
||||
type="text"
|
||||
value={formData.sku}
|
||||
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"
|
||||
placeholder="SKU"
|
||||
/>
|
||||
@ -428,6 +431,7 @@ export default function SupplierProductsPage() {
|
||||
type="url"
|
||||
value={formData.imageUrl}
|
||||
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"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"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 { useSession } from "next-auth/react"
|
||||
import { useTheme } from "@/components/ThemeProvider"
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import { format } from "date-fns"
|
||||
import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
|
||||
import { CHAT_MESSAGE_CONTENT_MAX } from "@/lib/api-schemas/public"
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
@ -43,27 +44,6 @@ interface Message {
|
||||
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 = {
|
||||
id: number
|
||||
name: string
|
||||
@ -287,8 +267,7 @@ export function ChatView() {
|
||||
setAttachOpen(false)
|
||||
}
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const sendMessage = async () => {
|
||||
if (!selectedContact) return
|
||||
const hasText = !!newMessage.trim()
|
||||
const hasAttachment = !!draftKind && !!draftAttachment
|
||||
@ -297,10 +276,10 @@ export function ChatView() {
|
||||
try {
|
||||
setSending(true)
|
||||
setSendError(null)
|
||||
const payload: any = {
|
||||
const payload: Record<string, unknown> = {
|
||||
receiverId: selectedContact.id,
|
||||
storeId: selectedContact.storeId,
|
||||
content: newMessage
|
||||
content: newMessage,
|
||||
}
|
||||
if (hasAttachment) {
|
||||
payload.kind = draftKind
|
||||
@ -309,12 +288,12 @@ export function ChatView() {
|
||||
const res = await fetch("/api/chat/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const msg = await res.json()
|
||||
const optimiticMsg: Message = {
|
||||
const optimisticMsg: Message = {
|
||||
id: msg.id,
|
||||
senderId: msg.senderId,
|
||||
receiverId: msg.receiverId,
|
||||
@ -322,14 +301,19 @@ export function ChatView() {
|
||||
content: newMessage,
|
||||
kind: msg.kind ?? "TEXT",
|
||||
attachment: msg.attachment ?? null,
|
||||
createdAt: msg.createdAt
|
||||
createdAt: msg.createdAt,
|
||||
}
|
||||
setMessages(prev => [...prev, optimiticMsg])
|
||||
setMessages((prev) => [...prev, optimisticMsg])
|
||||
setNewMessage("")
|
||||
setDraftKind(null)
|
||||
setDraftAttachment(null)
|
||||
} 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) {
|
||||
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.
|
||||
// Example legacy content:
|
||||
// - [Product: Title · Store (#123)]
|
||||
@ -461,7 +450,9 @@ export function ChatView() {
|
||||
type="text"
|
||||
placeholder="Find connections..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
"w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all font-medium",
|
||||
isLight ? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400" : "bg-white/5 border border-white/10 text-white placeholder:text-slate-600"
|
||||
@ -916,11 +907,16 @@ export function ChatView() {
|
||||
rows={1}
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
maxLength={CHAT_MESSAGE_CONTENT_MAX}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setNewMessage(
|
||||
e.target.value.slice(0, CHAT_MESSAGE_CONTENT_MAX),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage(e)
|
||||
void sendMessage()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
KNOWLEDGE_TEMPLATE_FIELDS,
|
||||
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
|
||||
KNOWLEDGE_TEMPLATE_MISC_MAX,
|
||||
type KnowledgeTemplateFieldKey,
|
||||
type KnowledgeTemplateStored,
|
||||
} from "@/lib/kb/knowledgeTemplate"
|
||||
@ -286,11 +288,15 @@ export function StructuredKnowledgeModal({
|
||||
onChange={(e) =>
|
||||
setAnswers((a) => ({
|
||||
...a,
|
||||
[field.key]: e.target.value,
|
||||
[field.key]: e.target.value.slice(
|
||||
0,
|
||||
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
|
||||
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",
|
||||
isLight
|
||||
@ -320,9 +326,14 @@ export function StructuredKnowledgeModal({
|
||||
</p>
|
||||
<textarea
|
||||
value={misc}
|
||||
onChange={(e) => setMisc(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setMisc(
|
||||
e.target.value.slice(0, KNOWLEDGE_TEMPLATE_MISC_MAX),
|
||||
)
|
||||
}
|
||||
placeholder="Optional…"
|
||||
rows={3}
|
||||
maxLength={KNOWLEDGE_TEMPLATE_MISC_MAX}
|
||||
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",
|
||||
isLight
|
||||
|
||||
@ -115,6 +115,7 @@ export function LandingFooter() {
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
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"
|
||||
/>
|
||||
|
||||
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 { prisma } from "./prisma"
|
||||
import { fetchProductMetafieldsMap } from "@/lib/shopify/productMetafieldsGraphql"
|
||||
|
||||
/** Minimal Shopify API shapes for cache updates */
|
||||
interface ShopifyProductRecord {
|
||||
@ -130,6 +131,28 @@ export async function getCachedCustomers(storeId: string) {
|
||||
*/
|
||||
export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) {
|
||||
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
|
||||
await prisma.cachedProduct.deleteMany({
|
||||
where: { storeId }
|
||||
@ -144,7 +167,9 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
|
||||
productType: product.product_type || null,
|
||||
status: product.status || null,
|
||||
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({
|
||||
@ -398,6 +423,28 @@ export async function updateCustomerCache(storeId: string, customers: ShopifyCus
|
||||
* Update a single product in cache (for webhook updates)
|
||||
*/
|
||||
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({
|
||||
where: {
|
||||
shopifyProductId_storeId: {
|
||||
@ -414,6 +461,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
|
||||
status: product.status || null,
|
||||
variants: (product.variants || []) as Prisma.InputJsonValue,
|
||||
images: (product.images || []) as Prisma.InputJsonValue,
|
||||
metafields: (metafieldsPatch ?? []) as Prisma.InputJsonValue,
|
||||
lastSyncedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
@ -423,6 +471,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
|
||||
status: product.status || null,
|
||||
variants: (product.variants || []) as Prisma.InputJsonValue,
|
||||
images: (product.images || []) as Prisma.InputJsonValue,
|
||||
...(metafieldsPatch !== undefined ? { metafields: metafieldsPatch } : {}),
|
||||
lastSyncedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
@ -68,16 +68,18 @@ export const KNOWLEDGE_TEMPLATE_FIELDS: {
|
||||
},
|
||||
]
|
||||
|
||||
const answerMax = 12_000
|
||||
const miscMax = 16_000
|
||||
/** Per-field answer cap (mirrored on structured KB form). */
|
||||
export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000
|
||||
/** Misc notes cap. */
|
||||
export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000
|
||||
|
||||
export const knowledgeTemplatePutSchema = z.object({
|
||||
knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
|
||||
confirmDestructiveSwitch: z.boolean().optional(),
|
||||
template: z
|
||||
.object({
|
||||
answers: z.record(z.string().max(answerMax)).optional(),
|
||||
misc: z.string().max(miscMax).optional(),
|
||||
answers: z.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)).optional(),
|
||||
misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@ -2,14 +2,23 @@ type RateLimitResult = {
|
||||
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.
|
||||
// This avoids external dependencies while still providing basic protection.
|
||||
const WINDOW_MS = 60_000 // 1 minute
|
||||
const MAX_REQUESTS = 10
|
||||
const DEFAULT_WINDOW_MS = 60_000 // 1 minute
|
||||
const DEFAULT_MAX_REQUESTS = 10
|
||||
|
||||
type Entry = {
|
||||
count: number
|
||||
windowStart: number
|
||||
windowMs: number
|
||||
maxRequests: number
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Entry>()
|
||||
@ -23,19 +32,33 @@ function getClientIp(req: Request): string {
|
||||
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 key = `${keyPrefix}:${ip}`
|
||||
const key = `${keyPrefix}:w${windowMs}:m${maxRequests}:${ip}`
|
||||
const now = Date.now()
|
||||
|
||||
const existing = buckets.get(key)
|
||||
|
||||
if (!existing || now - existing.windowStart > WINDOW_MS) {
|
||||
buckets.set(key, { count: 1, windowStart: now })
|
||||
if (!existing || now - existing.windowStart > existing.windowMs) {
|
||||
buckets.set(key, {
|
||||
count: 1,
|
||||
windowStart: now,
|
||||
windowMs,
|
||||
maxRequests,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (existing.count >= MAX_REQUESTS) {
|
||||
if (existing.count >= existing.maxRequests) {
|
||||
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)}...`
|
||||
}
|
||||
|
||||
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) {
|
||||
const variants = Array.isArray(product?.variants) ? product.variants : []
|
||||
const firstVariant = variants[0] || {}
|
||||
const price = firstVariant?.price ?? 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 status = product?.status || "unknown"
|
||||
const mfLine = metafieldsForText(product?.metafields)
|
||||
|
||||
const bits = [
|
||||
`Title: ${product?.title || "Unknown"}`,
|
||||
@ -45,7 +65,9 @@ function productPreview(product: any) {
|
||||
`Status: ${status}`,
|
||||
price !== null && price !== undefined ? `Price: ${String(price)}` : "",
|
||||
sku ? `SKU: ${String(sku)}` : "",
|
||||
weight ? `Weight: ${weight}` : "",
|
||||
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
|
||||
mfLine ? `Details: ${mfLine}` : "",
|
||||
].filter(Boolean)
|
||||
|
||||
return bits.join(" | ")
|
||||
@ -99,12 +121,13 @@ export async function getBlendedStorefrontContext(params: {
|
||||
status: true,
|
||||
variants: true,
|
||||
images: true,
|
||||
metafields: true,
|
||||
},
|
||||
})
|
||||
|
||||
const rankedProducts: RetrievedContextItem[] = (products || [])
|
||||
.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)
|
||||
return {
|
||||
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/*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user