fix: so many fixed and others

This commit is contained in:
talatops 2026-04-11 21:13:20 +05:00
parent bbeb43adb1
commit a86c805cda
72 changed files with 10790 additions and 446 deletions

37
.dockerignore Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View 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.

View 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`.

View File

@ -1,3 +1,4 @@
// API / form validation governance: see docs/qa/pr-validation-checklist.md
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";

127
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"name": "pdc-poc", "name": "pdc-poc",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@auth/core": "^0.41.0", "@auth/core": "^0.41.0",
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
@ -46,8 +47,9 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "20.19.39",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@ -1464,6 +1466,7 @@
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.1.0", "@emnapi/wasi-threads": "1.1.0",
@ -1483,6 +1486,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
@ -2757,6 +2761,7 @@
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.4.3", "@emnapi/core": "^1.4.3",
@ -3011,6 +3016,21 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.6.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz",
@ -4614,6 +4634,60 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -4660,6 +4734,7 @@
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
@ -4748,9 +4823,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.33", "version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -10064,6 +10139,50 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"devOptional": true,
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"devOptional": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",

View File

@ -3,12 +3,14 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "prisma generate",
"dev": "next dev", "dev": "next dev",
"prebuild": "npx prisma generate", "prebuild": "npx prisma generate",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test",
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v", "test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts", "re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
@ -55,8 +57,9 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "20.19.39",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

17
playwright.config.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -155,6 +155,8 @@ model CachedProduct {
status String? status String?
variants Json // Store variants as JSON variants Json // Store variants as JSON
images Json // Store images as JSON images Json // Store images as JSON
/** Shopify product metafields (incl. category / taxonomy fields from admin) for automation & search. */
metafields Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lastSyncedAt DateTime @default(now()) lastSyncedAt DateTime @default(now())

View 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."

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { applyRateLimit } from "@/lib/rate-limit" import { applyRateLimit } from "@/lib/rate-limit"
import { handleApiError } from "@/lib/errors" import { handleApiError } from "@/lib/errors"
import { authVerifyEmailBodySchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -13,15 +14,12 @@ export async function POST(req: Request) {
) )
} }
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { token, email } = body const parsed = authVerifyEmailBodySchema.safeParse(raw)
if (!parsed.success) {
if (!token || !email) { return validationErrorResponse(parsed.error)
return NextResponse.json(
{ message: "Token and email are required" },
{ status: 400 }
)
} }
const { token, email } = parsed.data
// Verify token // Verify token
const verificationToken = await prisma.verificationToken.findUnique({ const verificationToken = await prisma.verificationToken.findUnique({

View File

@ -3,10 +3,16 @@ import { prisma } from "@/lib/prisma"
import { verify } from "otplib" import { verify } from "otplib"
import { decryptString } from "@/lib/encryption" import { decryptString } from "@/lib/encryption"
import { applyRateLimit } from "@/lib/rate-limit" import { applyRateLimit } from "@/lib/rate-limit"
import { authVerifyMfaSchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { email, code } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = authVerifyMfaSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { email, code } = parsed.data
// Rate limit MFA verification attempts per IP // Rate limit MFA verification attempts per IP
const rate = await applyRateLimit(req, "auth:mfa-verify") const rate = await applyRateLimit(req, "auth:mfa-verify")
@ -17,13 +23,6 @@ export async function POST(req: Request) {
) )
} }
if (!email || !code) {
return NextResponse.json(
{ message: "Email and verification code are required" },
{ status: 400 }
)
}
// Use raw query to access MFA fields until Prisma client is fully regenerated // Use raw query to access MFA fields until Prisma client is fully regenerated
const userResult = await prisma.$queryRaw< const userResult = await prisma.$queryRaw<
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }> Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>

View 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)
})
})

View File

@ -7,6 +7,9 @@ import {
hasChatConnectionForStore, hasChatConnectionForStore,
resolveB2bChatPair, resolveB2bChatPair,
} from "@/lib/chat/chatThreadAccess" } from "@/lib/chat/chatThreadAccess"
import { applyRateLimit } from "@/lib/rate-limit"
import { chatMessagePostSchema } from "@/lib/api-schemas/public"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -15,17 +18,26 @@ export async function POST(req: Request) {
return new NextResponse("Unauthorized", { status: 401 }) return new NextResponse("Unauthorized", { status: 401 })
} }
const body = await req.json() const rate = await applyRateLimit(req, "chat:messages", {
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = body windowMs: 60_000,
maxRequests: 120,
if (!receiverId || typeof receiverId !== "string") { })
return new NextResponse("Missing fields", { status: 400 }) if (!rate.success) {
return NextResponse.json(
{ message: "Too many requests. Please try again later." },
{ status: 429 },
)
} }
if (!storeIdRaw || typeof storeIdRaw !== "string") { const raw = await req.json().catch(() => ({}))
return new NextResponse("Missing storeId", { status: 400 }) const parsed = chatMessagePostSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
} }
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = parsed.data
const normalizedKind = kind ?? "TEXT"
const pair = await resolveB2bChatPair(session.user.id, receiverId) const pair = await resolveB2bChatPair(session.user.id, receiverId)
if (!pair) { if (!pair) {
return new NextResponse("Invalid chat participant", { status: 403 }) return new NextResponse("Invalid chat participant", { status: 403 })
@ -40,20 +52,6 @@ export async function POST(req: Request) {
return new NextResponse("Store not connected for this chat", { status: 403 }) return new NextResponse("Store not connected for this chat", { status: 403 })
} }
const CHAT_KINDS = ["TEXT", "PRODUCT", "ORDER"] as const
const normalizedKind =
typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT"
if (normalizedKind === "TEXT") {
if (content != null && typeof content !== "string") {
return new NextResponse("Invalid content", { status: 400 })
}
} else {
if (!attachment || typeof attachment !== "object") {
return new NextResponse("Missing attachment for typed message", { status: 400 })
}
}
const sessionUser = await prisma.user.findUnique({ const sessionUser = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { role: true }, select: { role: true },
@ -75,7 +73,7 @@ export async function POST(req: Request) {
receiverId, receiverId,
storeId: storeIdRaw, storeId: storeIdRaw,
kind: normalizedKind as any, kind: normalizedKind as any,
content: (typeof content === "string" ? content : "") as any, content: content as any,
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any, attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
} as any, } as any,
}) })

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles" import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { flaggedEmailCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
async function getActiveStoreId(userId: string): Promise<string | null> { async function getActiveStoreId(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -72,25 +73,14 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "No active store found" }, { status: 400 }) return NextResponse.json({ message: "No active store found" }, { status: 400 })
} }
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { subject, from, reason, priority, messageId } = body as { const parsed = flaggedEmailCreateSchema.safeParse(raw)
subject?: string if (!parsed.success) {
from?: string return validationErrorResponse(parsed.error)
reason?: string
priority?: "LOW" | "MEDIUM" | "HIGH"
messageId?: string
} }
const { subject, from, reason, priority, messageId } = parsed.data
if (!subject || !from || !reason) { const priorityVal = priority ?? "MEDIUM"
return NextResponse.json(
{ message: "subject, from, and reason are required" },
{ status: 400 }
)
}
const priorityVal = priority && ["LOW", "MEDIUM", "HIGH"].includes(priority)
? priority
: "MEDIUM"
const created = await prisma.flaggedEmail.create({ const created = await prisma.flaggedEmail.create({
data: { data: {

View File

@ -3,6 +3,8 @@ import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles" import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { junoTemplatePatchSchema } from "@/lib/api-schemas/vendorMutations"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
type TemplateRow = { type TemplateRow = {
id: string id: string
@ -130,11 +132,11 @@ export async function PUT(
return NextResponse.json({ message: "Template not found" }, { status: 404 }) return NextResponse.json({ message: "Template not found" }, { status: 404 })
} }
const body = await req.json().catch(() => null) const raw = await req.json().catch(() => ({}))
if (!body) { const parsed = junoTemplatePatchSchema.safeParse(raw)
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 }) if (!parsed.success) {
return validationErrorResponse(parsed.error)
} }
const { const {
name, name,
intent, intent,
@ -143,15 +145,7 @@ export async function PUT(
body: templateBody, body: templateBody,
variables, variables,
isHtml, isHtml,
} = body as { } = parsed.data
name?: string
intent?: string
mood?: string
description?: string | null
body?: string
variables?: unknown
isHtml?: boolean
}
const rows = await prisma.$queryRaw<TemplateRow[]>` const rows = await prisma.$queryRaw<TemplateRow[]>`
UPDATE "Template" UPDATE "Template"

View File

@ -4,6 +4,8 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { junoTemplateCreateSchema } from "@/lib/api-schemas/vendorMutations"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
type TemplateRow = { type TemplateRow = {
id: string id: string
@ -122,28 +124,13 @@ export async function POST(req: Request) {
) )
} }
const body = await req.json().catch(() => null) const raw = await req.json().catch(() => ({}))
if (!body) { const parsed = junoTemplateCreateSchema.safeParse(raw)
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 }) if (!parsed.success) {
return validationErrorResponse(parsed.error)
} }
const { name, intent, mood, description, body: templateBody, variables, isHtml } = const { name, intent, mood, description, body: templateBody, variables, isHtml } =
body as { parsed.data
name?: string
intent?: string
mood?: string
description?: string | null
body?: string
variables?: unknown
isHtml?: boolean
}
if (!name || !intent || !mood || !templateBody) {
return NextResponse.json(
{ message: "Missing required fields (name, intent, mood, body)" },
{ status: 400 },
)
}
const id = randomUUID() const id = randomUUID()

View File

@ -1,26 +1,39 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { sendEmail, getEmailLogoUrl } from "@/lib/email" import { sendEmail, getEmailLogoUrl } from "@/lib/email"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { applyRateLimit } from "@/lib/rate-limit"
import { newsletterSubscribeSchema } from "@/lib/api-schemas/public"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
import { escapeHtmlText } from "@/lib/sanitize/escapeHtml"
// This is a simple mock-up of a newsletter subscription API // This is a simple mock-up of a newsletter subscription API
// In a real app, you would save the email to a database (e.g., Prisma) // In a real app, you would save the email to a database (e.g., Prisma)
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { email } = await req.json() const rate = await applyRateLimit(req, "public:newsletter", {
windowMs: 60_000,
if (!email || !email.includes("@")) { maxRequests: 20,
})
if (!rate.success) {
return NextResponse.json( return NextResponse.json(
{ message: "Invalid email address" }, { message: "Too many requests. Please try again later." },
{ status: 400 } { status: 429 },
) )
} }
const raw = await req.json().catch(() => ({}))
const parsed = newsletterSubscribeSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { email } = parsed.data
// 1. In a production app, you would check if the email already exists // 1. In a production app, you would check if the email already exists
// and then save it to your 'Newsletter' table. // and then save it to your 'Newsletter' table.
// For now, we'll proceed to sending the welcome email. // For now, we'll proceed to sending the welcome email.
const subject = "Welcome to the JUNO Newsletter! ⚡" const subject = "Welcome to the JUNO Newsletter! ⚡"
const emailHtml = generateNewsletterWelcomeHtml(email) const emailHtml = generateNewsletterWelcomeHtml(escapeHtmlText(email))
const result = await sendEmail({ const result = await sendEmail({
to: email, to: email,

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { notificationsMarkReadSchema } from "@/lib/api-schemas/vendorMutations"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* GET /api/notifications * GET /api/notifications
@ -61,8 +63,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { notificationIds, markAllAsRead } = body const parsed = notificationsMarkReadSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { notificationIds, markAllAsRead } = parsed.data
if (markAllAsRead) { if (markAllAsRead) {
// Mark all notifications as read for this user // Mark all notifications as read for this user
@ -80,26 +86,22 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "All notifications marked as read" }) return NextResponse.json({ message: "All notifications marked as read" })
} }
if (notificationIds && Array.isArray(notificationIds) && notificationIds.length > 0) { if (notificationIds && notificationIds.length > 0) {
// Mark specific notifications as read
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { where: {
id: { in: notificationIds }, id: { in: notificationIds },
userId: session.user.id userId: session.user.id,
}, },
data: { data: {
read: true, read: true,
readAt: new Date() readAt: new Date(),
} },
}) })
return NextResponse.json({ message: "Notifications marked as read" }) return NextResponse.json({ message: "Notifications marked as read" })
} }
return NextResponse.json( return NextResponse.json({ message: "Invalid request" }, { status: 400 })
{ message: "Invalid request. Provide notificationIds array or markAllAsRead: true" },
{ status: 400 }
)
} catch (error) { } catch (error) {
console.error("Error updating notifications:", error) console.error("Error updating notifications:", error)
return NextResponse.json( return NextResponse.json(

View File

@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { handleApiError } from '@/lib/errors' import { handleApiError } from '@/lib/errors'
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from '@/lib/api-schemas/notificationsPush'
import { validationErrorResponse } from '@/lib/api-schemas/validate'
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -10,11 +12,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
} }
const subscription = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = webPushSubscribeSchema.safeParse(raw)
if (!subscription || !subscription.endpoint || !subscription.keys) { if (!parsed.success) {
return NextResponse.json({ message: 'Invalid subscription data' }, { status: 400 }) return validationErrorResponse(parsed.error)
} }
const subscription = parsed.data
// Upsert the subscription // Upsert the subscription
await prisma.pushSubscription.upsert({ await prisma.pushSubscription.upsert({
@ -49,11 +52,12 @@ export async function DELETE(req: Request) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
} }
const { endpoint } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = webPushUnsubscribeSchema.safeParse(raw)
if (!endpoint) { if (!parsed.success) {
return NextResponse.json({ message: 'Endpoint is required' }, { status: 400 }) return validationErrorResponse(parsed.error)
} }
const { endpoint } = parsed.data
await prisma.pushSubscription.delete({ await prisma.pushSubscription.delete({
where: { endpoint } where: { endpoint }

View File

@ -3,6 +3,7 @@ import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles" import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { onboardingCompleteSchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@ -17,7 +18,11 @@ export async function POST(req: NextRequest) {
) )
} }
const body = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = onboardingCompleteSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { const {
businessName, businessName,
businessEmail, businessEmail,
@ -27,13 +32,8 @@ export async function POST(req: NextRequest) {
domain, domain,
apiKey, apiKey,
apiSecret, apiSecret,
accessToken accessToken,
} = body } = parsed.data
// Validate required fields
if (!businessName || !domain || !accessToken) {
return NextResponse.json({ message: "Missing required onboarding data." }, { status: 400 })
}
// Clean domain: remove protocol, trailing slashes, and trim whitespace // Clean domain: remove protocol, trailing slashes, and trim whitespace
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()

View File

@ -3,6 +3,7 @@ import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { onboardingShopifySchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -17,19 +18,12 @@ export async function POST(req: Request) {
) )
} }
const { const raw = await req.json().catch(() => ({}))
businessName, const parsed = onboardingShopifySchema.safeParse(raw)
storeName, if (!parsed.success) {
domain, return validationErrorResponse(parsed.error)
apiKey,
apiSecret,
accessToken
} = await req.json()
// Basic validation
if (!businessName || !storeName || !domain || !apiKey || !apiSecret || !accessToken) {
return NextResponse.json({ message: "Missing required fields" }, { status: 400 })
} }
const { businessName, storeName, domain, apiKey, apiSecret, accessToken } = parsed.data
// Multi-store: create a new store; set as active if first store // Multi-store: create a new store; set as active if first store
const existingCount = await prisma.store.count({ where: { userId: session.user.id } }) const existingCount = await prisma.store.count({ where: { userId: session.user.id } })

View File

@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum" import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
import { productSyncDecisionSchema } from "@/lib/api-schemas/business"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* PUT /api/products/sync/[syncId] * PUT /api/products/sync/[syncId]
@ -19,15 +21,12 @@ export async function PUT(
} }
const { syncId } = await params const { syncId } = await params
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { action } = body // "accept" or "reject" const parsed = productSyncDecisionSchema.safeParse(raw)
if (!parsed.success) {
if (!action || !["accept", "reject"].includes(action)) { return validationErrorResponse(parsed.error)
return NextResponse.json(
{ message: "Invalid action. Use 'accept' or 'reject'" },
{ status: 400 }
)
} }
const { action } = parsed.data
// Get supplier profile // Get supplier profile
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({

View File

@ -3,6 +3,8 @@ import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles" import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
import { productSyncRequestSchema } from "@/lib/api-schemas/business"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* GET /api/products/sync * GET /api/products/sync
@ -82,15 +84,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = body const parsed = productSyncRequestSchema.safeParse(raw)
if (!parsed.success) {
if (!shopifyProductId || !supplierId || !shopifyProductTitle) { return validationErrorResponse(parsed.error)
return NextResponse.json(
{ message: "Product ID, title, and supplier ID are required" },
{ status: 400 }
)
} }
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = parsed.data
// Get vendor's active store // Get vendor's active store
const vendorStore = await prisma.store.findFirst({ const vendorStore = await prisma.store.findFirst({
@ -150,7 +149,7 @@ export async function POST(req: Request) {
storeId_supplierId_shopifyProductId: { storeId_supplierId_shopifyProductId: {
storeId: vendorStore.id, storeId: vendorStore.id,
supplierId: supplier.id, supplierId: supplier.id,
shopifyProductId: shopifyProductId.toString() shopifyProductId
} }
} }
}) })
@ -211,7 +210,7 @@ export async function POST(req: Request) {
data: { data: {
storeId: vendorStore.id, storeId: vendorStore.id,
supplierId: supplier.id, supplierId: supplier.id,
shopifyProductId: shopifyProductId.toString(), shopifyProductId,
shopifyProductTitle, shopifyProductTitle,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined, shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined,

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { decryptString } from "@/lib/encryption" import { decryptString } from "@/lib/encryption"
import { mfaToggleSchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -10,7 +11,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { enabled } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = mfaToggleSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { enabled } = parsed.data
// Use raw query to check mfaSecret until Prisma client is fully regenerated // Use raw query to check mfaSecret until Prisma client is fully regenerated
const result = await prisma.$queryRaw< const result = await prisma.$queryRaw<

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
import { verify } from "otplib" import { verify } from "otplib"
import { decryptString } from "@/lib/encryption" import { decryptString } from "@/lib/encryption"
import { applyRateLimit } from "@/lib/rate-limit" import { applyRateLimit } from "@/lib/rate-limit"
import { mfaSetupVerifySchema, validationErrorResponse } from "@/lib/api-schemas"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -12,7 +13,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { code } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = mfaSetupVerifySchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { code } = parsed.data
// Rate limit MFA setup verification attempts per IP // Rate limit MFA setup verification attempts per IP
const rate = await applyRateLimit(req, "settings:mfa-verify") const rate = await applyRateLimit(req, "settings:mfa-verify")
@ -23,13 +29,6 @@ export async function POST(req: Request) {
) )
} }
if (!code || typeof code !== "string") {
return NextResponse.json(
{ message: "Verification code is required" },
{ status: 400 }
)
}
// Use raw query to access mfaSecret until Prisma client is fully regenerated // Use raw query to access mfaSecret until Prisma client is fully regenerated
const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}` const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`

View File

@ -42,7 +42,8 @@ export async function GET() {
status: product.status, status: product.status,
variants: product.variants, variants: product.variants,
images: images, images: images,
image: images.length > 0 ? { src: images[0].src } : null image: images.length > 0 ? { src: images[0].src } : null,
metafields: product.metafields ?? null,
} }
}) })
return NextResponse.json({ return NextResponse.json({

View File

@ -1,4 +1,6 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { shopifyValidateSchema, validationErrorResponse } from "@/lib/api-schemas"
import { cleanShopifyDomain } from "@/lib/shopify/adminPing"
const TRANSIENT_ERROR_CODES = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"] const TRANSIENT_ERROR_CODES = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]
const MAX_RETRIES = 3 const MAX_RETRIES = 3
@ -16,15 +18,14 @@ function sleep(ms: number): Promise<void> {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { domain, accessToken } = body const parsed = shopifyValidateSchema.safeParse(raw)
if (!parsed.success) {
if (!domain || !accessToken) { return validationErrorResponse(parsed.error)
return NextResponse.json({ message: "Store domain and access token are required." }, { status: 400 })
} }
const { domain, accessToken } = parsed.data
// Clean domain: remove protocol, trailing slashes, and trim whitespace const cleanDomain = cleanShopifyDomain(domain)
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
let lastError: unknown = null let lastError: unknown = null

View File

@ -1,13 +1,9 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq" import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
import { applyRateLimit } from "@/lib/rate-limit"
type StorefrontMessagePayload = { import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
shop: string import { validationErrorResponse } from "@/lib/api-schemas/validate"
visitorId: string
content: string
customerEmail?: string | null
}
function normalizeShopDomain(input: string) { function normalizeShopDomain(input: string) {
return input return input
@ -29,35 +25,27 @@ function shopVariants(cleanDomain: string) {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const rate = await applyRateLimit(req, "public:storefront-chat", {
windowMs: 60_000,
maxRequests: 40,
})
if (!rate.success) {
return NextResponse.json(
{ message: "Too many requests. Please try again later." },
{ status: 429 },
)
}
const p = prisma as any const p = prisma as any
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload> const raw = await req.json().catch(() => ({}))
const { shop, visitorId, content } = payload const parsed = storefrontChatPostSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { shop, visitorId, content: trimmedContent, customerEmail } = parsed.data
if (typeof shop !== "string" || !shop.trim()) {
return NextResponse.json({ message: "Missing shop" }, { status: 400 })
}
if (typeof visitorId !== "string" || !visitorId.trim()) {
return NextResponse.json({ message: "Missing visitorId" }, { status: 400 })
}
if (typeof content !== "string") {
return NextResponse.json({ message: "Missing content" }, { status: 400 })
}
const trimmedContent = content.trim()
const trimmedVisitorId = visitorId.trim()
const normalizedShop = normalizeShopDomain(shop) const normalizedShop = normalizeShopDomain(shop)
// Guardrails (v1): keep it small, avoid empty spam.
if (trimmedContent.length < 1) {
return NextResponse.json({ message: "Empty message" }, { status: 400 })
}
if (trimmedContent.length > 2000) {
return NextResponse.json({ message: "Message too long" }, { status: 400 })
}
if (trimmedVisitorId.length > 300) {
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
}
const variants = shopVariants(normalizedShop) const variants = shopVariants(normalizedShop)
const stores = await p.store.findMany({ const stores = await p.store.findMany({
where: { shopifyDomain: { in: variants } }, where: { shopifyDomain: { in: variants } },
@ -74,7 +62,7 @@ export async function POST(req: Request) {
// Find or create conversation for this anonymous visitor. // Find or create conversation for this anonymous visitor.
let conversation = await p.storefrontChatConversation.findFirst({ let conversation = await p.storefrontChatConversation.findFirst({
where: { storeId: store.id, visitorId: trimmedVisitorId }, where: { storeId: store.id, visitorId },
select: { id: true } select: { id: true }
}) })
@ -95,8 +83,8 @@ export async function POST(req: Request) {
conversation = await p.storefrontChatConversation.create({ conversation = await p.storefrontChatConversation.create({
data: { data: {
storeId: store.id, storeId: store.id,
visitorId: trimmedVisitorId, visitorId,
customerEmail: payload.customerEmail ?? null customerEmail: customerEmail ?? null,
}, },
select: { id: true } select: { id: true }
}) })
@ -121,7 +109,7 @@ export async function POST(req: Request) {
const ai = await generateStorefrontReply({ const ai = await generateStorefrontReply({
storeId: store.id, storeId: store.id,
query: trimmedContent, query: trimmedContent,
visitorId: trimmedVisitorId, visitorId,
history: recentHistory.map((m: any) => ({ history: recentHistory.map((m: any) => ({
senderType: m.senderType, senderType: m.senderType,
content: m.content content: m.content

View File

@ -5,12 +5,7 @@ import { logger } from "@/lib/logger"
import { embedBatch } from "@/lib/kb/openaiEmbeddings" import { embedBatch } from "@/lib/kb/openaiEmbeddings"
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta" import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
import type { FaqItem } from "@prisma/client" import type { FaqItem } from "@prisma/client"
import { kbFaqImportArraySchema, validationErrorResponse } from "@/lib/api-schemas"
type FaqImportItem = {
question: string
answer: string
tags?: unknown
}
export async function POST( export async function POST(
request: Request, request: Request,
@ -46,7 +41,7 @@ export async function POST(
) )
} }
let items: FaqImportItem[] = [] let items: unknown[] = []
const contentType = request.headers.get("content-type") || "" const contentType = request.headers.get("content-type") || ""
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
@ -57,7 +52,7 @@ export async function POST(
{ status: 400 }, { status: 400 },
) )
} }
items = body as FaqImportItem[] items = body
} else if (contentType.includes("multipart/form-data")) { } else if (contentType.includes("multipart/form-data")) {
const formData = await request.formData() const formData = await request.formData()
const file = formData.get("file") const file = formData.get("file")
@ -76,7 +71,7 @@ export async function POST(
{ status: 400 }, { status: 400 },
) )
} }
items = parsed as FaqImportItem[] items = parsed
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ message: "Invalid JSON in uploaded file" }, { message: "Invalid JSON in uploaded file" },
@ -90,19 +85,14 @@ export async function POST(
) )
} }
const validItems = items.filter( const validated = kbFaqImportArraySchema.safeParse(items)
(item) => item.question && item.answer, if (!validated.success) {
) as FaqImportItem[] return validationErrorResponse(validated.error)
if (!validItems.length) {
return NextResponse.json(
{ message: "No valid FAQs to import" },
{ status: 400 },
)
} }
const validItems = validated.data
const createdFaqs = await prisma.$transaction( const createdFaqs = await prisma.$transaction(
validItems.map((item: FaqImportItem) => validItems.map((item) =>
prisma.faqItem.create({ prisma.faqItem.create({
data: { data: {
storeId: store.id, storeId: store.id,

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { embedBatch } from "@/lib/kb/openaiEmbeddings" import { embedBatch } from "@/lib/kb/openaiEmbeddings"
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta" import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
import { kbFaqCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
export async function GET( export async function GET(
_request: Request, _request: Request,
@ -78,19 +79,12 @@ export async function POST(
) )
} }
const body = await request.json() const raw = await request.json().catch(() => ({}))
const { question, answer, tags } = body as { const parsed = kbFaqCreateSchema.safeParse(raw)
question?: string if (!parsed.success) {
answer?: string return validationErrorResponse(parsed.error)
tags?: unknown
}
if (!question || !answer) {
return NextResponse.json(
{ message: "Question and answer are required" },
{ status: 400 },
)
} }
const { question, answer, tags } = parsed.data
const faq = await (prisma as any).faqItem.create({ const faq = await (prisma as any).faqItem.create({
data: { data: {

View File

@ -5,14 +5,7 @@ import { logger } from "@/lib/logger"
import { embedBatch } from "@/lib/kb/openaiEmbeddings" import { embedBatch } from "@/lib/kb/openaiEmbeddings"
import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta" import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta"
import { generateKbRagAnswer } from "@/lib/kb/kbAnswer" import { generateKbRagAnswer } from "@/lib/kb/kbAnswer"
import { kbQuerySchema, validationErrorResponse } from "@/lib/api-schemas"
type QueryBody = {
query?: string
topK?: number
includeFaq?: boolean
includeDocuments?: boolean
includeStructured?: boolean
}
type KnowledgeChunkRow = { type KnowledgeChunkRow = {
id: string id: string
@ -48,14 +41,13 @@ export async function POST(
return NextResponse.json({ message: "Store not found" }, { status: 404 }) return NextResponse.json({ message: "Store not found" }, { status: 404 })
} }
const body = (await request.json().catch(() => ({}))) as QueryBody const raw = await request.json().catch(() => ({}))
const question = body.query?.trim() const parsed = kbQuerySchema.safeParse(raw)
if (!question) { if (!parsed.success) {
return NextResponse.json( return validationErrorResponse(parsed.error)
{ message: "Query is required" },
{ status: 400 },
)
} }
const body = parsed.data
const question = body.query
const topK = Math.min(Math.max(body.topK ?? 8, 1), 20) const topK = Math.min(Math.max(body.topK ?? 8, 1), 20)
const includeFaq = body.includeFaq ?? true const includeFaq = body.includeFaq ?? true

View File

@ -268,11 +268,23 @@ function buildStorefrontScript(params: {
var headerText = document.createElement("div"); var headerText = document.createElement("div");
headerText.style.minWidth = "0"; headerText.style.minWidth = "0";
headerText.innerHTML = "<div style=\\"font-size:13px;font-weight:800;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" + var titleLine = document.createElement("div");
String(chatName || "Chat") + titleLine.style.fontSize = "13px";
"</div><div style=\\"font-size:11px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\\">" + titleLine.style.fontWeight = "800";
String(tagline || "") + titleLine.style.color = "#111827";
"</div>"; titleLine.style.whiteSpace = "nowrap";
titleLine.style.overflow = "hidden";
titleLine.style.textOverflow = "ellipsis";
titleLine.textContent = String(chatName || "Chat");
var subLine = document.createElement("div");
subLine.style.fontSize = "11px";
subLine.style.color = "#64748b";
subLine.style.whiteSpace = "nowrap";
subLine.style.overflow = "hidden";
subLine.style.textOverflow = "ellipsis";
subLine.textContent = String(tagline || "");
headerText.appendChild(titleLine);
headerText.appendChild(subLine);
header.appendChild(headerText); header.appendChild(headerText);
panel.appendChild(header); panel.appendChild(header);

View 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 })
}
}

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { storeSwitchSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* POST /api/stores/switch * POST /api/stores/switch
@ -14,11 +15,12 @@ export async function POST(request: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { storeId } = await request.json() const raw = await request.json().catch(() => ({}))
const parsed = storeSwitchSchema.safeParse(raw)
if (!storeId) { if (!parsed.success) {
return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) return validationErrorResponse(parsed.error)
} }
const { storeId } = parsed.data
// Verify the store belongs to the user // Verify the store belongs to the user
const store = await prisma.store.findFirst({ const store = await prisma.store.findFirst({

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { notifyInvitationAccepted } from "@/lib/notifications" import { notifyInvitationAccepted } from "@/lib/notifications"
import { supplierInvitationAcceptSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* POST /api/supplier/invitations/accept * POST /api/supplier/invitations/accept
@ -15,14 +16,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { token } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierInvitationAcceptSchema.safeParse(raw)
if (!token || typeof token !== "string") { if (!parsed.success) {
return NextResponse.json( return validationErrorResponse(parsed.error)
{ message: "Invitation token is required" },
{ status: 400 }
)
} }
const { token } = parsed.data
// Get supplier profile // Get supplier profile
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus" import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
async function getSupplierContext(userId: string) { async function getSupplierContext(userId: string) {
const profile = await prisma.supplierProfile.findUnique({ const profile = await prisma.supplierProfile.findUnique({
@ -398,13 +399,16 @@ export async function PATCH(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { orderId } = await params const { orderId } = await params
const body = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierOrderPatchSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { fulfillmentStatus: fs, trackingNumber, trackingCompany, trackingUrl } = parsed.data
const fulfillmentStatus = normalizeFulfillmentStatus(body.fulfillmentStatus) const fulfillmentStatus = normalizeFulfillmentStatus(
fs === undefined || fs === null ? undefined : String(fs),
const trackingNumber = String(body.trackingNumber || "").trim() || null )
const trackingCompany = String(body.trackingCompany || "").trim() || null
const trackingUrl = String(body.trackingUrl || "").trim() || null
const ctx = await getSupplierContext(session.user.id) const ctx = await getSupplierContext(session.user.id)
if (!ctx) { if (!ctx) {

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { supplierProductUpdateSchema } from "@/lib/api-schemas/business"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* PUT /api/supplier/products/[productId] * PUT /api/supplier/products/[productId]
@ -18,7 +20,12 @@ export async function PUT(
} }
const { productId } = await params const { productId } = await params
const { title, description, price, sku, imageUrl, quantity } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierProductUpdateSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { title, description, price, sku, imageUrl, quantity } = parsed.data
// Get supplier profile // Get supplier profile
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({
@ -58,26 +65,33 @@ export async function PUT(
// Update the shopifyProductData with new values (including optional supplier-specific quantity) // Update the shopifyProductData with new values (including optional supplier-specific quantity)
const updatedData = { const updatedData = {
...(productSync.shopifyProductData || {}), ...(productSync.shopifyProductData || {}),
title: title || productSync.shopifyProductTitle, title: title ?? productSync.shopifyProductTitle,
body_html: description !== undefined ? description : (productSync.shopifyProductData?.body_html || ""), body_html:
variants: productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({ description !== undefined
...v, ? description
price: idx === 0 && price !== undefined ? price.toString() : v.price, : productSync.shopifyProductData?.body_html || "",
sku: idx === 0 && sku !== undefined ? sku : v.sku variants:
})) || [], productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image, ...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: supplier_quantity:
quantity !== undefined quantity !== undefined
? quantity ? quantity
: productSync.shopifyProductData?.supplier_quantity : productSync.shopifyProductData?.supplier_quantity,
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const updatedSync = await (prisma as any).productSync.update({ const updatedSync = await (prisma as any).productSync.update({
where: { id: syncId }, where: { id: syncId },
data: { data: {
shopifyProductTitle: title || productSync.shopifyProductTitle, shopifyProductTitle: title ?? productSync.shopifyProductTitle,
shopifyProductData: updatedData shopifyProductData: updatedData,
} }
}) })
@ -107,9 +121,12 @@ export async function PUT(
product: { product: {
id: productId, id: productId,
syncId, syncId,
title: title || productSync.shopifyProductTitle, title: title ?? productSync.shopifyProductTitle,
description, description,
price: price !== undefined ? parseFloat(price) : parseFloat(updatedData.variants?.[0]?.price || "0"), price:
price !== undefined
? price
: parseFloat(updatedData.variants?.[0]?.price || "0"),
sku, sku,
imageUrl, imageUrl,
availableQuantity: updatedData.supplier_quantity, availableQuantity: updatedData.supplier_quantity,
@ -133,11 +150,11 @@ export async function PUT(
const product = await prisma.product.update({ const product = await prisma.product.update({
where: { id: productId }, where: { id: productId },
data: { data: {
title: title || existingProduct.title, title: title ?? existingProduct.title,
description: description !== undefined ? description : existingProduct.description, description: description !== undefined ? description : existingProduct.description,
price: price !== undefined ? parseFloat(price) : existingProduct.price, price: price !== undefined ? price : existingProduct.price,
sku: sku !== undefined ? sku : existingProduct.sku, sku: sku !== undefined ? sku : existingProduct.sku,
imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl,
} }
}) })

View File

@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
import { supplierProductCreateSchema } from "@/lib/api-schemas/business"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* GET /api/supplier/products * GET /api/supplier/products
@ -106,14 +108,12 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { title, description, price, sku, imageUrl } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierProductCreateSchema.safeParse(raw)
if (!title || price === undefined) { if (!parsed.success) {
return NextResponse.json( return validationErrorResponse(parsed.error)
{ message: "Title and price are required" },
{ status: 400 }
)
} }
const { title, description, price, sku, imageUrl } = parsed.data
// Get supplier profile // Get supplier profile
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({
@ -128,10 +128,10 @@ export async function POST(req: Request) {
data: { data: {
supplierId: supplierProfile.id, supplierId: supplierProfile.id,
title, title,
description: description || null, description: description?.trim() ? description : null,
price: parseFloat(price), price,
sku: sku || null, sku: sku?.trim() ? sku : null,
imageUrl: imageUrl || null imageUrl: imageUrl?.trim() ? imageUrl : null,
} }
}) })

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { supplierProfileUpsertSchema } from "@/lib/api-schemas/business"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
/** /**
* GET /api/supplier/profile * GET /api/supplier/profile
@ -58,23 +60,24 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
} }
const { companyName, description } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierProfileUpsertSchema.safeParse(raw)
if (!companyName) { if (!parsed.success) {
return NextResponse.json({ message: "Company name is required" }, { status: 400 }) return validationErrorResponse(parsed.error)
} }
const { companyName, description } = parsed.data
// Upsert supplier profile // Upsert supplier profile
const supplierProfile = await prisma.supplierProfile.upsert({ const supplierProfile = await prisma.supplierProfile.upsert({
where: { userId: session.user.id }, where: { userId: session.user.id },
update: { update: {
companyName, companyName,
description: description || null description: description?.trim() ? description : null,
}, },
create: { create: {
userId: session.user.id, userId: session.user.id,
companyName, companyName,
description: description || null description: description?.trim() ? description : null,
}, },
include: { include: {
user: { user: {

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { createNotification } from "@/lib/notifications" import { createNotification } from "@/lib/notifications"
import { supplierOfferCreateSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* GET /api/supplier/vendors/[connectionId]/offers * GET /api/supplier/vendors/[connectionId]/offers
@ -97,26 +98,12 @@ export async function POST(
} }
const { connectionId } = await params const { connectionId } = await params
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { productId, price, quantity } = body as { const parsed = supplierOfferCreateSchema.safeParse(raw)
productId?: string if (!parsed.success) {
price?: number return validationErrorResponse(parsed.error)
quantity?: number
}
if (!productId || price === undefined || quantity === undefined) {
return NextResponse.json(
{ message: "productId, price and quantity are required" },
{ status: 400 }
)
}
if (quantity <= 0 || price < 0) {
return NextResponse.json(
{ message: "Price must be non-negative and quantity must be greater than zero" },
{ status: 400 }
)
} }
const { productId, price, quantity } = parsed.data
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id } where: { userId: session.user.id }

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { supplierConnectionStatusSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* GET /api/supplier/vendors/[connectionId] * GET /api/supplier/vendors/[connectionId]
@ -81,14 +82,12 @@ export async function PUT(
} }
const { connectionId } = await params const { connectionId } = await params
const { status } = await req.json() const raw = await req.json().catch(() => ({}))
const parsed = supplierConnectionStatusSchema.safeParse(raw)
if (!status || !["CONNECTED", "REJECTED"].includes(status)) { if (!parsed.success) {
return NextResponse.json( return validationErrorResponse(parsed.error)
{ message: "Valid status (CONNECTED or REJECTED) is required" },
{ status: 400 }
)
} }
const { status } = parsed.data
// Get supplier profile // Get supplier profile
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { createNotification } from "@/lib/notifications" import { createNotification } from "@/lib/notifications"
import { supplierOfferUpdateSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* PUT /api/supplier/vendors/offers/[offerId] * PUT /api/supplier/vendors/offers/[offerId]
@ -19,32 +20,12 @@ export async function PUT(
} }
const { offerId } = await params const { offerId } = await params
const body = await req.json() const raw = await req.json().catch(() => ({}))
const { price, quantity } = body as { const parsed = supplierOfferUpdateSchema.safeParse(raw)
price?: number if (!parsed.success) {
quantity?: number return validationErrorResponse(parsed.error)
}
if (price === undefined && quantity === undefined) {
return NextResponse.json(
{ message: "At least one of price or quantity must be provided" },
{ status: 400 }
)
}
if (quantity !== undefined && quantity <= 0) {
return NextResponse.json(
{ message: "Quantity must be greater than zero" },
{ status: 400 }
)
}
if (price !== undefined && price < 0) {
return NextResponse.json(
{ message: "Price must be non-negative" },
{ status: 400 }
)
} }
const { price, quantity } = parsed.data
const supplierProfile = await prisma.supplierProfile.findUnique({ const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id } where: { userId: session.user.id }

View File

@ -6,6 +6,7 @@ import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
import { notifyInvitationReceived } from "@/lib/notifications" import { notifyInvitationReceived } from "@/lib/notifications"
import { getBaseUrl } from "@/lib/utils" import { getBaseUrl } from "@/lib/utils"
import crypto from "crypto" import crypto from "crypto"
import { vendorInviteDeleteSchema, vendorInvitePostSchema, validationErrorResponse } from "@/lib/api-schemas"
/** /**
* DELETE /api/vendors/suppliers/[supplierId]/invite * DELETE /api/vendors/suppliers/[supplierId]/invite
@ -23,8 +24,12 @@ export async function DELETE(
} }
const { supplierId } = await params const { supplierId } = await params
const body = await req.json().catch(() => ({})) const raw = await req.json().catch(() => ({}))
const storeId = body.storeId // Optional: specific store to cancel, otherwise cancel all const parsed = vendorInviteDeleteSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const storeId = parsed.data.storeId ?? undefined
// Get vendor's stores // Get vendor's stores
const vendorStores = await prisma.store.findMany({ const vendorStores = await prisma.store.findMany({
@ -101,8 +106,12 @@ export async function POST(
} }
const { supplierId } = await params const { supplierId } = await params
const body = await req.json().catch(() => ({})) const raw = await req.json().catch(() => ({}))
const connectToAllStores = body.connectToAllStores === true const parsed = vendorInvitePostSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const connectToAllStores = parsed.data.connectToAllStores === true
// Get vendor's stores // Get vendor's stores
const vendorStores = await prisma.store.findMany({ const vendorStores = await prisma.store.findMany({

View File

@ -289,7 +289,12 @@ export default function DashboardPage() {
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)", boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
backdropFilter: isLight ? "none" : "blur(12px)", backdropFilter: isLight ? "none" : "blur(12px)",
}} }}
formatter={(value: number | undefined) => [value !== undefined ? `$${value.toLocaleString()}` : "$0", "Revenue"]} formatter={(value) => {
const num =
typeof value === "number" ? value : Number(value)
const v = Number.isFinite(num) ? num : 0
return [`$${v.toLocaleString()}`, "Revenue"]
}}
/> />
<Area <Area
type="monotone" type="monotone"

View File

@ -1,9 +1,31 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback } from "react"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { User, Mail, Building, Save, Loader2, CheckCircle2, AlertCircle, Bell, BellOff } from "lucide-react" import {
User,
Mail,
Building,
Save,
Loader2,
CheckCircle2,
AlertCircle,
Bell,
BellOff,
KeyRound,
ChevronDown,
} from "lucide-react"
import { usePushNotifications } from "@/hooks/usePushNotifications" import { usePushNotifications } from "@/hooks/usePushNotifications"
import { accountUpdateSchema, isStrongPassword } from "@/lib/validation"
import { cn } from "@/lib/utils"
type ActiveStoreMeta = {
id: string
businessName: string
shopifyDomain: string
}
const SHOPIFY_TOKEN_MAX = 4000
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
const { data: session, update } = useSession() const { data: session, update } = useSession()
@ -19,26 +41,100 @@ export default function AccountSettingsPage() {
confirmPassword: "" confirmPassword: ""
}) })
useEffect(() => { const [activeStoreMeta, setActiveStoreMeta] = useState<ActiveStoreMeta | null>(null)
// Fetch user's business name (from first store or user name) const [shopifyTokenOpen, setShopifyTokenOpen] = useState(false)
const fetchBusinessName = async () => { const [newShopifyToken, setNewShopifyToken] = useState("")
try { const [tokenLoading, setTokenLoading] = useState(false)
const res = await fetch("/api/stores") const [tokenSuccess, setTokenSuccess] = useState("")
if (res.ok) { const [tokenError, setTokenError] = useState("")
const data = await res.json()
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) const refreshActiveStore = useCallback(async () => {
if (activeStore?.businessName) { try {
setFormData(prev => ({ ...prev, businessName: activeStore.businessName })) const res = await fetch("/api/stores")
} else if (session?.user?.name) { if (!res.ok) return
setFormData(prev => ({ ...prev, businessName: session.user.name || "" })) const data = await res.json()
} const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) as
} | {
} catch { id?: string
// Silent fail 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?.user?.name])
}, [session])
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -59,7 +155,7 @@ export default function AccountSettingsPage() {
setLoading(false) setLoading(false)
return return
} }
if (formData.newPassword.length < 8) { if (!isStrongPassword(formData.newPassword)) {
setError("New password must be at least 8 characters and include upper, lower, number and special character") setError("New password must be at least 8 characters and include upper, lower, number and special character")
setLoading(false) setLoading(false)
return return
@ -77,10 +173,17 @@ export default function AccountSettingsPage() {
updateData.newPassword = formData.newPassword updateData.newPassword = formData.newPassword
} }
const bodyCheck = accountUpdateSchema.safeParse(updateData)
if (!bodyCheck.success) {
setError(bodyCheck.error.issues[0]?.message ?? "Invalid input")
setLoading(false)
return
}
const res = await fetch("/api/settings/account", { const res = await fetch("/api/settings/account", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateData) body: JSON.stringify(bodyCheck.data),
}) })
if (!res.ok) { if (!res.ok) {
@ -149,6 +252,7 @@ export default function AccountSettingsPage() {
value={formData.businessName} value={formData.businessName}
onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, businessName: e.target.value }))}
placeholder="Enter your business name" placeholder="Enter your business name"
maxLength={200}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
/> />
</div> </div>
@ -167,6 +271,7 @@ export default function AccountSettingsPage() {
value={formData.currentPassword} value={formData.currentPassword}
onChange={(e) => setFormData(prev => ({ ...prev, currentPassword: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, currentPassword: e.target.value }))}
placeholder="Enter current password" placeholder="Enter current password"
maxLength={128}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
/> />
</div> </div>
@ -177,6 +282,7 @@ export default function AccountSettingsPage() {
value={formData.newPassword} value={formData.newPassword}
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
placeholder="Enter new password" placeholder="Enter new password"
maxLength={128}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
/> />
</div> </div>
@ -187,6 +293,7 @@ export default function AccountSettingsPage() {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
placeholder="Confirm new password" placeholder="Confirm new password"
maxLength={128}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
/> />
</div> </div>
@ -214,6 +321,96 @@ export default function AccountSettingsPage() {
</div> </div>
</form> </form>
{/* Shopify access token (collapsed by default — reinstall / new scopes) */}
{activeStoreMeta ? (
<div className="border-t border-white/10 pt-6">
<button
type="button"
onClick={toggleShopifyTokenPanel}
className="flex w-full items-center justify-between gap-3 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left transition-colors hover:bg-white/[0.07]"
>
<span className="flex min-w-0 items-center gap-2">
<KeyRound className="h-4 w-4 shrink-0 text-indigo-400" />
<span className="min-w-0">
<span className="block text-sm font-bold text-white">
Shopify access token
</span>
<span className="block truncate text-xs text-slate-500">
{activeStoreMeta.businessName} · {activeStoreMeta.shopifyDomain}
</span>
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-slate-400 transition-transform",
shopifyTokenOpen && "rotate-180",
)}
/>
</button>
{shopifyTokenOpen ? (
<div className="mt-3 space-y-4 rounded-xl border border-white/10 bg-white/[0.03] p-4">
<p className="text-xs leading-relaxed text-slate-400">
Reinstalled your app in Shopify or added API permissions? Shopify issues a
new <strong className="text-slate-300">Admin API access token</strong>.
Paste it here—your store domain stays the same; we verify the token with
Shopify before saving.
</p>
{tokenSuccess ? (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-400">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{tokenSuccess}
</div>
) : null}
{tokenError ? (
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
<AlertCircle className="h-4 w-4 shrink-0" />
{tokenError}
</div>
) : null}
<div>
<label className="mb-2 block text-xs font-medium text-slate-400">
New Admin API access token
</label>
<input
type="password"
autoComplete="off"
value={newShopifyToken}
maxLength={SHOPIFY_TOKEN_MAX}
onChange={(e) =>
setNewShopifyToken(
e.target.value.slice(0, SHOPIFY_TOKEN_MAX),
)
}
placeholder="shpat_…"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-sm text-white placeholder:text-slate-600 focus:border-indigo-500/50 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
</div>
<div className="flex justify-end">
<button
type="button"
disabled={tokenLoading || !newShopifyToken.trim()}
onClick={() => void saveShopifyAccessToken()}
className="flex items-center gap-2 rounded-xl bg-indigo-500 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{tokenLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying…
</>
) : (
<>
<KeyRound className="h-4 w-4" />
Save token
</>
)}
</button>
</div>
</div>
) : null}
</div>
) : null}
{/* Push notifications */} {/* Push notifications */}
<div className="pt-6 border-t border-white/10"> <div className="pt-6 border-t border-white/10">
<h2 className="text-xl font-bold text-white flex items-center gap-2"> <h2 className="text-xl font-bold text-white flex items-center gap-2">

View File

@ -6,6 +6,7 @@ import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import Image from "next/image" import Image from "next/image"
import { authRegisterSchema } from "@/lib/validation"
function SignupFormContent() { function SignupFormContent() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -43,6 +44,13 @@ function SignupFormContent() {
const email = formData.get("email") as string const email = formData.get("email") as string
const password = formData.get("password") as string const password = formData.get("password") as string
const precheck = authRegisterSchema.safeParse({ name, email, password, role })
if (!precheck.success) {
setError(precheck.error.issues[0]?.message ?? "Please check your details")
setLoading(false)
return
}
try { try {
const res = await fetch("/api/auth/register", { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",
@ -151,6 +159,7 @@ function SignupFormContent() {
name="name" name="name"
type="text" type="text"
required required
maxLength={100}
placeholder="John Carter" placeholder="John Carter"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/> />
@ -167,6 +176,7 @@ function SignupFormContent() {
name="email" name="email"
type="email" type="email"
required required
maxLength={254}
placeholder="john@example.com" placeholder="john@example.com"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/> />
@ -183,6 +193,7 @@ function SignupFormContent() {
name="password" name="password"
type="password" type="password"
required required
maxLength={128}
placeholder="••••••••" placeholder="••••••••"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200" className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/> />

View File

@ -257,7 +257,11 @@ export default function SupplierDashboardPage() {
<Tooltip <Tooltip
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }} contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
labelStyle={{ color: "rgb(248 250 252)" }} labelStyle={{ color: "rgb(248 250 252)" }}
formatter={(value: number | undefined) => [value ?? 0, "Count"]} formatter={(value) => {
const num =
typeof value === "number" ? value : Number(value)
return [Number.isFinite(num) ? num : 0, "Count"]
}}
/> />
<Bar dataKey="value" radius={[4, 4, 0, 0]}> <Bar dataKey="value" radius={[4, 4, 0, 0]}>
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (

View File

@ -364,6 +364,7 @@ export default function SupplierProductsPage() {
<input <input
type="text" type="text"
required required
maxLength={500}
value={formData.title} value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
@ -377,6 +378,7 @@ export default function SupplierProductsPage() {
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3} rows={3}
maxLength={50_000}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="Product description" placeholder="Product description"
/> />
@ -403,6 +405,7 @@ export default function SupplierProductsPage() {
type="text" type="text"
value={formData.sku} value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })} onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
maxLength={200}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="SKU" placeholder="SKU"
/> />
@ -428,6 +431,7 @@ export default function SupplierProductsPage() {
type="url" type="url"
value={formData.imageUrl} value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })} onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
maxLength={4000}
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="https://example.com/image.jpg" placeholder="https://example.com/image.jpg"
/> />

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { useTheme } from "@/components/ThemeProvider" import { useTheme } from "@/components/ThemeProvider"
@ -20,6 +20,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { format } from "date-fns" import { format } from "date-fns"
import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal" import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
import { CHAT_MESSAGE_CONTENT_MAX } from "@/lib/api-schemas/public"
interface Contact { interface Contact {
id: string id: string
@ -43,27 +44,6 @@ interface Message {
createdAt: string createdAt: string
} }
function AlertCircle(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)
}
type AttachOrder = { type AttachOrder = {
id: number id: number
name: string name: string
@ -287,8 +267,7 @@ export function ChatView() {
setAttachOpen(false) setAttachOpen(false)
} }
const handleSendMessage = async (e: React.FormEvent) => { const sendMessage = async () => {
e.preventDefault()
if (!selectedContact) return if (!selectedContact) return
const hasText = !!newMessage.trim() const hasText = !!newMessage.trim()
const hasAttachment = !!draftKind && !!draftAttachment const hasAttachment = !!draftKind && !!draftAttachment
@ -297,10 +276,10 @@ export function ChatView() {
try { try {
setSending(true) setSending(true)
setSendError(null) setSendError(null)
const payload: any = { const payload: Record<string, unknown> = {
receiverId: selectedContact.id, receiverId: selectedContact.id,
storeId: selectedContact.storeId, storeId: selectedContact.storeId,
content: newMessage content: newMessage,
} }
if (hasAttachment) { if (hasAttachment) {
payload.kind = draftKind payload.kind = draftKind
@ -309,12 +288,12 @@ export function ChatView() {
const res = await fetch("/api/chat/messages", { const res = await fetch("/api/chat/messages", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}) })
if (res.ok) { if (res.ok) {
const msg = await res.json() const msg = await res.json()
const optimiticMsg: Message = { const optimisticMsg: Message = {
id: msg.id, id: msg.id,
senderId: msg.senderId, senderId: msg.senderId,
receiverId: msg.receiverId, receiverId: msg.receiverId,
@ -322,14 +301,19 @@ export function ChatView() {
content: newMessage, content: newMessage,
kind: msg.kind ?? "TEXT", kind: msg.kind ?? "TEXT",
attachment: msg.attachment ?? null, attachment: msg.attachment ?? null,
createdAt: msg.createdAt createdAt: msg.createdAt,
} }
setMessages(prev => [...prev, optimiticMsg]) setMessages((prev) => [...prev, optimisticMsg])
setNewMessage("") setNewMessage("")
setDraftKind(null) setDraftKind(null)
setDraftAttachment(null) setDraftAttachment(null)
} else { } else {
setSendError("Failed to send message. Please try again.") const errJson = await res.json().catch(() => ({}))
const errMsg =
typeof errJson?.message === "string"
? errJson.message
: "Failed to send message. Please try again."
setSendError(errMsg)
} }
} catch (error) { } catch (error) {
console.error("Send message error", error) console.error("Send message error", error)
@ -339,6 +323,11 @@ export function ChatView() {
} }
} }
const handleSendMessage = (e: FormEvent) => {
e.preventDefault()
void sendMessage()
}
// Best-effort compatibility for legacy bracket-style chat messages. // Best-effort compatibility for legacy bracket-style chat messages.
// Example legacy content: // Example legacy content:
// - [Product: Title · Store (#123)] // - [Product: Title · Store (#123)]
@ -461,7 +450,9 @@ export function ChatView() {
type="text" type="text"
placeholder="Find connections..." placeholder="Find connections..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e: ChangeEvent<HTMLInputElement>) =>
setSearchQuery(e.target.value)
}
className={cn( className={cn(
"w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all font-medium", "w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all font-medium",
isLight ? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400" : "bg-white/5 border border-white/10 text-white placeholder:text-slate-600" isLight ? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400" : "bg-white/5 border border-white/10 text-white placeholder:text-slate-600"
@ -916,11 +907,16 @@ export function ChatView() {
rows={1} rows={1}
placeholder="Type a message..." placeholder="Type a message..."
value={newMessage} value={newMessage}
onChange={(e) => setNewMessage(e.target.value)} maxLength={CHAT_MESSAGE_CONTENT_MAX}
onKeyDown={(e) => { onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
setNewMessage(
e.target.value.slice(0, CHAT_MESSAGE_CONTENT_MAX),
)
}
onKeyDown={(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSendMessage(e) void sendMessage()
} }
}} }}
className={cn( className={cn(

View File

@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { import {
KNOWLEDGE_TEMPLATE_FIELDS, KNOWLEDGE_TEMPLATE_FIELDS,
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
KNOWLEDGE_TEMPLATE_MISC_MAX,
type KnowledgeTemplateFieldKey, type KnowledgeTemplateFieldKey,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
@ -286,11 +288,15 @@ export function StructuredKnowledgeModal({
onChange={(e) => onChange={(e) =>
setAnswers((a) => ({ setAnswers((a) => ({
...a, ...a,
[field.key]: e.target.value, [field.key]: e.target.value.slice(
0,
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
),
})) }))
} }
placeholder={field.placeholder} placeholder={field.placeholder}
rows={3} rows={3}
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
className={cn( className={cn(
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30", "w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
isLight isLight
@ -320,9 +326,14 @@ export function StructuredKnowledgeModal({
</p> </p>
<textarea <textarea
value={misc} value={misc}
onChange={(e) => setMisc(e.target.value)} onChange={(e) =>
setMisc(
e.target.value.slice(0, KNOWLEDGE_TEMPLATE_MISC_MAX),
)
}
placeholder="Optional…" placeholder="Optional…"
rows={3} rows={3}
maxLength={KNOWLEDGE_TEMPLATE_MISC_MAX}
className={cn( className={cn(
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30", "w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
isLight isLight

View File

@ -115,6 +115,7 @@ export function LandingFooter() {
name="email" name="email"
type="email" type="email"
required required
maxLength={254}
placeholder="Enter your email" placeholder="Enter your email"
className="bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-indigo-500 transition-colors flex-1" className="bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-indigo-500 transition-colors flex-1"
/> />

View 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>

View 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),
})

View File

@ -0,0 +1,6 @@
export * from "./public"
export * from "./business"
export * from "./notificationsPush"
export * from "./vendorMutations"
export * from "./extraMutations"
export { validationErrorResponse } from "./validate"

View 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),
})

View 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>

View 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)
})
})

View 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 },
)
}

View 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>

View File

@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client" import type { Prisma } from "@prisma/client"
import { prisma } from "./prisma" import { prisma } from "./prisma"
import { fetchProductMetafieldsMap } from "@/lib/shopify/productMetafieldsGraphql"
/** Minimal Shopify API shapes for cache updates */ /** Minimal Shopify API shapes for cache updates */
interface ShopifyProductRecord { interface ShopifyProductRecord {
@ -130,6 +131,28 @@ export async function getCachedCustomers(storeId: string) {
*/ */
export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) { export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) {
try { try {
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
let metafieldsByProductId = new Map<string, Prisma.InputJsonValue>()
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
try {
const ids = products.map((p) => p.id)
const map = await fetchProductMetafieldsMap(
store.shopifyDomain,
store.shopifyAccessToken,
ids,
)
for (const [pid, rows] of map) {
metafieldsByProductId.set(pid, rows as Prisma.InputJsonValue)
}
} catch (e) {
console.error("[updateProductCache] Metafields GraphQL fetch failed:", e)
}
}
// Delete existing cached products // Delete existing cached products
await prisma.cachedProduct.deleteMany({ await prisma.cachedProduct.deleteMany({
where: { storeId } where: { storeId }
@ -144,7 +167,9 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
productType: product.product_type || null, productType: product.product_type || null,
status: product.status || null, status: product.status || null,
variants: (product.variants || []) as import("@prisma/client").Prisma.InputJsonValue, variants: (product.variants || []) as import("@prisma/client").Prisma.InputJsonValue,
images: (product.images || []) as import("@prisma/client").Prisma.InputJsonValue images: (product.images || []) as import("@prisma/client").Prisma.InputJsonValue,
metafields: (metafieldsByProductId.get(product.id.toString()) ??
[]) as Prisma.InputJsonValue,
})) }))
await prisma.cachedProduct.createMany({ await prisma.cachedProduct.createMany({
@ -398,6 +423,28 @@ export async function updateCustomerCache(storeId: string, customers: ShopifyCus
* Update a single product in cache (for webhook updates) * Update a single product in cache (for webhook updates)
*/ */
export async function updateSingleProduct(storeId: string, product: ShopifyProductRecord) { export async function updateSingleProduct(storeId: string, product: ShopifyProductRecord) {
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
let metafieldsPatch: Prisma.InputJsonValue | undefined
if (store?.shopifyDomain && store?.shopifyAccessToken) {
try {
const map = await fetchProductMetafieldsMap(
store.shopifyDomain,
store.shopifyAccessToken,
[product.id],
)
const rows = map.get(product.id.toString())
if (rows !== undefined) {
metafieldsPatch = rows as Prisma.InputJsonValue
}
} catch (e) {
console.error("[updateSingleProduct] Metafields fetch failed:", e)
}
}
await prisma.cachedProduct.upsert({ await prisma.cachedProduct.upsert({
where: { where: {
shopifyProductId_storeId: { shopifyProductId_storeId: {
@ -414,6 +461,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
status: product.status || null, status: product.status || null,
variants: (product.variants || []) as Prisma.InputJsonValue, variants: (product.variants || []) as Prisma.InputJsonValue,
images: (product.images || []) as Prisma.InputJsonValue, images: (product.images || []) as Prisma.InputJsonValue,
metafields: (metafieldsPatch ?? []) as Prisma.InputJsonValue,
lastSyncedAt: new Date() lastSyncedAt: new Date()
}, },
update: { update: {
@ -423,6 +471,7 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
status: product.status || null, status: product.status || null,
variants: (product.variants || []) as Prisma.InputJsonValue, variants: (product.variants || []) as Prisma.InputJsonValue,
images: (product.images || []) as Prisma.InputJsonValue, images: (product.images || []) as Prisma.InputJsonValue,
...(metafieldsPatch !== undefined ? { metafields: metafieldsPatch } : {}),
lastSyncedAt: new Date() lastSyncedAt: new Date()
} }
}) })

View File

@ -68,16 +68,18 @@ export const KNOWLEDGE_TEMPLATE_FIELDS: {
}, },
] ]
const answerMax = 12_000 /** Per-field answer cap (mirrored on structured KB form). */
const miscMax = 16_000 export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000
/** Misc notes cap. */
export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000
export const knowledgeTemplatePutSchema = z.object({ export const knowledgeTemplatePutSchema = z.object({
knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(), knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
confirmDestructiveSwitch: z.boolean().optional(), confirmDestructiveSwitch: z.boolean().optional(),
template: z template: z
.object({ .object({
answers: z.record(z.string().max(answerMax)).optional(), answers: z.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)).optional(),
misc: z.string().max(miscMax).optional(), misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(),
}) })
.optional(), .optional(),
}) })

View File

@ -2,14 +2,23 @@ type RateLimitResult = {
success: boolean success: boolean
} }
export type RateLimitOptions = {
/** Window length in ms (default 60_000). */
windowMs?: number
/** Max requests per window per IP (default 10). */
maxRequests?: number
}
// Simple in-memory sliding window rate limiter per-process. // Simple in-memory sliding window rate limiter per-process.
// This avoids external dependencies while still providing basic protection. // This avoids external dependencies while still providing basic protection.
const WINDOW_MS = 60_000 // 1 minute const DEFAULT_WINDOW_MS = 60_000 // 1 minute
const MAX_REQUESTS = 10 const DEFAULT_MAX_REQUESTS = 10
type Entry = { type Entry = {
count: number count: number
windowStart: number windowStart: number
windowMs: number
maxRequests: number
} }
const buckets = new Map<string, Entry>() const buckets = new Map<string, Entry>()
@ -23,19 +32,33 @@ function getClientIp(req: Request): string {
return req.headers.get("x-real-ip") ?? "unknown" return req.headers.get("x-real-ip") ?? "unknown"
} }
export async function applyRateLimit(req: Request, keyPrefix: string): Promise<RateLimitResult> { /**
* Sliding-window rate limit by client IP. Key includes window/max so presets do not share wrong counters.
*/
export async function applyRateLimit(
req: Request,
keyPrefix: string,
options?: RateLimitOptions,
): Promise<RateLimitResult> {
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS
const ip = getClientIp(req) const ip = getClientIp(req)
const key = `${keyPrefix}:${ip}` const key = `${keyPrefix}:w${windowMs}:m${maxRequests}:${ip}`
const now = Date.now() const now = Date.now()
const existing = buckets.get(key) const existing = buckets.get(key)
if (!existing || now - existing.windowStart > WINDOW_MS) { if (!existing || now - existing.windowStart > existing.windowMs) {
buckets.set(key, { count: 1, windowStart: now }) buckets.set(key, {
count: 1,
windowStart: now,
windowMs,
maxRequests,
})
return { success: true } return { success: true }
} }
if (existing.count >= MAX_REQUESTS) { if (existing.count >= existing.maxRequests) {
return { success: false } return { success: false }
} }

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}

View 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"

View 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." }
}
}

View 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
}

View File

@ -30,13 +30,33 @@ function compact(value: string, max = 420) {
return `${value.slice(0, max - 1)}...` return `${value.slice(0, max - 1)}...`
} }
function metafieldsForText(metafields: unknown): string {
if (!Array.isArray(metafields) || metafields.length === 0) return ""
const parts: string[] = []
for (const row of metafields) {
if (!row || typeof row !== "object") continue
const r = row as { namespace?: string; key?: string; value?: string | null }
const k = r.key
const v = r.value
if (!k || v === null || v === undefined || String(v).trim() === "") continue
const ns = r.namespace ? `${r.namespace}.` : ""
parts.push(`${ns}${k}: ${String(v).slice(0, 200)}`)
}
return parts.join(" | ")
}
function productPreview(product: any) { function productPreview(product: any) {
const variants = Array.isArray(product?.variants) ? product.variants : [] const variants = Array.isArray(product?.variants) ? product.variants : []
const firstVariant = variants[0] || {} const firstVariant = variants[0] || {}
const price = firstVariant?.price ?? null const price = firstVariant?.price ?? null
const sku = firstVariant?.sku ?? null const sku = firstVariant?.sku ?? null
const weight =
firstVariant?.weight !== undefined && firstVariant?.weight !== null
? `${String(firstVariant.weight)}${firstVariant.weight_unit ? ` ${firstVariant.weight_unit}` : ""}`
: ""
const inventory = firstVariant?.inventory_quantity ?? null const inventory = firstVariant?.inventory_quantity ?? null
const status = product?.status || "unknown" const status = product?.status || "unknown"
const mfLine = metafieldsForText(product?.metafields)
const bits = [ const bits = [
`Title: ${product?.title || "Unknown"}`, `Title: ${product?.title || "Unknown"}`,
@ -45,7 +65,9 @@ function productPreview(product: any) {
`Status: ${status}`, `Status: ${status}`,
price !== null && price !== undefined ? `Price: ${String(price)}` : "", price !== null && price !== undefined ? `Price: ${String(price)}` : "",
sku ? `SKU: ${String(sku)}` : "", sku ? `SKU: ${String(sku)}` : "",
weight ? `Weight: ${weight}` : "",
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "", inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
mfLine ? `Details: ${mfLine}` : "",
].filter(Boolean) ].filter(Boolean)
return bits.join(" | ") return bits.join(" | ")
@ -99,12 +121,13 @@ export async function getBlendedStorefrontContext(params: {
status: true, status: true,
variants: true, variants: true,
images: true, images: true,
metafields: true,
}, },
}) })
const rankedProducts: RetrievedContextItem[] = (products || []) const rankedProducts: RetrievedContextItem[] = (products || [])
.map((prod: any) => { .map((prod: any) => {
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""}` const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""} ${metafieldsForText(prod?.metafields)}`
const score = scoreByTerms(text, terms) const score = scoreByTerms(text, terms)
return { return {
source: "PRODUCT", source: "PRODUCT",

15
tests/e2e/smoke.spec.ts Normal file
View 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()
})
})

View File

@ -26,10 +26,7 @@
"@/*": [ "@/*": [
"./src/*" "./src/*"
] ]
}, }
"types": [
"node"
]
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",