Author SHA1 Message Date
Your Name 99907308d3 feat(catalog): seed and link product attributes for 45 reed diffuser products 2026-10-19 09:40:21 +07:00
Your Name 296adb849c fix(categories): fallback to product name, description and details when translations are missing 2026-10-19 09:32:09 +07:00
Your Name 5686b3ea9c fix(backend): prioritize value.value in getPrimaryOptionValue for select attribute validation 2026-10-19 09:30:04 +07:00
Your Name e7fc0e2848 feat(catalog): add homaroma attribute definitions JSON schema and API import script 2026-10-19 09:21:29 +07:00
Your Name 1f7bc0e01c chore(scripts): add script to reassign all products to Hom Aroma brand and prune old scent brands 2026-10-19 08:10:32 +07:00
Your Name 44d9f1f198 fix(backend): close try-catch block for attribute definition route in product-attributes 2026-10-19 07:02:35 +07:00
antigravity-bot BotandYour Name 9107a64637 fix(security): Enforce admin authentication across sensitive backend endpoints and eliminate BOM data leak (#15)
## 🛡️ Security Audit & Hardening Report

### 1. Executive Summary
PR นี้ทำการตรวจสอบและปิดช่องโหว่ด้านความปลอดภัยของ **Backend API (Elysia.js)** ทั่วทั้งระบบ โดยบังคับใช้ **Admin Authentication Guard (`adminOnly` Middleware)** กับทุก Endpoint ที่เกี่ยวข้องกับ:
1. การจัดการและแก้ไขข้อมูลแคตตาล็อกสินค้า (Product & Category CRUD, Bulk Import/Delete)
2. ข้อมูลความลับทางการค้าและสูตรการผลิต (**Bill of Materials - BOM**, ต้นทุน Raw Materials, Profitability/MRP Forecast)
3. ข้อมูลส่วนบุคคลของลูกค้า (**PII** เช่น รายชื่อลูกค้า CRM, RFM Segment, ข้อความติดต่อ Contact Form, อีเมล Newsletter Subscribers)
4. การจัดการคำสั่งซื้อและสถานะ (Order Status Update, Tracking Assignment)
5. การจัดการ Assets และเนื้อหาเว็บไซต์ (Articles, Media Deletion, Category Layout Sections)

พร้อมทั้งกำจัดจุดรั่วไหลของข้อมูลสูตรสินค้า (`bomItems` และราคาต้นทุน `rawMaterial`) ที่เคยหลุดออกไปใน Public Catalog Query (`GET /products` และ `GET /products/:id`)

---

### 2. Root Cause & Technical Findings

#### 2.1 Elysia Lifecycle Plugin Scoping Flaw
- **ปัญหาเดิม:** Middleware `adminOnly` ถูกสร้างด้วย `new Elysia({ name: 'admin-auth' }).onBeforeHandle(...)` โดยไม่มีออปชัน `{ as: 'scoped' }`
- **ผลกระทบ:** ใน Elysia.js v1.2+ หากไม่ระบุ `{ as: 'scoped' }` หรือ `{ as: 'global' }` ฮุก `onBeforeHandle` ที่ประกาศใน plugin ย่อยจะไม่ถูก propagate ไปยัง Parent Route เมื่อถูกเรียกใช้ผ่าน `.use(adminOnly)` ส่งผลให้ middleware ไม่ทำงานจริงในบาง Controller
- **การแก้ไข:** เพิ่ม `{ as: 'scoped' }` ใน `onBeforeHandle` ของ `apps/backend/src/middleware/auth.ts` และรวมศูนย์ `JWT_SECRET` ให้เป็น Single Source of Truth

#### 2.2 Business Confidentiality Leak in Public Queries
- **ปัญหาเดิม:** `GET /api/products` และ `GET /api/products/:id` ทำการ query `variants` โดย `include: { bomItems: { include: { rawMaterial: true } } }`
- **ผลกระทบ:** ผู้ใช้ทั่วไปหรือ Bot ที่เรียกดูหน้าร้าน สามารถมองเห็นสูตรผสม ปริมาณสารเคมี/วัตถุดิบ และราคาต้นทุนต่อหน่วย (`pricePerUnit`) ของสินค้าทุกรายการ
- **การแก้ไข:** ตัด `bomItems` ออกจาก Public Queries ทั้งหมด ข้อมูล BOM จะเปิดให้เข้าถึงเฉพาะผ่าน `GET /api/products/:id/bom` และ `GET /api/products/bom-status` ซึ่งถูกครอบด้วย `adminOnly` เท่านั้น

---

### 3. Comprehensive Security Matrix

| Module | Route / Endpoint | Method | Previous State | New Protected State | Justification |
| :--- | :--- | :---: | :---: | :---: | :--- |
| **Products** | `/api/products` | GET | Public (leaked BOM) | **Public (Clean)** | หน้าร้านใช้แสดงสินค้า (ตัดข้อมูลต้นทุน BOM ออก) |
| | `/api/products/brands` | GET | Public | **Public** | ตัวกรองแบรนด์สำหรับหน้าร้าน |
| | `/api/products/:id` | GET | Public (leaked BOM) | **Public (Clean)** | หน้ารายละเอียดสินค้า (ตัดข้อมูลต้นทุน BOM ออก) |
| | `/api/products/:id/detail-sections` | GET | Public | **Public** | CMS Page Builder สำหรับ Product Page |
| | `/api/products/bom-status` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการสถานะสูตร BOM และต้นทุนรวมของทุกล็อต |
| | `/api/products/:id/bom` | GET, PUT | ⚠️ Public | 🔒 **Admin Only** | ความลับทางการค้า/สูตรผสมของสินค้า |
| | `/api/products` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการสร้างสินค้าโดยไม่ได้รับอนุญาต |
| | `/api/products/:id` | PUT, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการแก้ไข/ลบสินค้า |
| | `/api/products/bulk`, `/bulk-delete` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการโจมตี Mass Write/Delete |
| | `/api/products/brands` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแบรนด์สินค้า |
| | `/api/products/:id/images` | POST | ⚠️ Public | 🔒 **Admin Only** | เพิ่มรูปภาพสินค้า |
| | `/api/products/:id/detail-sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | แก้ไข Section ในหน้ารายละเอียดสินค้า |
| **Categories** | `/api/categories` | GET | Public | **Public** | แสดงหมวดหมู่สินค้าหน้าร้าน |
| | `/api/categories/:id` | GET | Public | **Public** | รายละเอียดหมวดหมู่ |
| | `/api/categories/:id/products` | GET | Public | **Public** | แสดงรายการสินค้าในหมวดหมู่ |
| | `/api/categories/:id/sections` | GET | Public | **Public** | Category Page Builder read |
| | `/api/categories/bulk-import` | POST | ⚠️ Public | 🔒 **Admin Only** | นำเข้าหมวดหมู่จำนวนมาก |
| | `/api/categories` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการหมวดหมู่สินค้า |
| | `/api/categories/:id/media`, `/image` | PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการรูปภาพหมวดหมู่ |
| | `/api/categories/:id/sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการ Section ของหมวดหมู่ |
| **Financial & MRP** | `/api/profitability/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | กำไร-ขาดทุน, Margin, ค่าธรรมเนียม, MRP 30 วัน |
| | `/api/raw-materials/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | สารเคมี, สต็อกวัตถุดิบ, ราคาต้นทุนต่อหน่วย |
| | `/api/business-analytics/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | แผนที่การเงิน Omnichannel, Repeat Analytics |
| **CRM & Customers** | `/api/crm/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | Customer RFM Scores, Churn Risk, VIP Lists |
| | `/api/customers` | GET, POST | ⚠️ Public | 🔒 **Admin Only** | รายชื่อลูกค้า, ที่อยู่, เบอร์โทรศัพท์, การปรับแต้ม |
| | `/api/contacts` | GET, PATCH | ⚠️ Public | 🔒 **Admin Only** | ข้อความติดต่อจากลูกค้าหน้าร้าน (PII) |
| | `/api/contacts` | POST | Public | **Public** | ฟอร์มติดต่อเราสำหรับผู้เยี่ยมชม |
| | `/api/newsletter/subscribers` | GET | ⚠️ Public | 🔒 **Admin Only** | รายชื่ออีเมลผู้ติดตาม Newsletter |
| | `/api/newsletter/subscribe` | POST | Public | **Public** | ฟอร์มสมัครรับจดหมายข่าว |
| **Orders & Sales** | `/api/orders` | POST, GET /:id, PATCH /:id/slip | Public | **Public** | ลูกค้าสั่งซื้อ, อัปโหลดสลิป, ดูคำสั่งซื้อของตน |
| | `/api/orders/track` | POST | Public | **Public** | ติดตามพัสดุ (ต้องกรอก OrderId + เบอร์โทรตรงกัน) |
| | `/api/orders` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการคำสั่งซื้อทั้งหมด |
| | `/api/orders/:id/status`, `/tracking` | PATCH | ⚠️ Public | 🔒 **Admin Only** | เปลี่ยนสถานะคำสั่งซื้อ, ใส่เลข Tracking |
| | `/api/orders/customer/:customerId` | GET | ⚠️ Public | 🔒 **Admin Only** | ดูประวัติสั่งซื้อตาม Customer ID |
| | `/api/tiktok/**`, `/lazada-orders/**`, `/shopee/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | ซิงค์คำสั่งซื้อและค่าธรรมเนียม Marketplace |
| **Promotions & CMS** | `/api/coupons/active`, `/validate` | GET, POST | Public | **Public** | ลูกค้าใช้คูปองและตรวจสอบส่วนลด |
| | `/api/coupons` (Admin CRUD) | GET, POST, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | สร้าง/แก้ไข/ลบคูปอง |
| | `/api/promotions/active` | GET | Public | **Public** | แสดงโปรโมชั่นหน้าร้าน |
| | `/api/promotions` (Admin CRUD) | GET, POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแคมเปญโปรโมชั่น |
| | `/api/articles` | GET, GET /:slug, GET /id/:id | Public | **Public** | อ่านบทความบล็อกหน้าร้าน |
| | `/api/articles` (Mutations) | POST, PUT, DELETE, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการบทความและคำแปลภาษา |
| | `/api/media` | GET, DELETE | ⚠️ Public | 🔒 **Admin Only** | รายการ Media Library และการลบไฟล์ |
| | `/api/product-attributes/definitions/**` | POST, PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | การสร้างและแก้ไข Attribute Definitions |

---

### 4. Client Compatibility & Verification
- **Storefront & Website (`apps/website`, `apps/spa`):**
  - ไม่ได้รับผลกระทบใดๆ ทุก Route ที่จำเป็นสำหรับการเรนเดอร์ (Catalog, Detail, Reviews, Sections, Checkout, Tracking, Contact, Newsletter) ยังคงเป็น Public
  - Payload เบาลงและปลอดภัยขึ้นเนื่องจากไม่มีการส่ง `bomItems` ที่ไม่จำเป็น
- **Admin Panel (`apps/admin`):**
  - ไม่ได้รับผลกระทบใดๆ เนื่องจากระบบ API Client ของ Admin ส่ง `Authorization: Bearer <token>` อยู่แล้วในทุก Request
- **Automated Test Results:**
  - เพิ่ม Security Contract Test: `apps/backend/src/routes/auth-guard.contract.test.ts`
  - ผ่านการทดสอบทั้งหมด 95/95 tests (0 failures)
  - ทดสอบผ่าน Production Build (`tsc -b && vite build`) สมบูรณ์ 100%

---

### 5. OWASP Standards Compliance
- **A01:2021 – Broken Access Control:** แก้ไขช่องโหว่ Direct Object Reference และ Unprotected Administrative Interfaces โดยบังคับใช้ JWT Authentication ในทุกจุดสำคัญ
- **A04:2021 – Insecure Design:** แก้ไขการรั่วไหลของข้อมูลสูตรสินค้าและต้นทุนใน Public Response ด้วย Least Privilege Data Exposure Model

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #15
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 23:56:00 +00:00
antigravity-bot BotandYour Name b27afb4561 feat(website): implement PDPA cookie consent banner and script manager (#14)
### Overview
Implement PDPA & GDPR compliant Cookie Consent Management Platform (CMP) for pps/website:
- **Zustand Store (cookieConsentStore.ts):** Stores consent record (
ecessary, nalytics, marketing) in localStorage with skipHydration: true for Next.js SSR safety.
- **Cookie Banner (CookieBanner.tsx):** Fixed bottom floating banner with Quiet Luxury styling, providing Accept All, Reject Non-Essential, and Customize buttons.
- **Preferences Modal (CookiePreferencesModal.tsx):** Detailed modal dialog allowing users to granularly toggle Analytics and Marketing cookies (Strictly Necessary is locked).
- **Script Manager (ScriptManager.tsx):** Coordinates Google Consent Mode v2 (nalytics_storage, d_storage), conditionally injects GA4, Meta Pixel, and TikTok Pixel, and dispatches homaroma:consent-change DOM events.
- **Footer Revocation Link:** Added Cookie Preferences link in the Support column of Footer.tsx so users can modify/revoke consent anytime.
- **Multilingual Support:** Added localized text for TH, EN, JA, and ZH.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #14
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 22:51:47 +00:00
33 changed files with 2287 additions and 605 deletions

No files matched your search

+1 -1
View File
@@ -37,9 +37,9 @@ import { categorySectionsRoute } from "./routes/categorySections";
import { llmPromptsRoute } from "./routes/llmPrompts"; import { llmPromptsRoute } from "./routes/llmPrompts";
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences"; import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
import { profitabilityRoute } from "./routes/profitability"; import { profitabilityRoute } from "./routes/profitability";
import { JWT_SECRET } from "./middleware/auth";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
const isDevelopment = process.env.NODE_ENV !== "production"; const isDevelopment = process.env.NODE_ENV !== "production";
const isPrivateLanHostname = (hostname: string) => { const isPrivateLanHostname = (hostname: string) => {
+12 -2
View File
@@ -44,7 +44,7 @@ export function resolveDefinitionLocalizedField<T extends { langCode: string; la
export function getPrimaryOptionValue(value: any): string { export function getPrimaryOptionValue(value: any): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
return value.th || value.en || Object.values(value)[0] || ''; return value.value || value.key || value.id || value.th || value.en || Object.values(value)[0] || '';
} }
return String(value ?? ''); return String(value ?? '');
} }
@@ -52,7 +52,17 @@ export function getPrimaryOptionValue(value: any): string {
export function getLocalizedOptionLabel(value: any, lang: string): string { export function getLocalizedOptionLabel(value: any, lang: string): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
return value[lang] || value[lang.slice(0, 2)] || value.th || value.en || Object.values(value)[0] || ''; return (
value[`label_${lang}`] ||
value[`label_${lang.slice(0, 2)}`] ||
value[lang] ||
value[lang.slice(0, 2)] ||
value.label ||
value.th ||
value.en ||
Object.values(value)[0] ||
''
);
} }
return String(value ?? ''); return String(value ?? '');
} }
+4 -2
View File
@@ -1,6 +1,8 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
export const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
export async function requireAdmin({ jwt, headers, set }: any) { export async function requireAdmin({ jwt, headers, set }: any) {
const auth = headers.authorization; const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) { if (!auth?.startsWith("Bearer ")) {
@@ -26,7 +28,7 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "fallback-secret" secret: JWT_SECRET,
}) })
) )
.onBeforeHandle(requireAdmin); .onBeforeHandle({ as: "scoped" }, requireAdmin);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const articlesRoute = new Elysia({ prefix: "/articles" }) export const articlesRoute = new Elysia({ prefix: "/articles" })
.get("/", async ({ query, set }) => { .get("/", async ({ query, set }) => {
@@ -46,6 +47,7 @@ export const articlesRoute = new Elysia({ prefix: "/articles" })
return { error: "Failed to fetch article" }; return { error: "Failed to fetch article" };
} }
}) })
.use(adminOnly)
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try { try {
const data = body as any; const data = body as any;
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { productsRoute } from "./products";
import { categoriesRoute } from "./categories";
import { categorySectionsRoute } from "./categorySections";
import { contactsRoute } from "./contacts";
import { newsletterRoute } from "./newsletter";
import { mediaRoute } from "./media";
import { JWT_SECRET } from "../middleware/auth";
import { jwt } from "@elysiajs/jwt";
import { Elysia } from "elysia";
const signer = new Elysia().use(jwt({ name: "jwt", secret: JWT_SECRET }));
async function req(app: any, path: string, method: string = "GET", body?: any, token?: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
return await app.handle(
new Request(`http://localhost${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
);
}
describe("Backend Auth Guard Security Enforcement", () => {
let adminToken: string;
test("generate admin token for testing", async () => {
adminToken = await (signer as any).decorator.jwt.sign({ role: "ADMIN" });
expect(adminToken).toBeDefined();
});
describe("Products routes", () => {
test("GET /products/brands is public", async () => {
const res = await req(productsRoute, "/products/brands");
expect(res.status).toBe(200);
});
test("GET /products/bom-status rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/bom-status");
expect(res.status).toBe(401);
const json = await res.json();
expect(json.error).toContain("Unauthorized");
});
test("GET /products/bom-status allows authenticated admin", async () => {
const res = await req(productsRoute, "/products/bom-status", "GET", undefined, adminToken);
expect(res.status).toBe(200);
});
test("POST /products rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /products/brands rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/brands", "POST", { name: "TestBrand" });
expect(res.status).toBe(401);
});
});
describe("Categories routes", () => {
test("GET /categories is public", async () => {
const res = await req(categoriesRoute, "/categories");
expect(res.status).toBe(200);
});
test("POST /categories rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /categories/bulk-import rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories/bulk-import", "POST", []);
expect(res.status).toBe(401);
});
});
describe("Category Sections routes", () => {
test("POST /categories/1/sections/seed-default rejects unauthenticated with 401", async () => {
const res = await req(categorySectionsRoute, "/categories/1/sections/seed-default", "POST");
expect(res.status).toBe(401);
});
});
describe("Contacts routes", () => {
test("GET /contacts rejects unauthenticated request with 401", async () => {
const res = await req(contactsRoute, "/contacts");
expect(res.status).toBe(401);
});
});
describe("Newsletter routes", () => {
test("GET /newsletter/subscribers rejects unauthenticated request with 401", async () => {
const res = await req(newsletterRoute, "/newsletter/subscribers");
expect(res.status).toBe(401);
});
});
describe("Media routes", () => {
test("GET /media rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media");
expect(res.status).toBe(401);
});
test("DELETE /media/1 rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media/1", "DELETE");
expect(res.status).toBe(401);
});
});
});
+2 -1
View File
@@ -2,12 +2,13 @@ import { Elysia, t } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { JWT_SECRET } from "../middleware/auth";
export const authRoute = new Elysia({ prefix: "/auth" }) export const authRoute = new Elysia({ prefix: "/auth" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent", secret: JWT_SECRET,
exp: "24h", exp: "24h",
}) })
) )
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces";
function extractShopeeAddress(order: any): { province: string; district: string } { function extractShopeeAddress(order: any): { province: string; district: string } {
@@ -53,6 +54,7 @@ function buildStatusFilter(statusFilter: string = "completed") {
} }
export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" })
.use(adminOnly)
// ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ──────────
.get("/sales-map", async ({ query }) => { .get("/sales-map", async ({ query }) => {
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
+122 -117
View File
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeCategories } from "../lib/catalogMaintenance"; import { purgeCategories } from "../lib/catalogMaintenance";
import { adminOnly } from "../middleware/auth";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const; const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
@@ -118,120 +119,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: resolveCatName(cat, lang), name: resolveCatName(cat, lang),
})); }));
}) })
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.get("/:id", async ({ params, query, status }) => { .get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
const lang = (query as any).lang || 'all'; const lang = (query as any).lang || 'all';
@@ -361,6 +248,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
descObj[t.langCode] = t.description; descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details; detailsObj[t.langCode] = t.details;
}); });
if (!nameObj.th && p.name) nameObj.th = p.name;
if (!descObj.th && p.description) descObj.th = p.description;
if (!detailsObj.th && p.details) detailsObj.th = p.details;
return { return {
...p, ...p,
name: nameObj, name: nameObj,
@@ -372,9 +262,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0]; const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return { return {
...p, ...p,
name: trans?.name || p.brand?.name || '', name: trans?.name || p.name || p.brand?.name || '',
description: trans?.description, description: trans?.description || p.description,
details: trans?.details, details: trans?.details || p.details,
category: { ...p.category, name: resolveCatName(p.category, lang) }, category: { ...p.category, name: resolveCatName(p.category, lang) },
}; };
} }
@@ -387,6 +277,121 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
}, },
}; };
}) })
.use(adminOnly)
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.patch("/:id/translations", async ({ params, body, status }) => { .patch("/:id/translations", async ({ params, body, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
@@ -7,6 +7,7 @@ import {
validateCategorySectionPayload, validateCategorySectionPayload,
} from "../lib/categorySections"; } from "../lib/categorySections";
import { hydrateSectionPayload } from "./pages"; import { hydrateSectionPayload } from "./pages";
import { adminOnly } from "../middleware/auth";
function summarizeValidationIssues(issues: any[]) { function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({ return issues.map((issue) => ({
@@ -43,6 +44,8 @@ export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
); );
}) })
.use(adminOnly)
// Seed default sections // Seed default sections
.post("/:id/sections/seed-default", async ({ params, status }) => { .post("/:id/sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma.js"; import prisma from "../lib/prisma.js";
import { adminOnly } from "../middleware/auth";
export const contactsRoute = new Elysia({ prefix: "/contacts" }) export const contactsRoute = new Elysia({ prefix: "/contacts" })
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
@@ -27,6 +28,7 @@ export const contactsRoute = new Elysia({ prefix: "/contacts" })
message: t.String({ minLength: 1 }) message: t.String({ minLength: 1 })
}) })
}) })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
return await prisma.contactMessage.findMany({ return await prisma.contactMessage.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+106 -100
View File
@@ -1,13 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const couponsRoute = new Elysia({ prefix: "/coupons" }) export const couponsRoute = new Elysia({ prefix: "/coupons" })
// Get all coupons (Admin) // ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/", async () => {
return await prisma.coupon.findMany({
orderBy: { createdAt: 'desc' }
});
})
// Get active coupons (Storefront) // Get active coupons (Storefront)
.get("/active", async () => { .get("/active", async () => {
const now = new Date(); const now = new Date();
@@ -22,98 +18,7 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
}) })
// Create new coupon (Admin)
.post("/", async ({ body, set }) => {
try {
const data = body as any;
// Check if code already exists
const existing = await prisma.coupon.findUnique({
where: { code: data.code.toUpperCase() }
});
if (existing) {
set.status = 400;
return { error: "Coupon code already exists" };
}
const coupon = await prisma.coupon.create({
data: {
code: data.code.toUpperCase(),
type: data.type,
value: data.value,
minPurchase: data.minPurchase || 0,
maxDiscount: data.maxDiscount || null,
usageLimit: data.usageLimit || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Create Error:", err);
set.status = 500;
return { error: "Failed to create coupon" };
}
}, {
body: t.Object({
code: t.String(),
type: t.String(),
value: t.Number(),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Update coupon (Admin)
.patch("/:id", async ({ params: { id }, body, set }) => {
try {
const data = body as any;
const coupon = await prisma.coupon.update({
where: { id: Number(id) },
data: {
...(data.code && { code: data.code.toUpperCase() }),
...(data.type && { type: data.type }),
...(data.value !== undefined && { value: data.value }),
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
...(data.isActive !== undefined && { isActive: data.isActive })
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Update Error:", err);
set.status = 500;
return { error: "Failed to update coupon" };
}
}, {
body: t.Object({
code: t.Optional(t.String()),
type: t.Optional(t.String()),
value: t.Optional(t.Number()),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Delete coupon (Admin)
.delete("/:id", async ({ params: { id }, set }) => {
try {
await prisma.coupon.delete({
where: { id: Number(id) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete coupon" };
}
})
// Validate coupon (Storefront) // Validate coupon (Storefront)
.post("/validate", async ({ body, set }) => { .post("/validate", async ({ body, set }) => {
try { try {
@@ -159,7 +64,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
} else if (coupon.type === "FIXED_AMOUNT") { } else if (coupon.type === "FIXED_AMOUNT") {
discountAmount = coupon.value; discountAmount = coupon.value;
} else if (coupon.type === "FREE_SHIPPING") { } else if (coupon.type === "FREE_SHIPPING") {
// We handle free shipping separately, returning discountAmount = 0 but a flag
return { return {
valid: true, valid: true,
coupon, coupon,
@@ -168,7 +72,6 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
}; };
} }
// Ensure discount doesn't exceed cart total
if (discountAmount > cartTotal) { if (discountAmount > cartTotal) {
discountAmount = cartTotal; discountAmount = cartTotal;
} }
@@ -190,4 +93,107 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
code: t.String(), code: t.String(),
cartTotal: t.Number() cartTotal: t.Number()
}) })
}); })
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
// Get all coupons (Admin)
.get("/", async () => {
return await prisma.coupon.findMany({
orderBy: { createdAt: 'desc' }
});
})
// Create new coupon (Admin)
.post("/", async ({ body, set }) => {
try {
const data = body as any;
const existing = await prisma.coupon.findUnique({
where: { code: data.code.toUpperCase() }
});
if (existing) {
set.status = 400;
return { error: "Coupon code already exists" };
}
const coupon = await prisma.coupon.create({
data: {
code: data.code.toUpperCase(),
type: data.type,
value: data.value,
minPurchase: data.minPurchase || 0,
maxDiscount: data.maxDiscount || null,
usageLimit: data.usageLimit || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Create Error:", err);
set.status = 500;
return { error: "Failed to create coupon" };
}
}, {
body: t.Object({
code: t.String(),
type: t.String(),
value: t.Number(),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Update coupon (Admin)
.patch("/:id", async ({ params: { id }, body, set }) => {
try {
const data = body as any;
const coupon = await prisma.coupon.update({
where: { id: Number(id) },
data: {
...(data.code && { code: data.code.toUpperCase() }),
...(data.type && { type: data.type }),
...(data.value !== undefined && { value: data.value }),
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
...(data.isActive !== undefined && { isActive: data.isActive })
}
});
return coupon;
} catch (err) {
console.error("[Coupons] Update Error:", err);
set.status = 500;
return { error: "Failed to update coupon" };
}
}, {
body: t.Object({
code: t.Optional(t.String()),
type: t.Optional(t.String()),
value: t.Optional(t.Number()),
minPurchase: t.Optional(t.Number()),
maxDiscount: t.Optional(t.Nullable(t.Number())),
usageLimit: t.Optional(t.Nullable(t.Number())),
expiresAt: t.Optional(t.Nullable(t.String())),
isActive: t.Optional(t.Boolean())
})
})
// Delete coupon (Admin)
.delete("/:id", async ({ params: { id }, set }) => {
try {
await prisma.coupon.delete({
where: { id: Number(id) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete coupon" };
}
})
);
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const crmRoute = new Elysia({ prefix: "/crm" }) export const crmRoute = new Elysia({ prefix: "/crm" })
.use(adminOnly)
// ── 1. Import Single / Multiple Customers ───────────────────────────────── // ── 1. Import Single / Multiple Customers ─────────────────────────────────
.post( .post(
"/import", "/import",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const customersRoute = new Elysia({ prefix: "/customers" }) export const customersRoute = new Elysia({ prefix: "/customers" })
.use(adminOnly)
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const search = query.search as string | undefined; const search = query.search as string | undefined;
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
const toNum = (val: any): number => { const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0; if (val === null || val === undefined || val === "") return 0;
@@ -26,6 +27,7 @@ const parseLazadaDate = (val: any): string => {
}; };
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" }) export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
.use(adminOnly)
// ── Import (Excel rows as JSON) ─────────────────────────────────────────── // ── Import (Excel rows as JSON) ───────────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const mediaRoute = new Elysia({ prefix: "/media" }) export const mediaRoute = new Elysia({ prefix: "/media" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const media = await prisma.media.findMany({ const media = await prisma.media.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const newsletterRoute = new Elysia({ prefix: "/newsletter" }) export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
.post("/subscribe", async ({ body, set }) => { .post("/subscribe", async ({ body, set }) => {
@@ -29,6 +30,7 @@ export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
email: t.String({ format: "email" }) email: t.String({ format: "email" })
}) })
}) })
.use(adminOnly)
.get("/subscribers", async () => { .get("/subscribers", async () => {
return await prisma.newsletterSubscriber.findMany({ return await prisma.newsletterSubscriber.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+88 -84
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
sendOrderCreatedNotification, sendOrderCreatedNotification,
sendPaymentConfirmedNotification, sendPaymentConfirmedNotification,
@@ -9,12 +10,6 @@ import {
} from "../lib/notifications"; } from "../lib/notifications";
export const ordersRoute = new Elysia({ prefix: "/orders" }) export const ordersRoute = new Elysia({ prefix: "/orders" })
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
});
})
.get("/:id", async ({ params: { id }, query, set }) => { .get("/:id", async ({ params: { id }, query, set }) => {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
@@ -245,54 +240,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
})) }))
}) })
}) })
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
// If status becomes CANCELLED, refund the points used and notify
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
// If status becomes SHIPPED, notify
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/slip", async ({ params: { id }, body, set }) => { .patch("/:id/slip", async ({ params: { id }, body, set }) => {
try { try {
const { paymentSlipUrl } = body as any; const { paymentSlipUrl } = body as any;
@@ -310,35 +257,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
paymentSlipUrl: t.String() paymentSlipUrl: t.String()
}) })
}) })
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
// Trigger notification asynchronously
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
.post("/track", async ({ body, set }) => { .post("/track", async ({ body, set }) => {
try { try {
const { orderId, phone } = body as any; const { orderId, phone } = body as any;
@@ -366,4 +284,90 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
orderId: t.Number(), orderId: t.Number(),
phone: t.String() phone: t.String()
}) })
}); })
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
});
})
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
);
-3
View File
@@ -1485,9 +1485,6 @@ async function resolveProductsForSection(payload: any, lang: string) {
brand: true, brand: true,
translations: true, translations: true,
variants: { variants: {
include: {
bomItems: { include: { rawMaterial: true } },
},
orderBy: { price: "asc" as const }, orderBy: { price: "asc" as const },
}, },
images: { images: {
+31 -29
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
SUPPORTED_LANGS, SUPPORTED_LANGS,
localizeAttributeDefinition, localizeAttributeDefinition,
@@ -158,6 +159,36 @@ export const productAttributesRoute = new Elysia()
return status(400, { message: err.message }); return status(400, { message: err.message });
} }
}) })
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) {
return status(400, { message: err.message });
}
})
.use(adminOnly)
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => { .post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
try { try {
const payload = body as any; const payload = body as any;
@@ -351,35 +382,6 @@ export const productAttributesRoute = new Elysia()
}, { }, {
body: t.Any(), body: t.Any(),
}) })
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) {
return status(400, { message: err.message });
}
})
.post("/products/:id/attributes", async ({ params, body, status }) => { .post("/products/:id/attributes", async ({ params, body, status }) => {
try { try {
const productId = parseId(params.id, "product ID"); const productId = parseId(params.id, "product ID");
+199 -203
View File
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeProducts } from "../lib/catalogMaintenance"; import { purgeProducts } from "../lib/catalogMaintenance";
import { localizeProductAttribute } from "../lib/productAttributes"; import { localizeProductAttribute } from "../lib/productAttributes";
import { adminOnly } from "../middleware/auth";
import { import {
createCustomProductDetailSection, createCustomProductDetailSection,
createProductMainDetailSection, createProductMainDetailSection,
@@ -224,6 +225,204 @@ export const productsRoute = new Elysia({ prefix: "/products" })
} }
}); });
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.use(adminOnly)
.post("/brands", async ({ body, status }) => { .post("/brands", async ({ body, status }) => {
const payload = body as any; const payload = body as any;
const name = payload.name?.trim(); const name = payload.name?.trim();
@@ -315,122 +514,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, translations: results, brand: updated }; return { success: true, translations: results, brand: updated };
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/bom-status", async ({ query }) => { .get("/bom-status", async ({ query }) => {
const search = query.search?.trim(); const search = query.search?.trim();
const filterStatus = query.status?.trim() || "all"; const filterStatus = query.status?.trim() || "all";
@@ -520,74 +603,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
products: filtered, products: filtered,
}; };
}) })
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.get("/:id/bom", async ({ params, status }) => { .get("/:id/bom", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
@@ -845,25 +860,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined, unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
}; };
}) })
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.post("/:id/detail-sections/seed-default", async ({ params, status }) => { .post("/:id/detail-sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
// ── Date Range Helper ──────────────────────────────────────────────────────── // ── Date Range Helper ────────────────────────────────────────────────────────
interface DateRangeInfo { interface DateRangeInfo {
@@ -70,6 +71,7 @@ const toNum = (val: any): number => {
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
.use(adminOnly)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 1. Cost & Profitability Matrix (Catalog-Level Baseline) // 1. Cost & Profitability Matrix (Catalog-Level Baseline)
+68 -60
View File
@@ -1,9 +1,11 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const promotionsRoute = new Elysia({ prefix: "/promotions" }) export const promotionsRoute = new Elysia({ prefix: "/promotions" })
// ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/active", async () => { .get("/active", async () => {
const now = new Date() const now = new Date();
return await prisma.promotion.findMany({ return await prisma.promotion.findMany({
where: { where: {
isActive: true, isActive: true,
@@ -18,62 +20,68 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
orderBy: { level: 'asc' } orderBy: { level: 'asc' }
}); });
}) })
.get("/", async () => {
return await prisma.promotion.findMany({ // ── Protected Admin Endpoints ─────────────────────────────────────────────
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }] .guard((app) =>
}); app
}) .use(adminOnly)
.post("/", async ({ body, set }) => { .get("/", async () => {
try { return await prisma.promotion.findMany({
const data = body as any; orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
return await prisma.promotion.create({ data }); });
} catch (err) { })
set.status = 500; .post("/", async ({ body, set }) => {
return { error: "Failed to create promotion" }; try {
} const data = body as any;
}, { return await prisma.promotion.create({ data });
body: t.Object({ } catch (err) {
name: t.String(), set.status = 500;
level: t.Number(), return { error: "Failed to create promotion" };
type: t.String(), }
value: t.Number(), }, {
minPurchase: t.Number(), body: t.Object({
isActive: t.Boolean(), name: t.String(),
startDate: t.Optional(t.Nullable(t.String())), level: t.Number(),
endDate: t.Optional(t.Nullable(t.String())) type: t.String(),
}) value: t.Number(),
}) minPurchase: t.Number(),
.put("/:id", async ({ params: { id }, body, set }) => { isActive: t.Boolean(),
try { startDate: t.Optional(t.Nullable(t.String())),
const data = body as any; endDate: t.Optional(t.Nullable(t.String()))
return await prisma.promotion.update({ })
where: { id: Number(id) }, })
data .put("/:id", async ({ params: { id }, body, set }) => {
}); try {
} catch (err) { const data = body as any;
set.status = 500; return await prisma.promotion.update({
return { error: "Failed to update promotion" }; where: { id: Number(id) },
} data
}, { });
body: t.Object({ } catch (err) {
name: t.String(), set.status = 500;
level: t.Number(), return { error: "Failed to update promotion" };
type: t.String(), }
value: t.Number(), }, {
minPurchase: t.Number(), body: t.Object({
isActive: t.Boolean(), name: t.String(),
startDate: t.Optional(t.Nullable(t.String())), level: t.Number(),
endDate: t.Optional(t.Nullable(t.String())) type: t.String(),
}) value: t.Number(),
}) minPurchase: t.Number(),
.delete("/:id", async ({ params: { id }, set }) => { isActive: t.Boolean(),
try { startDate: t.Optional(t.Nullable(t.String())),
await prisma.promotion.delete({ endDate: t.Optional(t.Nullable(t.String()))
where: { id: Number(id) } })
}); })
return { success: true }; .delete("/:id", async ({ params: { id }, set }) => {
} catch (err) { try {
set.status = 500; await prisma.promotion.delete({
return { error: "Failed to delete promotion" }; where: { id: Number(id) }
} });
}); return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete promotion" };
}
})
);
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" }) export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const rawMaterials = await prisma.rawMaterial.findMany({ const rawMaterials = await prisma.rawMaterial.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -1,21 +1,41 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { settingsRoute } from './settings' import { settingsRoute } from './settings'
import { JWT_SECRET } from '../middleware/auth'
import { jwt } from '@elysiajs/jwt'
import { Elysia } from 'elysia'
function jsonRequest(path: string, method: string, body: unknown) { const signer = new Elysia().use(jwt({ name: 'jwt', secret: JWT_SECRET }))
function jsonRequest(path: string, method: string, body: unknown, token?: string) {
return new Request(`http://localhost${path}`, { return new Request(`http://localhost${path}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
describe('footer layout route boundaries', () => { describe('footer layout route boundaries', () => {
for (const method of ['PUT', 'POST']) { for (const method of ['PUT', 'POST']) {
test(`${method} /settings rejects footerLayout writes`, async () => { test(`${method} /settings requires admin authentication`, async () => {
const response = await settingsRoute.handle(jsonRequest('/settings', method, { const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] }, footerLayout: { columns: ['brand'] },
})) }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
})
test(`${method} /settings with admin token rejects footerLayout writes`, async () => {
const adminToken = await (signer as any).decorator.jwt.sign({ role: 'ADMIN' })
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] },
}, adminToken))
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
error: 'Use PATCH /settings/footer-layout to update footer layout', error: 'Use PATCH /settings/footer-layout to update footer layout',
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -17,6 +18,7 @@ function extractShopeeAddress(order: any): { province: string; district: string
} }
export const shopeeRoute = new Elysia({ prefix: "/shopee" }) export const shopeeRoute = new Elysia({ prefix: "/shopee" })
.use(adminOnly)
// ── Import Orders ───────────────────────────────────────────────────────── // ── Import Orders ─────────────────────────────────────────────────────────
.post( .post(
"/import/orders", "/import/orders",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const tiktokRoute = new Elysia({ prefix: "/tiktok" }) export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
.use(adminOnly)
.post( .post(
"/import/income", "/import/income",
async ({ body, set }) => { async ({ body, set }) => {
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -234,6 +235,7 @@ async function enrichOrdersWithBOM(orders: any[], shop: string) {
} }
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
.use(adminOnly)
// ── Import Orders (CSV rows as JSON) ─────────────────────────────────────── // ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const toolsRoute = new Elysia({ prefix: "/tools" }) export const toolsRoute = new Elysia({ prefix: "/tools" })
.use(adminOnly)
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ──────────── // ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
.get("/marketplace-products/master-variants", async () => { .get("/marketplace-products/master-variants", async () => {
+324
View File
@@ -0,0 +1,324 @@
[
{
"code": "scent_family",
"label": "กลุ่มโทนกลิ่น",
"description": "การจัดกลุ่มโทนกลิ่นหลักของเครื่องหอม เพื่อให้ค้นหาตามสไตล์กลิ่นที่ชอบได้ง่าย",
"type": "SELECT",
"unit": null,
"options": [
{
"value": "floral",
"label": "Floral (ดอกไม้)",
"label_th": "กลิ่นแนวดอกไม้",
"label_en": "Floral"
},
{
"value": "fresh_herbal",
"label": "Fresh & Herbal (สดชื่น/สมุนไพร)",
"label_th": "กลิ่นสดชื่นและสมุนไพร",
"label_en": "Fresh & Herbal"
},
{
"value": "fruity",
"label": "Fruity (ผลไม้)",
"label_th": "กลิ่นแนวผลไม้",
"label_en": "Fruity"
},
{
"value": "spa_relaxing",
"label": "Spa & Relaxing (สปา/ผ่อนคลาย)",
"label_th": "กลิ่นแนวสปาและผ่อนคลาย",
"label_en": "Spa & Relaxing"
},
{
"value": "sweet_warm",
"label": "Sweet & Warm (หวานละมุน/อบอุ่น)",
"label_th": "กลิ่นแนวหวานละมุนและอบอุ่น",
"label_en": "Sweet & Warm"
},
{
"value": "aquatic_ozone",
"label": "Aquatic & Ozone (อากาศ/ทะเล)",
"label_th": "กลิ่นแนวปลอดโปร่งและทะเล",
"label_en": "Aquatic & Ozone"
}
],
"isRequired": false,
"isFilterable": true,
"sortOrder": 1,
"categoryId": null,
"translations": {
"th": {
"label": "กลุ่มโทนกลิ่น",
"description": "การจัดกลุ่มโทนกลิ่นหลักของเครื่องหอม"
},
"en": {
"label": "Fragrance Family",
"description": "Primary olfactory classification for fragrances"
}
}
},
{
"code": "mood_benefit",
"label": "อารมณ์และคุณประโยชน์",
"description": "ความรู้สึกหรือบรรยากาศที่ได้รับจากกลิ่นหอมนี้",
"type": "SELECT",
"unit": null,
"options": [
{
"value": "relax_sleep",
"label": "Relax & Sleep (ผ่อนคลาย หลับสบาย)",
"label_th": "ผ่อนคลาย หลับสบาย",
"label_en": "Relax & Sleep"
},
{
"value": "refresh_energize",
"label": "Refresh & Energize (สดชื่น เติมพลัง)",
"label_th": "สดชื่น เติมพลัง",
"label_en": "Refresh & Energize"
},
{
"value": "focus_mind",
"label": "Focus & Mind (สร้างสมาธิ กระตุ้นไอเดีย)",
"label_th": "สร้างสมาธิ กระตุ้นไอเดีย",
"label_en": "Focus & Mind"
},
{
"value": "odor_eliminator",
"label": "Odor Eliminator (ดับกลิ่นอับ ปรับอากาศ)",
"label_th": "ดับกลิ่นอับ ปรับอากาศ",
"label_en": "Odor Eliminator"
},
{
"value": "romantic_cozy",
"label": "Romantic & Cozy (อบอุ่น โรแมนติก)",
"label_th": "อบอุ่น โรแมนติก",
"label_en": "Romantic & Cozy"
}
],
"isRequired": false,
"isFilterable": true,
"sortOrder": 2,
"categoryId": null,
"translations": {
"th": {
"label": "อารมณ์และคุณประโยชน์",
"description": "บรรยากาศหรือความรู้สึกที่ได้จากกลิ่น"
},
"en": {
"label": "Mood & Benefit",
"description": "Atmosphere and emotional benefits"
}
}
},
{
"code": "scent_intensity",
"label": "ระดับความเข้มข้นของกลิ่น",
"description": "ระดับการกระจายตัวและความชัดของกลิ่นหอม",
"type": "SELECT",
"unit": null,
"options": [
{
"value": "light",
"label": "Light / Subtle (หอมละมุน บางเบา)",
"label_th": "หอมละมุน บางเบา",
"label_en": "Light / Subtle"
},
{
"value": "medium",
"label": "Medium (หอมปานกลาง กำลังพอดี)",
"label_th": "หอมปานกลาง กำลังพอดี",
"label_en": "Medium"
},
{
"value": "strong",
"label": "Strong / Rich (หอมชัดเจน เข้มข้น)",
"label_th": "หอมชัดเจน เข้มข้น",
"label_en": "Strong / Rich"
}
],
"isRequired": false,
"isFilterable": true,
"sortOrder": 3,
"categoryId": null,
"translations": {
"th": {
"label": "ระดับความเข้มข้นของกลิ่น",
"description": "ระดับการกระจายตัวและความชัดของกลิ่น"
},
"en": {
"label": "Scent Intensity",
"description": "Diffusion strength and fragrance throw"
}
}
},
{
"code": "room_placement",
"label": "พื้นที่ใช้งานที่แนะนำ",
"description": "ห้องหรือบริเวณที่เหมาะสมที่สุดสำหรับการกระจายกลิ่น",
"type": "SELECT",
"unit": null,
"options": [
{
"value": "bedroom",
"label": "ห้องนอน (Bedroom)",
"label_th": "ห้องนอน",
"label_en": "Bedroom"
},
{
"value": "living_room",
"label": "ห้องนั่งเล่น / โถงกลาง (Living Room)",
"label_th": "ห้องนั่งเล่น / โถงกลาง",
"label_en": "Living Room"
},
{
"value": "workspace",
"label": "ห้องทำงาน / โต๊ะทำงาน (Workspace)",
"label_th": "ห้องทำงาน / โต๊ะทำงาน",
"label_en": "Workspace"
},
{
"value": "bathroom",
"label": "ห้องน้ำ (Bathroom)",
"label_th": "ห้องน้ำ",
"label_en": "Bathroom"
},
{
"value": "car",
"label": "ห้องโดยสารรถยนต์ (Car Interior)",
"label_th": "ห้องโดยสารรถยนต์",
"label_en": "Car Interior"
},
{
"value": "commercial_spa",
"label": "ร้านสปา / โรงแรม / คาเฟ่ (Commercial / Spa)",
"label_th": "ร้านสปา / โรงแรม / คาเฟ่",
"label_en": "Commercial / Spa"
}
],
"isRequired": false,
"isFilterable": true,
"sortOrder": 4,
"categoryId": null,
"translations": {
"th": {
"label": "พื้นที่ใช้งานที่แนะนำ",
"description": "ห้องหรือบริเวณที่เหมาะสมสำหรับการกระจายกลิ่น"
},
"en": {
"label": "Recommended Space",
"description": "Ideal placement areas for optimal aroma diffusion"
}
}
},
{
"code": "compatible_device",
"label": "อุปกรณ์ที่รองรับ",
"description": "ประเภทเครื่องพ่นหรืออุปกรณ์กระจายกลิ่นที่สามารถใช้ร่วมกันได้",
"type": "TEXT",
"unit": null,
"options": null,
"isRequired": false,
"isFilterable": false,
"sortOrder": 5,
"categoryId": null,
"translations": {
"th": {
"label": "อุปกรณ์ที่รองรับ",
"description": "อุปกรณ์หรือเครื่องพ่นที่สามารถใช้ร่วมกันได้"
},
"en": {
"label": "Compatible Devices",
"description": "Supported diffusers and diffusion equipment"
}
}
},
{
"code": "coverage_area",
"label": "ขนาดพื้นที่ครอบคลุม",
"description": "ขนาดพื้นที่ห้องที่กลิ่นสามารถกระจายตัวได้อย่างมีประสิทธิภาพ",
"type": "TEXT",
"unit": "ตร.ม.",
"options": null,
"isRequired": false,
"isFilterable": false,
"sortOrder": 6,
"categoryId": null,
"translations": {
"th": {
"label": "ขนาดพื้นที่ครอบคลุม",
"description": "ขนาดพื้นที่ห้องที่กลิ่นกระจายตัวได้ดี"
},
"en": {
"label": "Coverage Area",
"description": "Effective room coverage area in square meters"
}
}
},
{
"code": "scent_duration",
"label": "ระยะเวลาการใช้งาน",
"description": "อายุการใช้งานหรือระยะเวลาที่กลิ่นหอมกระจายตัวต่อเนื่อง",
"type": "TEXT",
"unit": null,
"options": null,
"isRequired": false,
"isFilterable": false,
"sortOrder": 7,
"categoryId": null,
"translations": {
"th": {
"label": "ระยะเวลาการใช้งาน",
"description": "ระยะเวลาที่กลิ่นกระจายตัวต่อเนื่อง"
},
"en": {
"label": "Estimated Duration",
"description": "Approximate lifespan and continuous scent duration"
}
}
},
{
"code": "formula_base",
"label": "ประเภทเบสและส่วนผสม",
"description": "ชนิดของเบสน้ำมันหรือส่วนประกอบในการกระจายกลิ่น",
"type": "TEXT",
"unit": null,
"options": null,
"isRequired": false,
"isFilterable": false,
"sortOrder": 8,
"categoryId": null,
"translations": {
"th": {
"label": "ประเภทเบสและส่วนผสม",
"description": "ชนิดของเบสที่ใช้ในการผสมและกระจายกลิ่น"
},
"en": {
"label": "Formula Base",
"description": "Base formula and carrier type"
}
}
},
{
"code": "usage_instructions",
"label": "วิธีใช้งานและคำแนะนำ",
"description": "ขั้นตอนการใช้งานที่ถูกต้องเพื่อประสิทธิภาพสูงสุดในการกระจายกลิ่น",
"type": "TEXT",
"unit": null,
"options": null,
"isRequired": false,
"isFilterable": false,
"sortOrder": 9,
"categoryId": null,
"translations": {
"th": {
"label": "วิธีใช้งานและคำแนะนำ",
"description": "ขั้นตอนการใช้งานเพื่อประสิทธิภาพสูงสุด"
},
"en": {
"label": "Usage Instructions",
"description": "Recommended steps for best diffusion experience"
}
}
}
]
+677
View File
@@ -0,0 +1,677 @@
[
{
"productId": 3,
"scentName": "ตะไคร้",
"scentFamily": "spa_relaxing",
"moodBenefit": "odor_eliminator",
"scentIntensity": "strong",
"roomPlacement": "bathroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 63,
"scentName": "ลีลาวดี",
"scentFamily": "spa_relaxing",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "commercial_spa",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 138,
"scentName": "ดอกบัว",
"scentFamily": "spa_relaxing",
"moodBenefit": "focus_mind",
"scentIntensity": "light",
"roomPlacement": "commercial_spa",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 148,
"scentName": "ไทยสปา",
"scentFamily": "spa_relaxing",
"moodBenefit": "relax_sleep",
"scentIntensity": "strong",
"roomPlacement": "commercial_spa",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 8,
"scentName": "ลาเวนเดอร์",
"scentFamily": "fresh_herbal",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 28,
"scentName": "ยูคาลิปตัส",
"scentFamily": "fresh_herbal",
"moodBenefit": "refresh_energize",
"scentIntensity": "strong",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 33,
"scentName": "เฟรชลาเวนเดอร์",
"scentFamily": "fresh_herbal",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 38,
"scentName": "เปเปอร์มิ้น",
"scentFamily": "fresh_herbal",
"moodBenefit": "refresh_energize",
"scentIntensity": "strong",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 73,
"scentName": "มะนาว",
"scentFamily": "fresh_herbal",
"moodBenefit": "odor_eliminator",
"scentIntensity": "strong",
"roomPlacement": "bathroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 98,
"scentName": "โรสเมรี่",
"scentFamily": "fresh_herbal",
"moodBenefit": "focus_mind",
"scentIntensity": "medium",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 123,
"scentName": "มะกรูด",
"scentFamily": "fresh_herbal",
"moodBenefit": "odor_eliminator",
"scentIntensity": "strong",
"roomPlacement": "bathroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 18,
"scentName": "ซากุระ",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "light",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 23,
"scentName": "มะลิ",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 53,
"scentName": "ดอกแก้ว",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "strong",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 58,
"scentName": "ดอกโมก",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 93,
"scentName": "ดอกลิลลี่",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 103,
"scentName": "อัญชัน",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "light",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 108,
"scentName": "กุหลาบแดง",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "strong",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 133,
"scentName": "ดอกปีบ",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 163,
"scentName": "ดอกชมนาด",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 173,
"scentName": "ดอกการะเวก",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 178,
"scentName": "ดอกจำปา",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 183,
"scentName": "ดอกพิกุล",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 188,
"scentName": "ดอกพุด",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "strong",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 193,
"scentName": "ดอกราตรี",
"scentFamily": "floral",
"moodBenefit": "romantic_cozy",
"scentIntensity": "strong",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 198,
"scentName": "ดอกลำดวน",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 213,
"scentName": "ดอกราชาวดี",
"scentFamily": "floral",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 68,
"scentName": "ส้ม",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 78,
"scentName": "แคนตาลูป",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 83,
"scentName": "แอปเปิ้ลแดง",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 88,
"scentName": "แอปเปิ้ลเขียว",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 118,
"scentName": "ผลไม้รวม",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 153,
"scentName": "กล้วย",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 203,
"scentName": "ทับทิม",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 208,
"scentName": "มะละกอสุก",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 218,
"scentName": "กลิ่นลูกพีช",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 223,
"scentName": "กลิ่นองุ่น",
"scentFamily": "fruity",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 13,
"scentName": "วานิลลา",
"scentFamily": "sweet_warm",
"moodBenefit": "romantic_cozy",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 113,
"scentName": "หมากฝรั่ง",
"scentFamily": "sweet_warm",
"moodBenefit": "refresh_energize",
"scentIntensity": "strong",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 128,
"scentName": "แซนด้าวูส",
"scentFamily": "sweet_warm",
"moodBenefit": "focus_mind",
"scentIntensity": "medium",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 158,
"scentName": "กาแฟ",
"scentFamily": "sweet_warm",
"moodBenefit": "focus_mind",
"scentIntensity": "strong",
"roomPlacement": "workspace",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 168,
"scentName": "กลิ่นน้ำนม",
"scentFamily": "sweet_warm",
"moodBenefit": "relax_sleep",
"scentIntensity": "medium",
"roomPlacement": "bedroom",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 43,
"scentName": "โอเชี่ยน",
"scentFamily": "aquatic_ozone",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 48,
"scentName": "เซเว่นซี",
"scentFamily": "aquatic_ozone",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
},
{
"productId": 143,
"scentName": "โอโซน",
"scentFamily": "aquatic_ozone",
"moodBenefit": "refresh_energize",
"scentIntensity": "medium",
"roomPlacement": "living_room",
"specs": {
"compatible_device": "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
"coverage_area": "15 - 25",
"scent_duration": "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
"formula_base": "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
"usage_instructions": "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง"
}
}
]
@@ -0,0 +1,75 @@
import * as fs from "fs";
import * as path from "path";
const API_BASE = process.env.API_BASE || "https://api.homaroma.shop/api";
const ADMIN_USER = process.env.ADMIN_USER || "admin";
const ADMIN_PASS = process.env.ADMIN_PASS || "homaroma123";
async function main() {
console.log(`=== Importing Attribute Definitions via API (${API_BASE}) ===`);
// 1. Read JSON definitions
const jsonPath = path.resolve(__dirname, "../data/homaroma_attribute_definitions.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`File not found: ${jsonPath}`);
}
const definitions = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
console.log(`✓ Loaded ${definitions.length} definitions from data/homaroma_attribute_definitions.json`);
// 2. Login to get JWT Token
console.log(`2. Logging in as '${ADMIN_USER}' to obtain JWT token...`);
const loginRes = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
});
if (!loginRes.ok) {
const errorText = await loginRes.text();
throw new Error(`Login failed (${loginRes.status}): ${errorText}`);
}
const loginData = (await loginRes.json()) as { success: boolean; token: string };
if (!loginData.token) {
throw new Error("No token returned from login API");
}
const token = loginData.token;
console.log("✓ Successfully authenticated. JWT token acquired.");
// 3. Post bulk-import
console.log("3. Sending bulk-import payload to /product-attributes/definitions/bulk-import...");
const importRes = await fetch(`${API_BASE}/product-attributes/definitions/bulk-import`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(definitions),
});
if (!importRes.ok) {
const errorText = await importRes.text();
throw new Error(`Bulk import failed (${importRes.status}): ${errorText}`);
}
const importResult = await importRes.json();
console.log("✓ Bulk import response:", JSON.stringify(importResult, null, 2));
// 4. Fetch all definitions to verify
console.log("4. Verifying active attribute definitions from API...");
const listRes = await fetch(`${API_BASE}/product-attributes/definitions?lang=th`);
const listData = await listRes.json();
console.log(`✓ Total active attribute definitions on API: ${Array.isArray(listData) ? listData.length : 'N/A'}`);
return importResult;
}
main()
.then(() => {
console.log("\n✅ Completed successfully.");
process.exit(0);
})
.catch((err) => {
console.error("\n❌ Error:", err.message || err);
process.exit(1);
});
+116
View File
@@ -0,0 +1,116 @@
let prisma: any;
try {
const mod = await import("../apps/backend/src/lib/prisma");
prisma = mod.default;
} catch {
const mod = await import("./src/lib/prisma");
prisma = mod.default;
}
interface RunOptions {
deleteOldBrands?: boolean;
}
export async function reassignProductsToHomAroma(options: RunOptions = {}) {
console.log("=== Reassign Products to 'Hom Aroma' Brand ===");
// 1. Find or create 'Hom Aroma' brand
let homAroma = await prisma.productBrand.findFirst({
where: { name: { equals: "Hom Aroma", mode: "insensitive" } },
});
if (!homAroma) {
homAroma = await prisma.productBrand.create({
data: { name: "Hom Aroma" },
});
console.log(`✓ Created 'Hom Aroma' brand with ID: ${homAroma.id}`);
} else {
console.log(`✓ Found existing 'Hom Aroma' brand with ID: ${homAroma.id}`);
}
// 2. Ensure translations for Hom Aroma
const translations = [
{ langCode: "th", name: "Hom Aroma" },
{ langCode: "en", name: "Hom Aroma" },
];
for (const tr of translations) {
await prisma.productBrandTranslation.upsert({
where: {
brandId_langCode: {
brandId: homAroma.id,
langCode: tr.langCode,
},
},
create: {
brandId: homAroma.id,
langCode: tr.langCode,
name: tr.name,
},
update: {
name: tr.name,
},
});
}
console.log("✓ Brand translations ensured for 'th' and 'en'.");
// 3. Reassign all products to Hom Aroma
const totalProducts = await prisma.product.count();
const updateResult = await prisma.product.updateMany({
data: {
brandId: homAroma.id,
},
});
console.log(`✓ Reassigned ${updateResult.count} of ${totalProducts} products to Brand '${homAroma.name}' (ID: ${homAroma.id}).`);
// 4. Handle old brands
const otherBrands = await prisma.productBrand.findMany({
where: { id: { not: homAroma.id } },
include: { _count: { select: { products: true } } },
});
console.log(`Found ${otherBrands.length} other brands in database.`);
if (options.deleteOldBrands && otherBrands.length > 0) {
console.log(`Deleting ${otherBrands.length} old scent brand records...`);
const delResult = await prisma.productBrand.deleteMany({
where: { id: { not: homAroma.id } },
});
console.log(`✓ Deleted ${delResult.count} old scent brands successfully.`);
} else if (!options.deleteOldBrands) {
console.log("Old brands preserved (deleteOldBrands is false).");
}
// 5. Final summary verification
const finalHomAromaProducts = await prisma.product.count({
where: { brandId: homAroma.id },
});
const remainingBrands = await prisma.productBrand.count();
console.log("\n=== Final Summary ===");
console.log(`Total Products linked to 'Hom Aroma': ${finalHomAromaProducts} / ${totalProducts}`);
console.log(`Total Brands remaining in database: ${remainingBrands}`);
return {
homAromaId: homAroma.id,
totalProducts,
reassignedCount: updateResult.count,
otherBrandsCount: otherBrands.length,
remainingBrandsCount: remainingBrands,
};
}
// Auto-run if executed directly
if (import.meta.main) {
const shouldDelete = process.argv.includes("--delete-old");
reassignProductsToHomAroma({ deleteOldBrands: shouldDelete })
.then((res) => {
console.log("\nExecution completed:", res);
process.exit(0);
})
.catch((err) => {
console.error("\nExecution failed:", err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
}
+296
View File
@@ -0,0 +1,296 @@
import * as fs from "fs";
import * as path from "path";
import prisma from "../apps/backend/src/lib/prisma";
const PROD_API = process.env.API_BASE || "https://api.homaroma.shop/api";
const ADMIN_USER = process.env.ADMIN_USER || "admin";
const ADMIN_PASS = process.env.ADMIN_PASS || "homaroma123";
interface ScentMapping {
productId: number;
scentName: string;
scentFamily: "floral" | "fresh_herbal" | "fruity" | "spa_relaxing" | "sweet_warm" | "aquatic_ozone";
moodBenefit: "relax_sleep" | "refresh_energize" | "focus_mind" | "odor_eliminator" | "romantic_cozy";
scentIntensity: "light" | "medium" | "strong";
roomPlacement: "bedroom" | "living_room" | "workspace" | "bathroom" | "car" | "commercial_spa";
}
// 45 Reed Diffuser products mapping
const REED_DIFFUSER_MAPPINGS: ScentMapping[] = [
// Spa & Relaxing
{ productId: 3, scentName: "ตะไคร้", scentFamily: "spa_relaxing", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
{ productId: 63, scentName: "ลีลาวดี", scentFamily: "spa_relaxing", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "commercial_spa" },
{ productId: 138, scentName: "ดอกบัว", scentFamily: "spa_relaxing", moodBenefit: "focus_mind", scentIntensity: "light", roomPlacement: "commercial_spa" },
{ productId: 148, scentName: "ไทยสปา", scentFamily: "spa_relaxing", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "commercial_spa" },
// Fresh & Herbal
{ productId: 8, scentName: "ลาเวนเดอร์", scentFamily: "fresh_herbal", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 28, scentName: "ยูคาลิปตัส", scentFamily: "fresh_herbal", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "workspace" },
{ productId: 33, scentName: "เฟรชลาเวนเดอร์", scentFamily: "fresh_herbal", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 38, scentName: "เปเปอร์มิ้น", scentFamily: "fresh_herbal", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "workspace" },
{ productId: 73, scentName: "มะนาว", scentFamily: "fresh_herbal", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
{ productId: 98, scentName: "โรสเมรี่", scentFamily: "fresh_herbal", moodBenefit: "focus_mind", scentIntensity: "medium", roomPlacement: "workspace" },
{ productId: 123, scentName: "มะกรูด", scentFamily: "fresh_herbal", moodBenefit: "odor_eliminator", scentIntensity: "strong", roomPlacement: "bathroom" },
// Floral
{ productId: 18, scentName: "ซากุระ", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "light", roomPlacement: "bedroom" },
{ productId: 23, scentName: "มะลิ", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 53, scentName: "ดอกแก้ว", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "bedroom" },
{ productId: 58, scentName: "ดอกโมก", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 93, scentName: "ดอกลิลลี่", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 103, scentName: "อัญชัน", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "light", roomPlacement: "bedroom" },
{ productId: 108, scentName: "กุหลาบแดง", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "bedroom" },
{ productId: 133, scentName: "ดอกปีบ", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 163, scentName: "ดอกชมนาด", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 173, scentName: "ดอกการะเวก", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 178, scentName: "ดอกจำปา", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 183, scentName: "ดอกพิกุล", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 188, scentName: "ดอกพุด", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "strong", roomPlacement: "bedroom" },
{ productId: 193, scentName: "ดอกราตรี", scentFamily: "floral", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "bedroom" },
{ productId: 198, scentName: "ดอกลำดวน", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
{ productId: 213, scentName: "ดอกราชาวดี", scentFamily: "floral", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
// Fruity
{ productId: 68, scentName: "ส้ม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 78, scentName: "แคนตาลูป", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 83, scentName: "แอปเปิ้ลแดง", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 88, scentName: "แอปเปิ้ลเขียว", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "workspace" },
{ productId: 118, scentName: "ผลไม้รวม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 153, scentName: "กล้วย", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 203, scentName: "ทับทิม", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 208, scentName: "มะละกอสุก", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 218, scentName: "กลิ่นลูกพีช", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 223, scentName: "กลิ่นองุ่น", scentFamily: "fruity", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
// Sweet & Warm
{ productId: 13, scentName: "วานิลลา", scentFamily: "sweet_warm", moodBenefit: "romantic_cozy", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 113, scentName: "หมากฝรั่ง", scentFamily: "sweet_warm", moodBenefit: "refresh_energize", scentIntensity: "strong", roomPlacement: "living_room" },
{ productId: 128, scentName: "แซนด้าวูส", scentFamily: "sweet_warm", moodBenefit: "focus_mind", scentIntensity: "medium", roomPlacement: "workspace" },
{ productId: 158, scentName: "กาแฟ", scentFamily: "sweet_warm", moodBenefit: "focus_mind", scentIntensity: "strong", roomPlacement: "workspace" },
{ productId: 168, scentName: "กลิ่นน้ำนม", scentFamily: "sweet_warm", moodBenefit: "relax_sleep", scentIntensity: "medium", roomPlacement: "bedroom" },
// Aquatic & Ozone
{ productId: 43, scentName: "โอเชี่ยน", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 48, scentName: "เซเว่นซี", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
{ productId: 143, scentName: "โอโซน", scentFamily: "aquatic_ozone", moodBenefit: "refresh_energize", scentIntensity: "medium", roomPlacement: "living_room" },
];
// Standard Reed Diffuser specifications common across all items
const REED_DIFFUSER_SPECS = {
compatible_device: "ก้านไม้ไฟเบอร์กระจายกลิ่น (Fiber Reeds) / ขวดแก้วตั้งโต๊ะ",
coverage_area: "15 - 25",
scent_duration: "3 - 4 สัปดาห์ (ขนาด 60ml), 6 - 8 สัปดาห์ (ขนาด 85-120ml)",
formula_base: "Alcohol-Free Reed Diffuser Base + Premium Fragrance Oil",
usage_instructions: "เปิดจุกขวดแก้ว ปักก้านไม้ 4-5 ก้านลงในขวด กลับด้านก้านไม้ทุก 3-5 วันเพื่อเพิ่มความฟุ้งกระจายของกลิ่น หลีกเลี่ยงการวางกลางแดดหรือลมพัดแรง",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log("=== Seeding Product Attributes for Reed Diffuser Products ===");
console.log(`Targeting ${REED_DIFFUSER_MAPPINGS.length} products on API: ${PROD_API}`);
// 1. Authenticate to get JWT token
console.log(`1. Logging in as '${ADMIN_USER}' to obtain JWT token...`);
const loginRes = await fetch(`${PROD_API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
});
if (!loginRes.ok) {
throw new Error(`Login failed (${loginRes.status}): ${await loginRes.text()}`);
}
const { token } = (await loginRes.json()) as { token: string };
console.log("✓ Authenticated successfully. JWT token acquired.\n");
// 2. Fetch active attribute definitions from API to map codes to IDs
console.log("2. Fetching active attribute definitions from API...");
const defsRes = await fetch(`${PROD_API}/product-attributes/definitions?lang=th`);
const definitions = (await defsRes.json()) as Array<{ id: number; code: string; type: string }>;
const codeToDef = new Map<string, { id: number; code: string; type: string }>();
for (const def of definitions) {
codeToDef.set(def.code, def);
}
const requiredCodes = [
"scent_family",
"mood_benefit",
"scent_intensity",
"room_placement",
"compatible_device",
"coverage_area",
"scent_duration",
"formula_base",
"usage_instructions",
];
for (const code of requiredCodes) {
if (!codeToDef.has(code)) {
throw new Error(`Attribute definition code '${code}' not found in API!`);
}
}
console.log("✓ All 9 attribute definitions verified on API.\n");
// Save the full matrix as JSON artifact
const exportPath = path.resolve(__dirname, "../data/reed_diffuser_attributes_matrix.json");
fs.writeFileSync(
exportPath,
JSON.stringify(
REED_DIFFUSER_MAPPINGS.map((m) => ({
...m,
specs: REED_DIFFUSER_SPECS,
})),
null,
2
),
"utf-8"
);
console.log(`✓ Saved mapping matrix to ${exportPath}\n`);
// 3. Process each product via API
console.log(`3. Updating attributes for ${REED_DIFFUSER_MAPPINGS.length} Reed Diffuser products via API...`);
let successCount = 0;
let failCount = 0;
for (let i = 0; i < REED_DIFFUSER_MAPPINGS.length; i++) {
const item = REED_DIFFUSER_MAPPINGS[i];
const attributesPayload = [
{
attributeDefinitionId: codeToDef.get("scent_family")!.id,
valueText: item.scentFamily,
},
{
attributeDefinitionId: codeToDef.get("mood_benefit")!.id,
valueText: item.moodBenefit,
},
{
attributeDefinitionId: codeToDef.get("scent_intensity")!.id,
valueText: item.scentIntensity,
},
{
attributeDefinitionId: codeToDef.get("room_placement")!.id,
valueText: item.roomPlacement,
},
{
attributeDefinitionId: codeToDef.get("compatible_device")!.id,
valueText: REED_DIFFUSER_SPECS.compatible_device,
},
{
attributeDefinitionId: codeToDef.get("coverage_area")!.id,
valueText: REED_DIFFUSER_SPECS.coverage_area,
},
{
attributeDefinitionId: codeToDef.get("scent_duration")!.id,
valueText: REED_DIFFUSER_SPECS.scent_duration,
},
{
attributeDefinitionId: codeToDef.get("formula_base")!.id,
valueText: REED_DIFFUSER_SPECS.formula_base,
},
{
attributeDefinitionId: codeToDef.get("usage_instructions")!.id,
valueText: REED_DIFFUSER_SPECS.usage_instructions,
},
];
try {
const updateRes = await fetch(`${PROD_API}/products/${item.productId}/attributes`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ attributes: attributesPayload }),
});
if (!updateRes.ok) {
const errText = await updateRes.text();
console.error(`❌ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} (${item.scentName}) failed: ${updateRes.status} ${errText}`);
failCount++;
} else {
console.log(`✓ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} (${item.scentName}) linked with 9 attributes.`);
successCount++;
}
} catch (err: any) {
console.error(`❌ [${i + 1}/${REED_DIFFUSER_MAPPINGS.length}] Product #${item.productId} error: ${err.message}`);
failCount++;
}
await sleep(80);
}
console.log(`\n=== API Seeding Completed: ${successCount} succeeded, ${failCount} failed ===\n`);
// 4. Also sync to Local Database if connected
try {
console.log("4. Syncing attributes to Local Database via Prisma...");
const localDefs = await prisma.attributeDefinition.findMany();
const localDefMap = new Map<string, number>();
for (const d of localDefs) {
localDefMap.set(d.code, d.id);
}
let localSuccess = 0;
for (const item of REED_DIFFUSER_MAPPINGS) {
// Find local product by name
const expectedName = `${item.scentName} Reed Diffuser`;
const localProd = await prisma.product.findFirst({
where: {
name: { contains: item.scentName },
category: { name: { contains: "ก้านไม้" } },
},
});
if (!localProd) continue;
const attrs = [
{ code: "scent_family", val: item.scentFamily, isText: true },
{ code: "mood_benefit", val: item.moodBenefit, isText: true },
{ code: "scent_intensity", val: item.scentIntensity, isText: true },
{ code: "room_placement", val: item.roomPlacement, isText: true },
{ code: "compatible_device", val: REED_DIFFUSER_SPECS.compatible_device, isText: true },
{ code: "coverage_area", val: REED_DIFFUSER_SPECS.coverage_area, isText: true },
{ code: "scent_duration", val: REED_DIFFUSER_SPECS.scent_duration, isText: true },
{ code: "formula_base", val: REED_DIFFUSER_SPECS.formula_base, isText: true },
{ code: "usage_instructions", val: REED_DIFFUSER_SPECS.usage_instructions, isText: true },
];
for (const attr of attrs) {
const defId = localDefMap.get(attr.code);
if (!defId) continue;
await prisma.productAttribute.upsert({
where: {
productId_attributeDefinitionId: {
productId: localProd.id,
attributeDefinitionId: defId,
},
},
create: {
productId: localProd.id,
attributeDefinitionId: defId,
valueText: attr.val,
},
update: {
valueText: attr.val,
},
});
}
localSuccess++;
}
console.log(`✓ Synced ${localSuccess} products into Local Database.\n`);
} catch (err: any) {
console.warn(`Local DB sync skipped or encountered an error: ${err.message}`);
}
}
main()
.then(() => {
console.log("✅ All Reed Diffuser Product Attributes successfully linked!");
process.exit(0);
})
.catch((err) => {
console.error("❌ Process failed:", err);
process.exit(1);
});