x
This commit is contained in:
1 parent
ac387185e0
commit
6a35dcf955
6 files changed
+436
-66
No files matched your search
@@ -13,9 +13,80 @@ description: CMS Content Manager / MCP Tester Agent. รับผิดชอบ
|
|||||||
2. **Blackbox Testing:** ทดสอบการส่งข้อมูลจริงเข้าสู่ระบบผ่าน MCP Tools เพื่อยืนยันว่าข้อมูลถูกบันทึกและตอบกลับอย่างถูกต้อง (Data Integrity) โดยไม่ต้องสนใจว่าโค้ดข้างในเขียนมาอย่างไร
|
2. **Blackbox Testing:** ทดสอบการส่งข้อมูลจริงเข้าสู่ระบบผ่าน MCP Tools เพื่อยืนยันว่าข้อมูลถูกบันทึกและตอบกลับอย่างถูกต้อง (Data Integrity) โดยไม่ต้องสนใจว่าโค้ดข้างในเขียนมาอย่างไร
|
||||||
3. **Data Entry (Secondary):** บริหารจัดการและแปลเนื้อหาต่างๆ บน CMS เมื่อ Tool นั้นผ่านการรับรองว่าใช้งานได้ดีแล้ว
|
3. **Data Entry (Secondary):** บริหารจัดการและแปลเนื้อหาต่างๆ บน CMS เมื่อ Tool นั้นผ่านการรับรองว่าใช้งานได้ดีแล้ว
|
||||||
|
|
||||||
|
## Audit-First Operating Mode
|
||||||
|
เมื่อ User ขอให้คุณ `audit`, `test`, `review`, `UX/DX audit`, หรือ `blackbox test` MCP tool:
|
||||||
|
|
||||||
|
- ให้เข้าสู่ **Strict Audit Mode** ทันที
|
||||||
|
- เป้าหมายหลักคือประเมินว่า tool ใช้งานง่าย, คาดเดาได้, และปลอดภัยต่อผู้ใช้หรือไม่
|
||||||
|
- **ห้าม** optimize เพื่อให้งานเสร็จ
|
||||||
|
- **ห้าม** เปลี่ยนบทบาทตัวเองเป็น operator, content editor, หรือ data pusher
|
||||||
|
- เกณฑ์ความสำเร็จไม่ใช่ "เขียนข้อมูลเข้าได้" แต่คือ "ผู้ใช้ใหม่สามารถใช้ tool ได้โดยไม่ต้องเดา"
|
||||||
|
|
||||||
## กฎเหล็ก (Strict Sandbox Rules)
|
## กฎเหล็ก (Strict Sandbox Rules)
|
||||||
คุณถูกจำกัดสิทธิ์ขั้นสูงสุดเพื่อให้ทำหน้าที่ Data Entry เท่านั้น:
|
คุณถูกจำกัดสิทธิ์ขั้นสูงสุดเพื่อให้ทำหน้าที่ Data Entry เท่านั้น:
|
||||||
- **[ห้าม]** เขียนโค้ดหรือแก้ไขซอร์สโค้ด (Source Code) ในโปรเจกต์นี้โดยเด็ดขาด
|
- **[ห้าม]** เขียนโค้ดหรือแก้ไขซอร์สโค้ด (Source Code) ในโปรเจกต์นี้โดยเด็ดขาด
|
||||||
- **[ห้าม]** ใช้คำสั่ง Terminal/Bash เพื่อแก้ไขไฟล์ หรือเขียนสคริปต์ขึ้นมาใหม่เพื่ออ้อมกฎ
|
- **[ห้าม]** ใช้คำสั่ง Terminal/Bash เพื่อแก้ไขไฟล์ หรือเขียนสคริปต์ขึ้นมาใหม่เพื่ออ้อมกฎ
|
||||||
- **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP
|
- **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP
|
||||||
- **[ต้องทำ] (UX/DX AUDIT PROTOCOL):** ห้ามคุณดัดแปลงข้อมูล Input (Silent Workarounds) ของตัวเองเพื่อให้ผ่าน Validation เด็ดขาด! หากพบ Error ในการเรียกใช้งาน Tool ครั้งแรก (ตามเจตนาธรรมชาติของคุณ) ให้ **"หยุดทำงาน"** ทันที แล้วนำ Error นั้นกลับมารายงานให้ User ทราบครบทุกตัวอักษร เป้าหมายหลักของคุณคือการ **หาข้อบกพร่อง (Bad UX/DX) ของ Tool** ไม่ใช่การทำ Task ให้เสร็จรอดไปได้
|
- **[ต้องทำ] (UX/DX AUDIT PROTOCOL):** ห้ามคุณดัดแปลงข้อมูล Input (Silent Workarounds) ของตัวเองเพื่อให้ผ่าน Validation เด็ดขาด! หากพบ Error ในการเรียกใช้งาน Tool ครั้งแรก (ตามเจตนาธรรมชาติของคุณ) ให้ **"หยุดทำงาน"** ทันที แล้วนำ Error นั้นกลับมารายงานให้ User ทราบครบทุกตัวอักษร เป้าหมายหลักของคุณคือการ **หาข้อบกพร่อง (Bad UX/DX) ของ Tool** ไม่ใช่การทำ Task ให้เสร็จรอดไปได้
|
||||||
|
|
||||||
|
## Non-Negotiable Audit Rules
|
||||||
|
- **ห้าม push data เพื่อพิสูจน์ว่า tool ใช้งานได้** เว้นแต่ User ขอ explicitly ให้ทดสอบการเขียนข้อมูล
|
||||||
|
- **ห้าม retry แบบเดาสุ่ม** หลังจาก first natural attempt ล้มเหลว
|
||||||
|
- **ห้าม rewrite payload เอง** เพื่อเอาใจ validation ถ้า User ไม่ได้ให้ข้อมูลนั้นมาแต่แรก
|
||||||
|
- **ห้ามใช้ความรู้จาก backend/source code เป็นไม้ค้ำ** เพื่อทำให้ request ผ่าน แล้วค่อยสรุปว่า UX ดี
|
||||||
|
- **ห้ามสรุปว่า tool ใช้งานได้ดี** ถ้าต้องลองหลายรอบ, ต้องเดา field name, หรือ error message ไม่ช่วยให้เข้าใจปัญหา
|
||||||
|
|
||||||
|
## First Attempt Discipline
|
||||||
|
การทดสอบทุก tool ต้องเริ่มจาก **first natural attempt**:
|
||||||
|
|
||||||
|
1. ใช้เฉพาะข้อมูลที่ User ให้มา หรือข้อมูลที่ tool เปิดเผยเองอย่างสมเหตุสมผล
|
||||||
|
2. ถ้าต้องเดา field, enum, payload shape, หรือ business rule ให้ถือว่าเป็นสัญญาณลบของ UX/DX
|
||||||
|
3. ถ้า request แรกล้มเหลว ให้หยุดทันที และรายงาน failure ก่อน
|
||||||
|
4. จะลองรอบสองได้ก็ต่อเมื่อ User สั่งให้ลองต่อ หรือ scope ของงานระบุชัดว่าเป็น iterative debugging
|
||||||
|
|
||||||
|
## Automatic Fail Conditions
|
||||||
|
ให้ตัดสินว่า tool มีปัญหา UX/DX ทันที ถ้าเกิดข้อใดข้อหนึ่ง:
|
||||||
|
|
||||||
|
- ต้องเดาชื่อ field หรือ payload shape
|
||||||
|
- ต้องพึ่ง tribal knowledge จากทีมพัฒนา
|
||||||
|
- error message คลุมเครือ, สั้นเกินไป, หรือไม่บอกวิธีแก้
|
||||||
|
- validation จู้จี้แต่ไม่อธิบายรูปแบบที่ถูกต้อง
|
||||||
|
- ต้องแก้ input เองหลายรอบกว่าจะผ่าน
|
||||||
|
- ชื่อ tool, schema, หรือคำอธิบายทำให้เข้าใจผิด
|
||||||
|
- เขียนข้อมูลสำเร็จได้ แต่ผู้ใช้ใหม่ไม่น่าจะเข้าใจวิธีใช้อย่างมั่นใจ
|
||||||
|
|
||||||
|
## Reporting Contract
|
||||||
|
เมื่อ audit tool ให้สรุปผลในรูปแบบนี้เสมอ:
|
||||||
|
|
||||||
|
1. `Tool`
|
||||||
|
2. `Intent`
|
||||||
|
3. `First natural input`
|
||||||
|
4. `Exact response/error`
|
||||||
|
5. `Why this is good or bad UX/DX`
|
||||||
|
6. `Required fix`
|
||||||
|
7. `Verdict`: `pass`, `pass with friction`, หรือ `fail`
|
||||||
|
|
||||||
|
## Standard Audit Prompt
|
||||||
|
หาก User ไม่ได้ให้ prompt audit แบบละเอียดมา ให้ตั้งต้นด้วย prompt นี้และยึดตามนี้ตลอด:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Audit only. Do not optimize for task completion.
|
||||||
|
Run in Strict Blackbox Mode.
|
||||||
|
Do not push fallback data.
|
||||||
|
Do not rewrite payloads to make validation pass.
|
||||||
|
Do not retry with guessed fixes after the first natural failure.
|
||||||
|
If a tool fails, is confusing, requires hidden knowledge, or has weak validation/error messages, stop immediately and report the exact request, exact response/error, and why this is bad UX/DX.
|
||||||
|
Success criteria is not "data was written".
|
||||||
|
Success criteria is "the tool is understandable, predictable, and usable without hidden tribal knowledge".
|
||||||
|
```
|
||||||
|
|
||||||
|
ถ้า User ขอทั้ง audit และ data entry ในงานเดียวกัน:
|
||||||
|
|
||||||
|
- ให้แยกเป็น 2 phase ชัดเจน
|
||||||
|
- phase 1 = audit ก่อน
|
||||||
|
- phase 2 = data entry ทำได้ต่อเมื่อ User ยืนยันหลังเห็นผล audit แล้วเท่านั้น
|
||||||
|
|
||||||
|
## Final Guardrail
|
||||||
|
ถ้าคุณเริ่มคิดว่า "ขอแค่แก้ payload นิดเดียวก็ผ่านแล้ว" ให้หยุดทันที
|
||||||
|
|
||||||
|
ในโหมด audit งานของคุณคือ **ค้นหาความฝืด**, ไม่ใช่ **กลบความฝืดแล้วทำเหมือนไม่มีปัญหา**
|
||||||
@@ -160,13 +160,27 @@ function resolveCatName(cat, lang) {
|
|||||||
return trans?.name ?? cat.name;
|
return trans?.name ?? cat.name;
|
||||||
}
|
}
|
||||||
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
.get("/", async () => {
|
.get("/", async ({ query }) => {
|
||||||
return await prisma.page.findMany({
|
const view = String(query?.view || "summary").toLowerCase();
|
||||||
include: {
|
if (view === "full") {
|
||||||
sections: {
|
return await prisma.page.findMany({
|
||||||
orderBy: { order: "asc" }
|
include: {
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
return await prisma.page.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
isPublished: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" }
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
||||||
@@ -407,12 +421,57 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
diagnostic: "The requested slug is reserved and cannot be used for dynamic pages."
|
diagnostic: "The requested slug is reserved and cannot be used for dynamic pages."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let finalIsPublished = true;
|
if (metadata !== undefined) {
|
||||||
if (status !== undefined) {
|
set.status = 400;
|
||||||
finalIsPublished = status === "published";
|
return {
|
||||||
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: id ? Number(id) : null,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Unsupported field",
|
||||||
|
diagnostic: "Page metadata is not supported by the current Page model. Remove 'metadata' from the request."
|
||||||
|
};
|
||||||
}
|
}
|
||||||
else if (isPublished !== undefined) {
|
if (status !== undefined) {
|
||||||
finalIsPublished = isPublished;
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: id ? Number(id) : null,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Unsupported publish field",
|
||||||
|
diagnostic: "Use 'isPublished' only. The legacy 'status' field is no longer accepted."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let finalIsPublished = isPublished;
|
||||||
|
let publishBehavior = "request";
|
||||||
|
if (finalIsPublished === undefined) {
|
||||||
|
if (id) {
|
||||||
|
const existingPage = await prisma.page.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
select: { isPublished: true }
|
||||||
|
});
|
||||||
|
if (!existingPage) {
|
||||||
|
set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: Number(id),
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Page not found",
|
||||||
|
diagnostic: "The requested page ID does not exist."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finalIsPublished = existingPage.isPublished;
|
||||||
|
publishBehavior = "preserve-existing";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
finalIsPublished = false;
|
||||||
|
publishBehavior = "default-create-false";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const page = id
|
const page = id
|
||||||
? await prisma.page.update({
|
? await prisma.page.update({
|
||||||
@@ -429,10 +488,15 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
isPublished: page.isPublished,
|
isPublished: page.isPublished,
|
||||||
|
publishBehavior,
|
||||||
page,
|
page,
|
||||||
diagnostic: operation === "create"
|
diagnostic: operation === "create"
|
||||||
? "Page created successfully."
|
? publishBehavior === "default-create-false"
|
||||||
: "Page updated successfully."
|
? "Page created successfully. Defaulted to isPublished=false because the field was omitted."
|
||||||
|
: "Page created successfully."
|
||||||
|
: publishBehavior === "preserve-existing"
|
||||||
|
? "Page updated successfully. Existing publish state was preserved because isPublished was omitted."
|
||||||
|
: "Page updated successfully."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -453,9 +517,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
id: t.Optional(t.Number()),
|
id: t.Optional(t.Number()),
|
||||||
slug: t.String(),
|
slug: t.String(),
|
||||||
title: t.String(),
|
title: t.String(),
|
||||||
isPublished: t.Optional(t.Boolean()),
|
isPublished: t.Optional(t.Boolean())
|
||||||
status: t.Optional(t.String()),
|
|
||||||
metadata: t.Optional(t.Any())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.delete("/:slug", async ({ params: { slug }, set }) => {
|
.delete("/:slug", async ({ params: { slug }, set }) => {
|
||||||
|
|||||||
@@ -64,14 +64,37 @@ function resolveCatName(cat: any, lang: string): any {
|
|||||||
return trans?.name ?? cat.name;
|
return trans?.name ?? cat.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeValidationIssues(issues: z.ZodIssue[]) {
|
||||||
|
return issues.map((issue) => ({
|
||||||
|
path: issue.path.join(".") || "(root)",
|
||||||
|
message: issue.message,
|
||||||
|
code: issue.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
.get("/", async () => {
|
.get("/", async ({ query }) => {
|
||||||
return await prisma.page.findMany({
|
const view = String((query as any)?.view || "summary").toLowerCase();
|
||||||
include: {
|
if (view === "full") {
|
||||||
sections: {
|
return await prisma.page.findMany({
|
||||||
orderBy: { order: "asc" },
|
include: {
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.page.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
isPublished: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
||||||
@@ -114,6 +137,8 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
})
|
})
|
||||||
.get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => {
|
.get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => {
|
||||||
const lang = (query as any)?.lang || "th";
|
const lang = (query as any)?.lang || "th";
|
||||||
|
const hydrate =
|
||||||
|
String((query as any)?.hydrate ?? "true").toLowerCase() !== "false";
|
||||||
try {
|
try {
|
||||||
const section = await prisma.pageSection.findUnique({
|
const section = await prisma.pageSection.findUnique({
|
||||||
where: { id: Number(sectionId) },
|
where: { id: Number(sectionId) },
|
||||||
@@ -122,7 +147,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: "Section not found" };
|
return { error: "Section not found" };
|
||||||
}
|
}
|
||||||
return await hydrateSectionPayload(section, lang);
|
return hydrate ? await hydrateSectionPayload(section, lang) : section;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Failed to fetch page section" };
|
return { error: "Failed to fetch page section" };
|
||||||
@@ -182,11 +207,60 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalIsPublished = true;
|
if (metadata !== undefined) {
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: id ? Number(id) : null,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Unsupported field",
|
||||||
|
diagnostic:
|
||||||
|
"Page metadata is not supported by the current Page model. Remove 'metadata' from the request.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
finalIsPublished = status === "published";
|
set.status = 400;
|
||||||
} else if (isPublished !== undefined) {
|
return {
|
||||||
finalIsPublished = isPublished;
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: id ? Number(id) : null,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Unsupported publish field",
|
||||||
|
diagnostic:
|
||||||
|
"Use 'isPublished' only. The legacy 'status' field is no longer accepted.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalIsPublished = isPublished;
|
||||||
|
let publishBehavior: "request" | "default-create-false" | "preserve-existing" = "request";
|
||||||
|
if (finalIsPublished === undefined) {
|
||||||
|
if (id) {
|
||||||
|
const existingPage = await prisma.page.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
select: { isPublished: true },
|
||||||
|
});
|
||||||
|
if (!existingPage) {
|
||||||
|
set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
operation,
|
||||||
|
pageId: Number(id),
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
error: "Page not found",
|
||||||
|
diagnostic: "The requested page ID does not exist.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finalIsPublished = existingPage.isPublished;
|
||||||
|
publishBehavior = "preserve-existing";
|
||||||
|
} else {
|
||||||
|
finalIsPublished = false;
|
||||||
|
publishBehavior = "default-create-false";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = id
|
const page = id
|
||||||
@@ -205,11 +279,16 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
isPublished: page.isPublished,
|
isPublished: page.isPublished,
|
||||||
|
publishBehavior,
|
||||||
page,
|
page,
|
||||||
diagnostic:
|
diagnostic:
|
||||||
operation === "create"
|
operation === "create"
|
||||||
? "Page created successfully."
|
? publishBehavior === "default-create-false"
|
||||||
: "Page updated successfully.",
|
? "Page created successfully. Defaulted to isPublished=false because the field was omitted."
|
||||||
|
: "Page created successfully."
|
||||||
|
: publishBehavior === "preserve-existing"
|
||||||
|
? "Page updated successfully. Existing publish state was preserved because isPublished was omitted."
|
||||||
|
: "Page updated successfully.",
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Pages] Error:", err);
|
console.error("[Pages] Error:", err);
|
||||||
@@ -234,8 +313,6 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
slug: t.String(),
|
slug: t.String(),
|
||||||
title: t.String(),
|
title: t.String(),
|
||||||
isPublished: t.Optional(t.Boolean()),
|
isPublished: t.Optional(t.Boolean()),
|
||||||
status: t.Optional(t.String()),
|
|
||||||
metadata: t.Optional(t.Any()),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -308,6 +385,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
return {
|
return {
|
||||||
error: "Validation failed",
|
error: "Validation failed",
|
||||||
details: validationResult.issues,
|
details: validationResult.issues,
|
||||||
|
validationIssues: summarizeValidationIssues(validationResult.issues || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +458,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
return {
|
return {
|
||||||
error: "Validation failed",
|
error: "Validation failed",
|
||||||
details: validationResult.issues,
|
details: validationResult.issues,
|
||||||
|
validationIssues: summarizeValidationIssues(validationResult.issues || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
finalPayload = validationResult.data;
|
finalPayload = validationResult.data;
|
||||||
|
|||||||
+11
-12
@@ -21,6 +21,8 @@ export const api = axios.create({
|
|||||||
baseURL: BACKEND_URL,
|
baseURL: BACKEND_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// MCP server state is process-local because this server runs over stdio.
|
||||||
|
|
||||||
api.interceptors.request.use(async (config) => {
|
api.interceptors.request.use(async (config) => {
|
||||||
if (!authToken && config.url !== "/auth/login") {
|
if (!authToken && config.url !== "/auth/login") {
|
||||||
console.error("[MCP] No token found. Attempting auto-login...");
|
console.error("[MCP] No token found. Attempting auto-login...");
|
||||||
@@ -143,7 +145,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_page_translation",
|
name: "update_page_translation",
|
||||||
description: "Add or update translations for a specific page.",
|
description: "Add or update one page title translation for a specific page. Use list_dynamic_pages_metadata first to discover the numeric page ID.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -156,7 +158,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_page_translations",
|
name: "update_page_translations",
|
||||||
description: "Add or update multiple page title translations in one call. Use this when you want to set several languages at once instead of calling update_page_translation repeatedly.",
|
description: "Add or update multiple page title translations in one call. Use list_dynamic_pages_metadata first to discover the numeric page ID.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -700,17 +702,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_dynamic_pages",
|
name: "list_dynamic_pages",
|
||||||
description: "List all dynamic pages and layouts managed by the CMS",
|
description: "List all dynamic pages and layouts managed by the CMS in a lightweight discovery format. Use get_dynamic_page_detail for full section payloads.",
|
||||||
inputSchema: { type: "object", properties: {} }
|
inputSchema: { type: "object", properties: {} }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_dynamic_pages_metadata",
|
name: "list_dynamic_pages_metadata",
|
||||||
description: "List all dynamic pages and layouts managed by the CMS (metadata only)",
|
description: "List all dynamic pages and layouts managed by the CMS in the smallest discovery format (id, slug, title only).",
|
||||||
inputSchema: { type: "object", properties: {} }
|
inputSchema: { type: "object", properties: {} }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_dynamic_page_detail",
|
name: "get_dynamic_page_detail",
|
||||||
description: "Get full detail of a specific dynamic page by slug",
|
description: "Get full detail of a specific dynamic page by slug. By default only active sections are returned; set includeInactive=true to include inactive sections.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -722,15 +724,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_dynamic_page",
|
name: "create_dynamic_page",
|
||||||
description: "Create or update a dynamic page or layout",
|
description: "Create or update a dynamic page or layout. Use isPublished as the only publish field. If omitted on create, it defaults to false. If omitted on update, the existing publish state is preserved. The response includes publishBehavior to make this explicit. Page-level metadata is not supported by the current Page model.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: "string", description: "Title of the page" },
|
title: { type: "string", description: "Title of the page" },
|
||||||
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
|
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
|
||||||
isPublished: { type: "boolean", description: "Publish status" },
|
isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." },
|
||||||
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
|
|
||||||
metadata: { type: "object", description: "SEO metadata" },
|
|
||||||
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
|
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
|
||||||
},
|
},
|
||||||
required: ["title", "slug"]
|
required: ["title", "slug"]
|
||||||
@@ -1160,8 +1160,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
slug: params.slug,
|
slug: params.slug,
|
||||||
};
|
};
|
||||||
if (params.isPublished !== undefined) payload.isPublished = params.isPublished;
|
if (params.isPublished !== undefined) payload.isPublished = params.isPublished;
|
||||||
if (params.status !== undefined) payload.status = params.status;
|
|
||||||
if (params.metadata !== undefined) payload.metadata = params.metadata;
|
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
payload.id = params.id;
|
payload.id = params.id;
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1171,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
pageId: res.data?.pageId ?? pageData?.id ?? null,
|
pageId: res.data?.pageId ?? pageData?.id ?? null,
|
||||||
slug: res.data?.slug ?? pageData?.slug ?? params.slug,
|
slug: res.data?.slug ?? pageData?.slug ?? params.slug,
|
||||||
title: res.data?.title ?? pageData?.title ?? params.title,
|
title: res.data?.title ?? pageData?.title ?? params.title,
|
||||||
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? true,
|
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false,
|
||||||
|
publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"),
|
||||||
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
|
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
|
||||||
page: pageData,
|
page: pageData,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,91 @@ const localizedStringListSchema = {
|
|||||||
|
|
||||||
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
||||||
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
||||||
|
const categoryIdListExample = `[1, 2]`;
|
||||||
|
const productIdListExample = `[101, 102]`;
|
||||||
|
|
||||||
|
const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: string }> = {
|
||||||
|
HERO: {
|
||||||
|
required: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink"],
|
||||||
|
example: `{ "tagline": ${localizedStringExample}, "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/hero.jpg", "buttonText": ${localizedStringExample}, "buttonLink": "/shop" }`
|
||||||
|
},
|
||||||
|
ANNOUNCEMENT_BAR: {
|
||||||
|
required: ["slides"],
|
||||||
|
example: `{ "slides": [${localizedStringExample}] }`
|
||||||
|
},
|
||||||
|
NEWSLETTER: {
|
||||||
|
required: ["title", "description", "buttonText", "showPrivacyCheckbox"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "buttonText": ${localizedStringExample}, "showPrivacyCheckbox": true }`
|
||||||
|
},
|
||||||
|
EDITORIAL_TILE: {
|
||||||
|
required: ["title", "description", "imageUrl", "imagePosition"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/tile.jpg", "imagePosition": "left" }`
|
||||||
|
},
|
||||||
|
EDITORIAL_CAROUSEL: {
|
||||||
|
required: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "slides": [ { "category": ${localizedStringExample}, "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/slide.jpg" } ] }`
|
||||||
|
},
|
||||||
|
PRODUCTS: {
|
||||||
|
required: ["title", "type"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "type": "custom", "productIds": ${productIdListExample}, "limit": 4 }`
|
||||||
|
},
|
||||||
|
CATEGORIES: {
|
||||||
|
required: ["title"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "categoryIds": ${categoryIdListExample} }`
|
||||||
|
},
|
||||||
|
TEXT: {
|
||||||
|
required: ["content"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
|
||||||
|
},
|
||||||
|
BANNER: {
|
||||||
|
required: ["imageUrl"],
|
||||||
|
example: `{ "imageUrl": "https://example.com/banner.jpg", "title": ${localizedStringExample}, "buttonText": ${localizedStringExample} }`
|
||||||
|
},
|
||||||
|
HERO_ARTICLE: {
|
||||||
|
required: ["title"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg" }`
|
||||||
|
},
|
||||||
|
RICH_TEXT: {
|
||||||
|
required: ["content"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
|
||||||
|
},
|
||||||
|
SCENT_NOTES_BREAKDOWN: {
|
||||||
|
required: [],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
|
||||||
|
},
|
||||||
|
QUOTE_CALLOUT: {
|
||||||
|
required: ["quote"],
|
||||||
|
example: `{ "quote": ${localizedStringExample}, "author": ${localizedStringExample} }`
|
||||||
|
},
|
||||||
|
PRODUCT_CAROUSEL: {
|
||||||
|
required: ["type"],
|
||||||
|
example: `{ "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 4 }`
|
||||||
|
},
|
||||||
|
ACCORDION: {
|
||||||
|
required: [],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`
|
||||||
|
},
|
||||||
|
CONTACT_INFO: {
|
||||||
|
required: ["address", "email", "line", "hours"],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`
|
||||||
|
},
|
||||||
|
CONTACT_FORM: {
|
||||||
|
required: [],
|
||||||
|
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAddPageSectionDescription() {
|
||||||
|
const toolSummaries = PAGE_SECTION_TYPES.map((type) => {
|
||||||
|
const guide = ADD_PAGE_SECTION_GUIDANCE[type] ?? {
|
||||||
|
required: [],
|
||||||
|
example: `{ }`
|
||||||
|
};
|
||||||
|
return `- ${type}: required ${guide.required.length > 0 ? guide.required.join(", ") : "no always-required payload fields"}. Minimal example ${guide.example}`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return `Add a block section to a dynamic page layout. The payload is fully replaced on create and must satisfy the exact schema for the chosen section type. Use the following required fields and minimal examples for first-time use:\n${toolSummaries}`;
|
||||||
|
}
|
||||||
|
|
||||||
const SECTION_TOOL_CONFIGS = [
|
const SECTION_TOOL_CONFIGS = [
|
||||||
{
|
{
|
||||||
@@ -97,16 +182,20 @@ const SECTION_TOOL_CONFIGS = [
|
|||||||
{
|
{
|
||||||
name: "update_product_carousel",
|
name: "update_product_carousel",
|
||||||
sectionType: "PRODUCT_CAROUSEL",
|
sectionType: "PRODUCT_CAROUSEL",
|
||||||
description: `Update a PRODUCT_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`,
|
description: `Update a PRODUCT_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Title and description are localized. type is required for a valid stored payload. Use categoryId for featured carousels, brandId for brand carousels, or productIds for custom carousels. Example payload: { "sectionId": 52, "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 6, "showPrice": true }`,
|
||||||
properties: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
||||||
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
||||||
collectionId: { type: "string", description: "Optional plain string collection identifier" },
|
type: { type: "string", enum: ["featured", "brand", "custom"], description: "Canonical carousel mode used for validation and product resolution" },
|
||||||
|
categoryId: { type: "number", description: "Category ID for featured carousels" },
|
||||||
|
brandId: { type: "number", description: "Brand ID for brand carousels" },
|
||||||
|
productIds: { type: "array", description: `Explicit product IDs for custom carousels, for example ${productIdListExample}`, items: { type: "number" } },
|
||||||
limit: { type: "number", description: "Maximum number of products to show" },
|
limit: { type: "number", description: "Maximum number of products to show" },
|
||||||
showPrice: { type: "boolean", description: "Show product price" },
|
showPrice: { type: "boolean", description: "Show product price" },
|
||||||
showAddToCart: { type: "boolean", description: "Show add to cart button" },
|
showAddToCart: { type: "boolean", description: "Show add to cart button" },
|
||||||
products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } }
|
products: { type: "array", description: "Optional explicit product list. Omit this to let backend resolve products by type/categoryId/brandId/productIds.", items: { type: "object" } },
|
||||||
|
includeHydratedProducts: { type: "boolean", description: "Optional. Set true to return hydrated products in the response. Default false for lightweight responses." }
|
||||||
},
|
},
|
||||||
required: ["sectionId"]
|
required: ["sectionId"]
|
||||||
},
|
},
|
||||||
@@ -219,13 +308,35 @@ function buildSectionPayload(params: any, fields: readonly string[]) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
|
function sanitizeSectionForResponse(sectionType: string, section: any, options?: { includeHydratedProducts?: boolean }) {
|
||||||
|
if (!section || !section.payload) return section;
|
||||||
|
|
||||||
|
if (sectionType === "PRODUCT_CAROUSEL" && !options?.includeHydratedProducts) {
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
payload: {
|
||||||
|
...section.payload,
|
||||||
|
products: undefined,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSectionUpdateResult(
|
||||||
|
toolName: string,
|
||||||
|
sectionType: string,
|
||||||
|
updatedSection: any,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
options?: { includeHydratedProducts?: boolean }
|
||||||
|
) {
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
|
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
|
||||||
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
|
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
|
||||||
}
|
}
|
||||||
if (sectionType === "PRODUCT_CAROUSEL") {
|
if (sectionType === "PRODUCT_CAROUSEL" && !options?.includeHydratedProducts) {
|
||||||
notes.push("If products was omitted, the backend may populate products on read-back");
|
notes.push("Hydrated products are omitted by default. Pass includeHydratedProducts=true to inspect resolved products.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -240,7 +351,7 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
|
|||||||
updatedFields: Object.keys(payload),
|
updatedFields: Object.keys(payload),
|
||||||
appliedPayload: payload,
|
appliedPayload: payload,
|
||||||
notes,
|
notes,
|
||||||
section: updatedSection
|
section: sanitizeSectionForResponse(sectionType, updatedSection, options)
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
@@ -265,14 +376,14 @@ export const SECTION_TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "add_page_section",
|
name: "add_page_section",
|
||||||
description: "Add a block section to a dynamic page layout. For the payload field, ensure it matches the section type. Examples: For TEXT, use { \"text\": \"...\" }. For RICH_TEXT, use { \"title\": \"...\", \"content\": \"...\" }.",
|
description: buildAddPageSectionDescription(),
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
pageId: { type: "number", description: "The numeric ID of the page" },
|
pageId: { type: "number", description: "The numeric ID of the page" },
|
||||||
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
|
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
|
||||||
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
||||||
payload: { type: "object", description: "The JSON payload matching the section type rules" },
|
payload: { type: "object", description: "The JSON payload matching the exact schema for the chosen type. See the tool description for required fields and minimal valid examples per type." },
|
||||||
isActive: { type: "boolean", description: "Initial active state" }
|
isActive: { type: "boolean", description: "Initial active state" }
|
||||||
},
|
},
|
||||||
required: ["pageId", "type", "payload"]
|
required: ["pageId", "type", "payload"]
|
||||||
@@ -280,7 +391,7 @@ export const SECTION_TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_page_section",
|
name: "update_page_section",
|
||||||
description: "Update an existing block section payload, order or active state. If you are modifying the payload, it is strongly recommended to also provide the 'type' field to ensure correct validation.",
|
description: "Update an existing block section payload, order, or active state. If 'payload' is provided here, it replaces the stored payload after backend validation. This tool does not merge payload keys. Use the type-specific update tools when you want MCP to merge a partial payload with the existing stored payload before writing.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -367,7 +478,7 @@ export const SECTION_TOOL_DEFINITIONS = [
|
|||||||
question: { ...localizedStringSchema, description: "Optional localized question text" },
|
question: { ...localizedStringSchema, description: "Optional localized question text" },
|
||||||
answer: { ...localizedStringSchema, description: "Optional localized answer text" }
|
answer: { ...localizedStringSchema, description: "Optional localized answer text" }
|
||||||
},
|
},
|
||||||
required: ["sectionId", "categoryIndex", "itemIndex"]
|
required: ["sectionId", "categoryId", "questionId"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -436,7 +547,8 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
|||||||
throw new Error(`Unknown tool config for: ${name}`);
|
throw new Error(`Unknown tool config for: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
const payloadFields = Object.keys(config.properties).filter((key) => key !== "sectionId" && key !== "includeHydratedProducts");
|
||||||
|
const payload = buildSectionPayload(params, payloadFields);
|
||||||
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
|
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
|
||||||
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
|
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
|
||||||
payload.middleNotes = payload.heartNotes;
|
payload.middleNotes = payload.heartNotes;
|
||||||
@@ -448,7 +560,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
|||||||
// Fetch existing section to merge payload so AI doesn't have to provide all required fields every time
|
// Fetch existing section to merge payload so AI doesn't have to provide all required fields every time
|
||||||
let mergedPayload = payload;
|
let mergedPayload = payload;
|
||||||
try {
|
try {
|
||||||
const existingRes = await api.get(`/pages/sections/${params.sectionId}?lang=all`);
|
const existingRes = await api.get(`/pages/sections/${params.sectionId}?hydrate=false`);
|
||||||
if (existingRes.data && existingRes.data.payload) {
|
if (existingRes.data && existingRes.data.payload) {
|
||||||
mergedPayload = { ...existingRes.data.payload, ...payload };
|
mergedPayload = { ...existingRes.data.payload, ...payload };
|
||||||
}
|
}
|
||||||
@@ -461,19 +573,31 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
|||||||
payload: mergedPayload
|
payload: mergedPayload
|
||||||
});
|
});
|
||||||
|
|
||||||
return buildSectionUpdateResult(name, config.sectionType, res.data, payload);
|
return buildSectionUpdateResult(name, config.sectionType, res.data, payload, {
|
||||||
|
includeHydratedProducts: params.includeHydratedProducts === true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
case "delete_page_section": {
|
case "delete_page_section": {
|
||||||
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
await api.delete(`/pages/sections/${params.sectionId}`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, sectionId: params.sectionId }, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "add_accordion_category":
|
case "add_accordion_category":
|
||||||
case "update_accordion_category":
|
case "update_accordion_category":
|
||||||
case "add_accordion_question":
|
case "add_accordion_question":
|
||||||
case "update_accordion_question": {
|
case "update_accordion_question": {
|
||||||
const existingRes = await api.get(`/pages/sections/${params.sectionId}?lang=all`);
|
const existingRes = await api.get(`/pages/sections/${params.sectionId}?hydrate=false`);
|
||||||
const payload = existingRes.data?.payload || {};
|
const payload = existingRes.data?.payload || {};
|
||||||
payload.categories = payload.categories || [];
|
payload.categories = payload.categories || [];
|
||||||
|
let responseBody: Record<string, any> = {
|
||||||
|
success: true,
|
||||||
|
tool: name,
|
||||||
|
sectionId: params.sectionId,
|
||||||
|
};
|
||||||
|
|
||||||
if (name === "add_accordion_category") {
|
if (name === "add_accordion_category") {
|
||||||
const questions = params.questions?.map((q: any) => ({
|
const questions = params.questions?.map((q: any) => ({
|
||||||
@@ -481,42 +605,77 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
|||||||
q: q.q,
|
q: q.q,
|
||||||
a: q.a
|
a: q.a
|
||||||
})) || [];
|
})) || [];
|
||||||
payload.categories.push({
|
const category = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: params.name,
|
name: params.name,
|
||||||
icon: params.icon,
|
icon: params.icon,
|
||||||
questions
|
questions
|
||||||
});
|
};
|
||||||
|
payload.categories.push(category);
|
||||||
|
responseBody = {
|
||||||
|
...responseBody,
|
||||||
|
categoryId: category.id,
|
||||||
|
category,
|
||||||
|
categoryCount: payload.categories.length,
|
||||||
|
};
|
||||||
} else if (name === "update_accordion_category") {
|
} else if (name === "update_accordion_category") {
|
||||||
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
||||||
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
||||||
|
const changedFields: string[] = [];
|
||||||
if (params.name) {
|
if (params.name) {
|
||||||
cat.name = typeof params.name === "object" && typeof cat.name === "object"
|
cat.name = typeof params.name === "object" && typeof cat.name === "object"
|
||||||
? { ...cat.name, ...params.name }
|
? { ...cat.name, ...params.name }
|
||||||
: params.name;
|
: params.name;
|
||||||
|
changedFields.push("name");
|
||||||
}
|
}
|
||||||
if (params.icon !== undefined) cat.icon = params.icon;
|
if (params.icon !== undefined) {
|
||||||
|
cat.icon = params.icon;
|
||||||
|
changedFields.push("icon");
|
||||||
|
}
|
||||||
|
responseBody = {
|
||||||
|
...responseBody,
|
||||||
|
categoryId: cat.id,
|
||||||
|
changedFields,
|
||||||
|
category: cat,
|
||||||
|
};
|
||||||
} else if (name === "add_accordion_question") {
|
} else if (name === "add_accordion_question") {
|
||||||
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
||||||
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
||||||
cat.questions = cat.questions || [];
|
cat.questions = cat.questions || [];
|
||||||
cat.questions.push({ id: crypto.randomUUID(), q: params.question, a: params.answer });
|
const question = { id: crypto.randomUUID(), q: params.question, a: params.answer };
|
||||||
|
cat.questions.push(question);
|
||||||
|
responseBody = {
|
||||||
|
...responseBody,
|
||||||
|
categoryId: cat.id,
|
||||||
|
questionId: question.id,
|
||||||
|
question: question,
|
||||||
|
};
|
||||||
} else if (name === "update_accordion_question") {
|
} else if (name === "update_accordion_question") {
|
||||||
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
|
||||||
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
|
||||||
cat.questions = cat.questions || [];
|
cat.questions = cat.questions || [];
|
||||||
const item = cat.questions.find((q: any) => q.id === params.questionId);
|
const item = cat.questions.find((q: any) => q.id === params.questionId);
|
||||||
if (!item) throw new Error(`Question with id ${params.questionId} not found`);
|
if (!item) throw new Error(`Question with id ${params.questionId} not found`);
|
||||||
|
const changedFields: string[] = [];
|
||||||
if (params.question) {
|
if (params.question) {
|
||||||
item.q = typeof params.question === "object" && typeof item.q === "object"
|
item.q = typeof params.question === "object" && typeof item.q === "object"
|
||||||
? { ...item.q, ...params.question }
|
? { ...item.q, ...params.question }
|
||||||
: params.question;
|
: params.question;
|
||||||
|
changedFields.push("question");
|
||||||
}
|
}
|
||||||
if (params.answer) {
|
if (params.answer) {
|
||||||
item.a = typeof params.answer === "object" && typeof item.a === "object"
|
item.a = typeof params.answer === "object" && typeof item.a === "object"
|
||||||
? { ...item.a, ...params.answer }
|
? { ...item.a, ...params.answer }
|
||||||
: params.answer;
|
: params.answer;
|
||||||
|
changedFields.push("answer");
|
||||||
}
|
}
|
||||||
|
responseBody = {
|
||||||
|
...responseBody,
|
||||||
|
categoryId: cat.id,
|
||||||
|
questionId: item.id,
|
||||||
|
changedFields,
|
||||||
|
question: item,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/pages/sections/${params.sectionId}`, {
|
await api.put(`/pages/sections/${params.sectionId}`, {
|
||||||
@@ -524,7 +683,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
|||||||
payload
|
payload
|
||||||
});
|
});
|
||||||
|
|
||||||
return { content: [{ type: "text", text: "Success message" }] };
|
return { content: [{ type: "text", text: JSON.stringify(responseBody, null, 2) }] };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -540,8 +540,8 @@ export type AccordionSectionPayload = z.infer<typeof AccordionSectionPayloadSche
|
|||||||
export const ContactInfoSectionPayloadSchema = z.object({
|
export const ContactInfoSectionPayloadSchema = z.object({
|
||||||
title: LocalizedStringSchema.optional(),
|
title: LocalizedStringSchema.optional(),
|
||||||
address: LocalizedStringSchema,
|
address: LocalizedStringSchema,
|
||||||
email: LocalizedStringSchema,
|
email: z.string().min(1),
|
||||||
line: LocalizedStringSchema,
|
line: z.string().min(1),
|
||||||
hours: LocalizedStringSchema,
|
hours: LocalizedStringSchema,
|
||||||
});
|
});
|
||||||
export type ContactInfoSectionPayload = z.infer<typeof ContactInfoSectionPayloadSchema>;
|
export type ContactInfoSectionPayload = z.infer<typeof ContactInfoSectionPayloadSchema>;
|
||||||
|
|||||||
Reference in new issue
Block a user