From 1ac727f4e515bef2d4aa49dc0ad16f36a996b3cb Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 13 Jun 2026 22:02:04 -0400 Subject: [PATCH] x --- .agents/skills/orchestrator/SKILL.md | 15 +++ apps/backend/src/middleware/auth.ts | 30 ++++++ apps/backend/src/routes/menus.ts | 2 + apps/backend/src/routes/pages.ts | 85 +++++++++------- apps/backend/src/routes/settings.ts | 2 + apps/backend/tsconfig.json | 44 +++++++++ apps/mcp-server/index.ts | 140 +++++++++++++++++++++++++++ apps/spa/src/App.tsx | 2 +- apps/spa/src/components/Footer.tsx | 92 ++++-------------- apps/spa/src/pages/CustomPage.tsx | 16 +-- 10 files changed, 306 insertions(+), 122 deletions(-) create mode 100644 apps/backend/src/middleware/auth.ts create mode 100644 apps/backend/tsconfig.json diff --git a/.agents/skills/orchestrator/SKILL.md b/.agents/skills/orchestrator/SKILL.md index 1649a68..fc5d946 100644 --- a/.agents/skills/orchestrator/SKILL.md +++ b/.agents/skills/orchestrator/SKILL.md @@ -37,6 +37,21 @@ description: Main Agent / Multi-Agent Orchestrator ทำหน้าที่ - **RULE #4: รายงานความคืบหน้าให้ User เป็นระยะ** ไม่หายเงียบไปนานๆ เมื่อสั่งงาน Sub Agent หรือรัน Task ยาวๆ ควรรายงานสถานะให้คุณกิตติศักดิ์ทราบ +> [!CAUTION] +> **RULE #5: ห้ามมักง่าย ทำทางลัด หรือเบี่ยงเบนเป้าหมายเด็ดขาด (No Shortcuts & No Bypassing)** +> หาก User สั่งให้ทำงานผ่านเครื่องมือใด (เช่น สั่งให้ทดสอบการใช้ MCP Tool) **ต้องใช้เครื่องมือนั้นจริงๆ ตามที่ถูกออกแบบมาเท่านั้น** ห้ามมักง่ายเขียนสคริปต์ยิง API ตรงเพื่อเลี่ยงการใช้ Tool (Bypass), ห้ามคิดอ้อม และต้องโฟกัสที่เป้าหมายที่ User กำหนดไว้อย่างเคร่งครัด หาก Tool มีปัญหา ให้หาวิธีแก้ไขที่ต้นตอของ Tool ไม่ใช่หาทางลัดเพื่อทำงานให้เสร็จไปเฉยๆ + +> [!IMPORTANT] +> **RULE #6: ห้ามคาดเดาเองเด็ดขาดเมื่อข้อมูลไม่เพียงพอ (Ask, Don't Guess)** +> หากได้รับโจทย์แล้วรู้สึกว่ามีข้อมูล บริบท หรือ Requirements ไม่ครบถ้วนชัดเจน **ห้ามคิดไปเอง หรือเดาสุ่มเด็ดขาด** ให้หยุดทำงานนั้นทันที แล้วสอบถามข้อมูลเพิ่มเติมหรือขอคำชี้แจงจาก User ทันที เพื่อป้องกันการพัฒนาระบบไปผิดทาง + +> [!TIP] +> **RULE #7: กระบวนการอัปเดต Taskboard (Taskboard Updates & Closures)** +> หาก User สั่งให้ "อัปเดต Taskboard" หรือคำสั่งที่มีความหมายทำนองเดียวกัน ให้เข้าใจตรงกันว่า: +> 1. คุณต้องสรุปผลงานที่ทำไปอย่างเข้าใจง่าย +> 2. นำไปสร้างหรืออัปเดตเป็น Main Task ใหม่ตามความเหมาะสมผ่าน Taskboard MCP +> 3. ทำการปิด (Complete) Task ที่เสร็จสิ้นแล้วผ่านเครื่องมือ Taskboard MCP ของโปรเจกต์ `homaroma` เสมอ + --- ## 🔄 Task Delegation Workflow (ขั้นตอนการทำงาน) diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts new file mode 100644 index 0000000..cafbd3f --- /dev/null +++ b/apps/backend/src/middleware/auth.ts @@ -0,0 +1,30 @@ +import { Elysia } from "elysia"; +import { jwt } from "@elysiajs/jwt"; + +export const adminOnly = new Elysia({ name: "adminOnly" }) + .use( + jwt({ + name: "jwt", + secret: process.env.JWT_SECRET || "fallback-secret" + }) + ) + .onBeforeHandle(async ({ jwt, headers, set }) => { + const auth = headers.authorization; + if (!auth?.startsWith("Bearer ")) { + set.status = 401; + return { error: "Unauthorized: Missing or invalid token format" }; + } + + const token = auth.substring(7); + const payload = await jwt.verify(token); + + if (!payload) { + set.status = 401; + return { error: "Unauthorized: Invalid token" }; + } + + if ((payload as any).role !== "ADMIN") { + set.status = 401; + return { error: "Unauthorized: Admin access required" }; + } + }); diff --git a/apps/backend/src/routes/menus.ts b/apps/backend/src/routes/menus.ts index 73144c3..b216ea3 100644 --- a/apps/backend/src/routes/menus.ts +++ b/apps/backend/src/routes/menus.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +import { adminOnly } from "../middleware/auth"; export const menusRoute = new Elysia({ prefix: "/menus" }) .get("/", async () => { @@ -26,6 +27,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) } return menu; }) + .use(adminOnly) .post("/", async ({ body, set }) => { try { const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] }; diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 014bf56..9606d32 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -7,6 +7,14 @@ import { QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema } from "@homaroma/types"; +import { adminOnly } from "../middleware/auth"; + +const RESERVED_SLUGS = [ + "shop", "category", "products", "cart", "checkout", "account", "search", + "contact", "login", "admin", "wishlist", "track", "coupons", "scent-guide", + "faq", "about", "profile", "article", "pages", "orders", "customers", + "settings", "menus", "auth", "api" +]; export const pagesRoute = new Elysia({ prefix: "/pages" }) .get("/", async () => { @@ -118,10 +126,52 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) sections: populatedSections }; }) + .get("/:slug/sections", async ({ params: { slug }, set }) => { + try { + return await prisma.pageSection.findMany({ + where: { pageId: Number(slug) }, + orderBy: { order: "asc" } + }); + } catch (err) { + set.status = 500; + return { error: "Failed to fetch page sections" }; + } + }) + .post("/newsletter", async ({ body, set }) => { + try { + const { email } = body as { email: string }; + if (!email || !email.includes("@")) { + set.status = 400; + return { error: "Invalid email address" }; + } + + const sub = await prisma.newsletterSubscription.upsert({ + where: { email }, + update: {}, + create: { email } + }); + return { success: true, data: sub }; + } catch (err) { + console.error("[Newsletter] Error:", err); + set.status = 500; + return { error: "Failed to subscribe" }; + } + }, { + body: t.Object({ + email: t.String() + }) + }) + .use(adminOnly) .post("/", async ({ body, set }) => { try { const data = body as any; const { id, slug, title, isPublished, status, metadata } = data; + + if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) { + set.status = 400; + return { error: "Reserved slug cannot be used" }; + } + let finalIsPublished = true; if (status !== undefined) { finalIsPublished = status === "published"; @@ -165,17 +215,6 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) return { error: "Failed to delete page" }; } }) - .get("/:slug/sections", async ({ params: { slug }, set }) => { - try { - return await prisma.pageSection.findMany({ - where: { pageId: Number(slug) }, - orderBy: { order: "asc" } - }); - } catch (err) { - set.status = 500; - return { error: "Failed to fetch page sections" }; - } - }) .post("/:slug/sections", async ({ params: { slug }, body, set }) => { try { const pageId = Number(slug); @@ -280,30 +319,6 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) })) }) }) - .post("/newsletter", async ({ body, set }) => { - try { - const { email } = body as { email: string }; - if (!email || !email.includes("@")) { - set.status = 400; - return { error: "Invalid email address" }; - } - - const sub = await prisma.newsletterSubscription.upsert({ - where: { email }, - update: {}, - create: { email } - }); - return { success: true, data: sub }; - } catch (err) { - console.error("[Newsletter] Error:", err); - set.status = 500; - return { error: "Failed to subscribe" }; - } - }, { - body: t.Object({ - email: t.String() - }) - }) .delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => { try { await prisma.pageSection.delete({ diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 9bb9b07..859fd7b 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +import { adminOnly } from "../middleware/auth"; export const settingsRoute = new Elysia({ prefix: "/settings" }) .get("/", async ({ set }) => { @@ -17,6 +18,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) return { error: "Failed to fetch settings" }; } }) + .use(adminOnly) .put("/", async ({ body, set }) => { try { const data = body as any; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..cec4a3a --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,44 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +} diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 71e8cb5..c20c5e7 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -260,6 +260,54 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, required: ["sectionId"] } + }, + { + name: "update_footer_content", + description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)", + inputSchema: { + type: "object", + properties: { + brandDescription: { type: "string", description: "Text for Block 1 (Brand tagline)" }, + socialLinks: { + type: "object", + description: "URLs for facebook, instagram, tiktok (e.g. { facebookUrl: '...' })", + properties: { + facebookUrl: { type: "string" }, + instagramUrl: { type: "string" }, + tiktokUrl: { type: "string" } + } + }, + exploreLinks: { + type: "array", + description: "Array of { label, url, order } for Block 2 (Explore)", + items: { + type: "object", + properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } + } + }, + collectionLinks: { + type: "array", + description: "Array of { label, url, order } for Block 3 (Collections)", + items: { + type: "object", + properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } + } + }, + supportLinks: { + type: "array", + description: "Array of { label, url, order } for Block 4 (Support/Legal)", + items: { + type: "object", + properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } + } + } + } + } + }, + { + name: "audit_missing_pages", + description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route", + inputSchema: { type: "object", properties: {} } } ], }; @@ -421,6 +469,98 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.delete(`/pages/sections/${params.sectionId}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "update_footer_content": { + const results = []; + + if (params.brandDescription !== undefined || params.socialLinks) { + const settingsPayload: any = {}; + if (params.brandDescription !== undefined) settingsPayload.footerDescription = params.brandDescription; + if (params.socialLinks?.facebookUrl !== undefined) settingsPayload.facebookUrl = params.socialLinks.facebookUrl; + if (params.socialLinks?.instagramUrl !== undefined) settingsPayload.instagramUrl = params.socialLinks.instagramUrl; + if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl; + + if (Object.keys(settingsPayload).length > 0) { + // First fetch existing settings because the backend requires full payload validation + const currentSettingsRes = await api.get("/settings"); + const currentSettings = currentSettingsRes.data || {}; + + const mergedSettings = { ...currentSettings, ...settingsPayload }; + // Remove null values so Elysia validation doesn't fail on t.Optional(t.String()) + for (const key in mergedSettings) { + if (mergedSettings[key] === null) { + delete mergedSettings[key]; + } + } + await api.put("/settings", mergedSettings); + results.push({ type: "settings", status: "success" }); + } + } + + if (params.exploreLinks) { + await api.put("/menus/footer-explore", { items: params.exploreLinks }); + results.push({ type: "explore_links", status: "success" }); + } + + if (params.collectionLinks) { + await api.put("/menus/footer-collections", { items: params.collectionLinks }); + results.push({ type: "collection_links", status: "success" }); + } + + if (params.supportLinks) { + await api.put("/menus/footer-support", { items: params.supportLinks }); + results.push({ type: "support_links", status: "success" }); + } + + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + } + case "audit_missing_pages": { + // 1. Fetch all menus + const menusRes = await api.get("/menus"); + const menus = menusRes.data || []; + + // 2. Fetch all dynamic pages + const pagesRes = await api.get("/pages"); + const pages = pagesRes.data || []; + const createdSlugs = pages.map((p: any) => `/${p.slug}`); + + // 3. Known SPA Hardcoded Routes + const hardcodedRoutes = [ + "/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact" + ]; + + const missingLinks: any[] = []; + + // 4. Audit + menus.forEach((menu: any) => { + if (menu.items && Array.isArray(menu.items)) { + menu.items.forEach((item: any) => { + const url = item.url; + if (!url) return; + + // Skip external links + if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return; + + // Skip SPA Hardcoded routes + if (hardcodedRoutes.includes(url)) return; + + // Skip dynamic collection routes natively handled by SPA (e.g. /collections/...) + if (url.startsWith("/collections/")) return; + + // Check if it exists in created dynamic pages + if (!createdSlugs.includes(url)) { + missingLinks.push({ + menuName: menu.name, + label: item.label, + url: url, + suggestedSlug: url.startsWith("/") ? url.substring(1) : url + }); + } + }); + } + }); + + return { content: [{ type: "text", text: JSON.stringify({ missingPages: missingLinks }, null, 2) }] }; + } default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/apps/spa/src/App.tsx b/apps/spa/src/App.tsx index 85fce9f..6a6e8ab 100644 --- a/apps/spa/src/App.tsx +++ b/apps/spa/src/App.tsx @@ -52,8 +52,8 @@ function App() { } /> } /> } /> - } /> } /> + } /> } /> diff --git a/apps/spa/src/components/Footer.tsx b/apps/spa/src/components/Footer.tsx index 361a50b..6eab078 100644 --- a/apps/spa/src/components/Footer.tsx +++ b/apps/spa/src/components/Footer.tsx @@ -9,40 +9,8 @@ interface FooterProps { export default function Footer({ categories = [] }: FooterProps) { const { t } = useTranslation() const [exploreMenu, setExploreMenu] = useState([]) + const [supportMenu, setSupportMenu] = useState([]) const [settings, setSettings] = useState(null) - - const [email, setEmail] = useState('') - const [subscribing, setSubscribing] = useState(false) - const [subscribeStatus, setSubscribeStatus] = useState<'idle' | 'success' | 'error'>('idle') - const [subscribeMessage, setSubscribeMessage] = useState('') - - const handleSubscribe = async (e: React.FormEvent) => { - e.preventDefault() - if (!email) return - setSubscribing(true) - setSubscribeStatus('idle') - try { - const res = await fetch(`${API_BASE}/newsletter/subscribe`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }) - }) - const data = await res.json() - if (res.ok) { - setSubscribeStatus('success') - setSubscribeMessage(data.message || 'Subscribed!') - setEmail('') - } else { - setSubscribeStatus('error') - setSubscribeMessage(data.error || data.message || 'Failed to subscribe') - } - } catch (err) { - setSubscribeStatus('error') - setSubscribeMessage('An error occurred. Please try again.') - } finally { - setSubscribing(false) - } - } useEffect(() => { fetch(`${API_BASE}/menus/footer-explore`) @@ -52,6 +20,13 @@ export default function Footer({ categories = [] }: FooterProps) { }) .catch(console.error) + fetch(`${API_BASE}/menus/footer-support`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data && data.items) setSupportMenu(data.items.sort((a: any, b: any) => a.order - b.order)) + }) + .catch(console.error) + fetch(`${API_BASE}/settings`) .then(r => r.ok ? r.json() : null) .then(data => { @@ -124,46 +99,19 @@ export default function Footer({ categories = [] }: FooterProps) { -
-

- {settings?.newsletterTitle || 'Newsletter'} -

-

- {settings?.newsletterDescription || 'Subscribe for exclusive releases and sensory wisdom.'} -

- - {subscribeStatus === 'success' ? ( -
- ✨ {subscribeMessage} -
- ) : ( -
-
- setEmail(e.target.value)} - placeholder={settings?.newsletterPlaceholder || "Your email"} - className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600" - /> - -
- {subscribeStatus === 'error' && ( -

{subscribeMessage}

- )} -
- )} +
+

{t('footer.support', 'Support & Legal')}

+
+ {supportMenu.map(item => ( + {item.label} + ))} +
-

- © {new Date().getFullYear()} HomAroma. {t('footer.rights')} -

+
+

+ © {new Date().getFullYear()} HomAroma. {t('footer.rights')} +

+
diff --git a/apps/spa/src/pages/CustomPage.tsx b/apps/spa/src/pages/CustomPage.tsx index d95557c..040f001 100644 --- a/apps/spa/src/pages/CustomPage.tsx +++ b/apps/spa/src/pages/CustomPage.tsx @@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom'; import Navbar from '../components/Navbar'; import Footer from '../components/Footer'; import DynamicPageRenderer from '../components/DynamicPageRenderer'; +import NotFoundPage from './NotFoundPage'; import { API_BASE, type Category, type PageSection } from '../types'; export default function CustomPage() { @@ -78,20 +79,7 @@ export default function CustomPage() {
) : error ? ( -
-

- Sensory Page Not Found -

-

- The page you are looking for does not exist or may have been moved. -

- - Return to Home - -
+ ) : (
0 ? 'pt-[32px] md:pt-[40px]' : ''}`}>