Author SHA1 Message Date
Your Name cf4546422d feat(website): implement PDPA cookie consent banner and script manager
- Add cookieConsentStore using Zustand with localStorage persistence and skipHydration
- Implement responsive CookieBanner with Quiet Luxury design and PDPA compliance
- Implement CookiePreferencesModal with category toggles (Necessary, Analytics, Marketing)
- Implement ScriptManager for Google Consent Mode v2, GA4, Meta Pixel, and TikTok Pixel orchestration
- Add Cookie Preferences trigger in Footer support column
- Add multilingual translation keys for Thai, English, Japanese, and Chinese
2026-10-19 05:45:36 +07:00
27 changed files with 601 additions and 778 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) => {
+2 -4
View File
@@ -1,8 +1,6 @@
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 ")) {
@@ -28,7 +26,7 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: JWT_SECRET, secret: process.env.JWT_SECRET || "fallback-secret"
}) })
) )
.onBeforeHandle({ as: "scoped" }, requireAdmin); .onBeforeHandle(requireAdmin);
-2
View File
@@ -1,6 +1,5 @@
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 }) => {
@@ -47,7 +46,6 @@ 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;
@@ -1,112 +0,0 @@
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);
});
});
});
+1 -2
View File
@@ -2,13 +2,12 @@ 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: JWT_SECRET, secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent",
exp: "24h", exp: "24h",
}) })
) )
@@ -1,6 +1,5 @@
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 } {
@@ -54,7 +53,6 @@ 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'
+114 -116
View File
@@ -2,7 +2,6 @@ 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;
@@ -119,6 +118,120 @@ 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';
@@ -274,121 +387,6 @@ 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,7 +7,6 @@ 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) => ({
@@ -44,8 +43,6 @@ 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,6 +1,5 @@
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 }) => {
@@ -28,7 +27,6 @@ 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" }
+101 -107
View File
@@ -1,9 +1,13 @@
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" })
// ── Public Storefront Endpoints ─────────────────────────────────────────── // Get all coupons (Admin)
.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();
@@ -18,7 +22,98 @@ 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 {
@@ -64,6 +159,7 @@ 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,
@@ -72,6 +168,7 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
}; };
} }
// Ensure discount doesn't exceed cart total
if (discountAmount > cartTotal) { if (discountAmount > cartTotal) {
discountAmount = cartTotal; discountAmount = cartTotal;
} }
@@ -93,107 +190,4 @@ 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,9 +1,7 @@
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,9 +1,7 @@
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,6 +1,5 @@
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;
@@ -27,7 +26,6 @@ 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,9 +1,7 @@
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,6 +1,5 @@
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 }) => {
@@ -30,7 +29,6 @@ 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" }
+84 -88
View File
@@ -1,6 +1,5 @@
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,
@@ -10,6 +9,12 @@ 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) },
@@ -240,6 +245,54 @@ 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;
@@ -257,6 +310,35 @@ 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;
@@ -284,90 +366,4 @@ 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,6 +1485,9 @@ 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: {
+29 -27
View File
@@ -1,6 +1,5 @@
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,
@@ -155,36 +154,10 @@ export const productAttributesRoute = new Elysia()
if (!definition) return status(404, { message: "Attribute definition not found" }); if (!definition) return status(404, { message: "Attribute definition not found" });
return localizeAttributeDefinition(definition, lang); return localizeAttributeDefinition(definition, lang);
.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) { } catch (err: any) {
return status(400, { message: err.message }); 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;
@@ -378,6 +351,35 @@ 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");
+203 -199
View File
@@ -3,7 +3,6 @@ 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,
@@ -225,204 +224,6 @@ 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();
@@ -514,6 +315,122 @@ 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";
@@ -603,6 +520,74 @@ 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" });
@@ -860,6 +845,25 @@ 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,6 +1,5 @@
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 {
@@ -71,7 +70,6 @@ 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)
+60 -68
View File
@@ -1,11 +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 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,
@@ -20,68 +18,62 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
orderBy: { level: 'asc' } orderBy: { level: 'asc' }
}); });
}) })
.get("/", async () => {
// ── Protected Admin Endpoints ───────────────────────────────────────────── return await prisma.promotion.findMany({
.guard((app) => orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
app });
.use(adminOnly) })
.get("/", async () => { .post("/", async ({ body, set }) => {
return await prisma.promotion.findMany({ try {
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }] const data = body as any;
}); return await prisma.promotion.create({ data });
}) } catch (err) {
.post("/", async ({ body, set }) => { set.status = 500;
try { return { error: "Failed to create promotion" };
const data = body as any; }
return await prisma.promotion.create({ data }); }, {
} catch (err) { body: t.Object({
set.status = 500; name: t.String(),
return { error: "Failed to create promotion" }; level: t.Number(),
} type: t.String(),
}, { value: t.Number(),
body: t.Object({ minPurchase: t.Number(),
name: t.String(), isActive: t.Boolean(),
level: t.Number(), startDate: t.Optional(t.Nullable(t.String())),
type: t.String(), endDate: t.Optional(t.Nullable(t.String()))
value: t.Number(), })
minPurchase: t.Number(), })
isActive: t.Boolean(), .put("/:id", async ({ params: { id }, body, set }) => {
startDate: t.Optional(t.Nullable(t.String())), try {
endDate: t.Optional(t.Nullable(t.String())) const data = body as any;
}) return await prisma.promotion.update({
}) where: { id: Number(id) },
.put("/:id", async ({ params: { id }, body, set }) => { data
try { });
const data = body as any; } catch (err) {
return await prisma.promotion.update({ set.status = 500;
where: { id: Number(id) }, return { error: "Failed to update promotion" };
data }
}); }, {
} catch (err) { body: t.Object({
set.status = 500; name: t.String(),
return { error: "Failed to update promotion" }; level: t.Number(),
} type: t.String(),
}, { value: t.Number(),
body: t.Object({ minPurchase: t.Number(),
name: t.String(), isActive: t.Boolean(),
level: t.Number(), startDate: t.Optional(t.Nullable(t.String())),
type: t.String(), endDate: t.Optional(t.Nullable(t.String()))
value: t.Number(), })
minPurchase: t.Number(), })
isActive: t.Boolean(), .delete("/:id", async ({ params: { id }, set }) => {
startDate: t.Optional(t.Nullable(t.String())), try {
endDate: t.Optional(t.Nullable(t.String())) await prisma.promotion.delete({
}) where: { id: Number(id) }
}) });
.delete("/:id", async ({ params: { id }, set }) => { return { success: true };
try { } catch (err) {
await prisma.promotion.delete({ set.status = 500;
where: { id: Number(id) } return { error: "Failed to delete promotion" };
}); }
return { success: true }; });
} catch (err) {
set.status = 500;
return { error: "Failed to delete promotion" };
}
})
);
-2
View File
@@ -1,9 +1,7 @@
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,41 +1,21 @@
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'
const signer = new Elysia().use(jwt({ name: 'jwt', secret: JWT_SECRET })) function jsonRequest(path: string, method: string, body: unknown) {
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: { headers: { 'content-type': 'application/json' },
'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 requires admin authentication`, async () => { test(`${method} /settings rejects footerLayout writes`, 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,6 +1,5 @@
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 => {
@@ -18,7 +17,6 @@ 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,9 +1,7 @@
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,6 +1,5 @@
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 => {
@@ -235,7 +234,6 @@ 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,9 +1,7 @@
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 () => {