fix(security): enforce admin auth guards across sensitive backend routes and eliminate BOM data leak

This commit is contained in:
Your Name committed 2026-10-19 06:44:26 +07:00
1 parent b27afb4561
commit d930397d47
27 files changed
+702 -525

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) => {
+4 -2
View File
@@ -1,6 +1,8 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
export const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
export async function requireAdmin({ jwt, headers, set }: any) { export async function requireAdmin({ jwt, headers, set }: any) {
const auth = headers.authorization; const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) { if (!auth?.startsWith("Bearer ")) {
@@ -26,7 +28,7 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "fallback-secret" secret: JWT_SECRET,
}) })
) )
.onBeforeHandle(requireAdmin); .onBeforeHandle({ as: "scoped" }, requireAdmin);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const articlesRoute = new Elysia({ prefix: "/articles" }) export const articlesRoute = new Elysia({ prefix: "/articles" })
.get("/", async ({ query, set }) => { .get("/", async ({ query, set }) => {
@@ -46,6 +47,7 @@ export const articlesRoute = new Elysia({ prefix: "/articles" })
return { error: "Failed to fetch article" }; return { error: "Failed to fetch article" };
} }
}) })
.use(adminOnly)
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try { try {
const data = body as any; const data = body as any;
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { productsRoute } from "./products";
import { categoriesRoute } from "./categories";
import { categorySectionsRoute } from "./categorySections";
import { contactsRoute } from "./contacts";
import { newsletterRoute } from "./newsletter";
import { mediaRoute } from "./media";
import { JWT_SECRET } from "../middleware/auth";
import { jwt } from "@elysiajs/jwt";
import { Elysia } from "elysia";
const signer = new Elysia().use(jwt({ name: "jwt", secret: JWT_SECRET }));
async function req(app: any, path: string, method: string = "GET", body?: any, token?: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
return await app.handle(
new Request(`http://localhost${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
);
}
describe("Backend Auth Guard Security Enforcement", () => {
let adminToken: string;
test("generate admin token for testing", async () => {
adminToken = await (signer as any).decorator.jwt.sign({ role: "ADMIN" });
expect(adminToken).toBeDefined();
});
describe("Products routes", () => {
test("GET /products/brands is public", async () => {
const res = await req(productsRoute, "/products/brands");
expect(res.status).toBe(200);
});
test("GET /products/bom-status rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/bom-status");
expect(res.status).toBe(401);
const json = await res.json();
expect(json.error).toContain("Unauthorized");
});
test("GET /products/bom-status allows authenticated admin", async () => {
const res = await req(productsRoute, "/products/bom-status", "GET", undefined, adminToken);
expect(res.status).toBe(200);
});
test("POST /products rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /products/brands rejects unauthenticated request with 401", async () => {
const res = await req(productsRoute, "/products/brands", "POST", { name: "TestBrand" });
expect(res.status).toBe(401);
});
});
describe("Categories routes", () => {
test("GET /categories is public", async () => {
const res = await req(categoriesRoute, "/categories");
expect(res.status).toBe(200);
});
test("POST /categories rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories", "POST", { name: "Test" });
expect(res.status).toBe(401);
});
test("POST /categories/bulk-import rejects unauthenticated request with 401", async () => {
const res = await req(categoriesRoute, "/categories/bulk-import", "POST", []);
expect(res.status).toBe(401);
});
});
describe("Category Sections routes", () => {
test("POST /categories/1/sections/seed-default rejects unauthenticated with 401", async () => {
const res = await req(categorySectionsRoute, "/categories/1/sections/seed-default", "POST");
expect(res.status).toBe(401);
});
});
describe("Contacts routes", () => {
test("GET /contacts rejects unauthenticated request with 401", async () => {
const res = await req(contactsRoute, "/contacts");
expect(res.status).toBe(401);
});
});
describe("Newsletter routes", () => {
test("GET /newsletter/subscribers rejects unauthenticated request with 401", async () => {
const res = await req(newsletterRoute, "/newsletter/subscribers");
expect(res.status).toBe(401);
});
});
describe("Media routes", () => {
test("GET /media rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media");
expect(res.status).toBe(401);
});
test("DELETE /media/1 rejects unauthenticated request with 401", async () => {
const res = await req(mediaRoute, "/media/1", "DELETE");
expect(res.status).toBe(401);
});
});
});
+2 -1
View File
@@ -2,12 +2,13 @@ import { Elysia, t } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { JWT_SECRET } from "../middleware/auth";
export const authRoute = new Elysia({ prefix: "/auth" }) export const authRoute = new Elysia({ prefix: "/auth" })
.use( .use(
jwt({ jwt({
name: "jwt", name: "jwt",
secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent", secret: JWT_SECRET,
exp: "24h", exp: "24h",
}) })
) )
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces";
function extractShopeeAddress(order: any): { province: string; district: string } { function extractShopeeAddress(order: any): { province: string; district: string } {
@@ -53,6 +54,7 @@ function buildStatusFilter(statusFilter: string = "completed") {
} }
export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" })
.use(adminOnly)
// ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ──────────
.get("/sales-map", async ({ query }) => { .get("/sales-map", async ({ query }) => {
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
+116 -114
View File
@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeCategories } from "../lib/catalogMaintenance"; import { purgeCategories } from "../lib/catalogMaintenance";
import { adminOnly } from "../middleware/auth";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const; const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
@@ -118,120 +119,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: resolveCatName(cat, lang), name: resolveCatName(cat, lang),
})); }));
}) })
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.get("/:id", async ({ params, query, status }) => { .get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
const lang = (query as any).lang || 'all'; const lang = (query as any).lang || 'all';
@@ -387,6 +274,121 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
}, },
}; };
}) })
.use(adminOnly)
.post("/bulk-import", async ({ body, status }) => {
try {
const raw = body as any;
const list = Array.isArray(raw) ? raw : (Array.isArray(raw.categories) ? raw.categories : []);
if (list.length === 0) {
return status(400, { message: "No categories provided in array" });
}
const results = [];
let createdCount = 0;
let updatedCount = 0;
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
if (item.id && typeof item.id === 'number') {
category = await prisma.category.findUnique({
where: { id: item.id, deletedAt: null },
});
}
if (!category && baseName) {
category = await prisma.category.findFirst({
where: { name: baseName, deletedAt: null },
});
}
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
if (Object.keys(updateData).length > 0) {
category = await prisma.category.update({
where: { id: category.id },
data: updateData,
});
}
updatedCount++;
} else {
// Create new
category = await prisma.category.create({
data: {
name: baseName,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
? translations[lang]
: (translations[lang]?.name || (lang === 'th' ? baseName : null));
if (transName && typeof transName === 'string' && transName.trim() !== '') {
await prisma.categoryTranslation.upsert({
where: {
categoryId_langCode: {
categoryId: category.id,
langCode: lang,
},
},
update: { name: transName.trim() },
create: {
categoryId: category.id,
langCode: lang,
name: transName.trim(),
},
});
}
}
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
}
return {
success: true,
count: results.length,
createdCount,
updatedCount,
categories: results,
};
} catch (err: any) {
console.error("Bulk import categories error:", err);
return status(500, { message: err?.message || "Failed to bulk import categories" });
}
})
.patch("/:id/translations", async ({ params, body, status }) => { .patch("/:id/translations", async ({ params, body, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
@@ -7,6 +7,7 @@ import {
validateCategorySectionPayload, validateCategorySectionPayload,
} from "../lib/categorySections"; } from "../lib/categorySections";
import { hydrateSectionPayload } from "./pages"; import { hydrateSectionPayload } from "./pages";
import { adminOnly } from "../middleware/auth";
function summarizeValidationIssues(issues: any[]) { function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({ return issues.map((issue) => ({
@@ -43,6 +44,8 @@ export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
); );
}) })
.use(adminOnly)
// Seed default sections // Seed default sections
.post("/:id/sections/seed-default", async ({ params, status }) => { .post("/:id/sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma.js"; import prisma from "../lib/prisma.js";
import { adminOnly } from "../middleware/auth";
export const contactsRoute = new Elysia({ prefix: "/contacts" }) export const contactsRoute = new Elysia({ prefix: "/contacts" })
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
@@ -27,6 +28,7 @@ export const contactsRoute = new Elysia({ prefix: "/contacts" })
message: t.String({ minLength: 1 }) message: t.String({ minLength: 1 })
}) })
}) })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
return await prisma.contactMessage.findMany({ return await prisma.contactMessage.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+90 -84
View File
@@ -1,13 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const couponsRoute = new Elysia({ prefix: "/coupons" }) export const couponsRoute = new Elysia({ prefix: "/coupons" })
// Get all coupons (Admin) // ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/", async () => {
return await prisma.coupon.findMany({
orderBy: { createdAt: 'desc' }
});
})
// Get active coupons (Storefront) // Get active coupons (Storefront)
.get("/active", async () => { .get("/active", async () => {
const now = new Date(); const now = new Date();
@@ -22,12 +18,98 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
}) })
// Validate coupon (Storefront)
.post("/validate", async ({ body, set }) => {
try {
const { code, cartTotal } = body as any;
const upperCode = code.toUpperCase();
const coupon = await prisma.coupon.findUnique({
where: { code: upperCode }
});
if (!coupon) {
set.status = 404;
return { valid: false, error: "Coupon not found" };
}
if (!coupon.isActive) {
set.status = 400;
return { valid: false, error: "Coupon is no longer active" };
}
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
set.status = 400;
return { valid: false, error: "Coupon has expired" };
}
if (coupon.usageLimit && coupon.usedCount >= coupon.usageLimit) {
set.status = 400;
return { valid: false, error: "Coupon usage limit reached" };
}
if (cartTotal < coupon.minPurchase) {
set.status = 400;
return { valid: false, error: `Minimum purchase of ฿${coupon.minPurchase} required` };
}
// Calculate discount amount
let discountAmount = 0;
if (coupon.type === "PERCENTAGE") {
discountAmount = cartTotal * (coupon.value / 100);
if (coupon.maxDiscount && discountAmount > coupon.maxDiscount) {
discountAmount = coupon.maxDiscount;
}
} else if (coupon.type === "FIXED_AMOUNT") {
discountAmount = coupon.value;
} else if (coupon.type === "FREE_SHIPPING") {
return {
valid: true,
coupon,
discountAmount: 0,
isFreeShipping: true
};
}
if (discountAmount > cartTotal) {
discountAmount = cartTotal;
}
return {
valid: true,
coupon,
discountAmount,
isFreeShipping: false
};
} catch (err) {
console.error("[Coupons] Validate Error:", err);
set.status = 500;
return { error: "Failed to validate coupon" };
}
}, {
body: t.Object({
code: t.String(),
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) // Create new coupon (Admin)
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try { try {
const data = body as any; const data = body as any;
// Check if code already exists
const existing = await prisma.coupon.findUnique({ const existing = await prisma.coupon.findUnique({
where: { code: data.code.toUpperCase() } where: { code: data.code.toUpperCase() }
}); });
@@ -114,80 +196,4 @@ export const couponsRoute = new Elysia({ prefix: "/coupons" })
return { error: "Failed to delete coupon" }; return { error: "Failed to delete coupon" };
} }
}) })
// Validate coupon (Storefront) );
.post("/validate", async ({ body, set }) => {
try {
const { code, cartTotal } = body as any;
const upperCode = code.toUpperCase();
const coupon = await prisma.coupon.findUnique({
where: { code: upperCode }
});
if (!coupon) {
set.status = 404;
return { valid: false, error: "Coupon not found" };
}
if (!coupon.isActive) {
set.status = 400;
return { valid: false, error: "Coupon is no longer active" };
}
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
set.status = 400;
return { valid: false, error: "Coupon has expired" };
}
if (coupon.usageLimit && coupon.usedCount >= coupon.usageLimit) {
set.status = 400;
return { valid: false, error: "Coupon usage limit reached" };
}
if (cartTotal < coupon.minPurchase) {
set.status = 400;
return { valid: false, error: `Minimum purchase of ฿${coupon.minPurchase} required` };
}
// Calculate discount amount
let discountAmount = 0;
if (coupon.type === "PERCENTAGE") {
discountAmount = cartTotal * (coupon.value / 100);
if (coupon.maxDiscount && discountAmount > coupon.maxDiscount) {
discountAmount = coupon.maxDiscount;
}
} else if (coupon.type === "FIXED_AMOUNT") {
discountAmount = coupon.value;
} else if (coupon.type === "FREE_SHIPPING") {
// We handle free shipping separately, returning discountAmount = 0 but a flag
return {
valid: true,
coupon,
discountAmount: 0,
isFreeShipping: true
};
}
// Ensure discount doesn't exceed cart total
if (discountAmount > cartTotal) {
discountAmount = cartTotal;
}
return {
valid: true,
coupon,
discountAmount,
isFreeShipping: false
};
} catch (err) {
console.error("[Coupons] Validate Error:", err);
set.status = 500;
return { error: "Failed to validate coupon" };
}
}, {
body: t.Object({
code: t.String(),
cartTotal: t.Number()
})
});
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const crmRoute = new Elysia({ prefix: "/crm" }) export const crmRoute = new Elysia({ prefix: "/crm" })
.use(adminOnly)
// ── 1. Import Single / Multiple Customers ───────────────────────────────── // ── 1. Import Single / Multiple Customers ─────────────────────────────────
.post( .post(
"/import", "/import",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const customersRoute = new Elysia({ prefix: "/customers" }) export const customersRoute = new Elysia({ prefix: "/customers" })
.use(adminOnly)
.get("/", async ({ query }) => { .get("/", async ({ query }) => {
const search = query.search as string | undefined; const search = query.search as string | undefined;
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
const toNum = (val: any): number => { const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0; if (val === null || val === undefined || val === "") return 0;
@@ -26,6 +27,7 @@ const parseLazadaDate = (val: any): string => {
}; };
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" }) export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
.use(adminOnly)
// ── Import (Excel rows as JSON) ─────────────────────────────────────────── // ── Import (Excel rows as JSON) ───────────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const mediaRoute = new Elysia({ prefix: "/media" }) export const mediaRoute = new Elysia({ prefix: "/media" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const media = await prisma.media.findMany({ const media = await prisma.media.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const newsletterRoute = new Elysia({ prefix: "/newsletter" }) export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
.post("/subscribe", async ({ body, set }) => { .post("/subscribe", async ({ body, set }) => {
@@ -29,6 +30,7 @@ export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
email: t.String({ format: "email" }) email: t.String({ format: "email" })
}) })
}) })
.use(adminOnly)
.get("/subscribers", async () => { .get("/subscribers", async () => {
return await prisma.newsletterSubscriber.findMany({ return await prisma.newsletterSubscriber.findMany({
orderBy: { createdAt: "desc" } orderBy: { createdAt: "desc" }
+87 -83
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
sendOrderCreatedNotification, sendOrderCreatedNotification,
sendPaymentConfirmedNotification, sendPaymentConfirmedNotification,
@@ -9,12 +10,6 @@ import {
} from "../lib/notifications"; } from "../lib/notifications";
export const ordersRoute = new Elysia({ prefix: "/orders" }) export const ordersRoute = new Elysia({ prefix: "/orders" })
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
});
})
.get("/:id", async ({ params: { id }, query, set }) => { .get("/:id", async ({ params: { id }, query, set }) => {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: Number(id) }, where: { id: Number(id) },
@@ -245,54 +240,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
})) }))
}) })
}) })
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
// If status becomes CANCELLED, refund the points used and notify
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
// If status becomes SHIPPED, notify
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/slip", async ({ params: { id }, body, set }) => { .patch("/:id/slip", async ({ params: { id }, body, set }) => {
try { try {
const { paymentSlipUrl } = body as any; const { paymentSlipUrl } = body as any;
@@ -310,35 +257,6 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
paymentSlipUrl: t.String() paymentSlipUrl: t.String()
}) })
}) })
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
// Trigger notification asynchronously
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
.post("/track", async ({ body, set }) => { .post("/track", async ({ body, set }) => {
try { try {
const { orderId, phone } = body as any; const { orderId, phone } = body as any;
@@ -366,4 +284,90 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
orderId: t.Number(), orderId: t.Number(),
phone: t.String() phone: t.String()
}) })
})
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
.get("/", async () => {
return await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
include: { items: true, customer: true }
}); });
})
.patch("/:id/status", async ({ params: { id }, body, set }) => {
try {
const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
if (status === 'PAID') {
await markOrderAsPaid(order.id);
} else {
await prisma.order.update({
where: { id: order.id },
data: { status }
});
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
if (order.customerId && order.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
}
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
sendOrderShippedNotification(order.id).catch(e => console.error(e));
}
}
return await prisma.order.findUnique({
where: { id: order.id },
include: { items: true, customer: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update order status" };
}
}, {
body: t.Object({
status: t.String()
})
})
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
const order = await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo, status: "SHIPPED" },
include: { items: true }
});
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
orderBy: { createdAt: 'desc' },
include: { items: true }
});
})
);
-3
View File
@@ -1485,9 +1485,6 @@ async function resolveProductsForSection(payload: any, lang: string) {
brand: true, brand: true,
translations: true, translations: true,
variants: { variants: {
include: {
bomItems: { include: { rawMaterial: true } },
},
orderBy: { price: "asc" as const }, orderBy: { price: "asc" as const },
}, },
images: { images: {
+27 -29
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { import {
SUPPORTED_LANGS, SUPPORTED_LANGS,
localizeAttributeDefinition, localizeAttributeDefinition,
@@ -154,10 +155,36 @@ 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;
@@ -351,35 +378,6 @@ export const productAttributesRoute = new Elysia()
}, { }, {
body: t.Any(), body: t.Any(),
}) })
.get("/products/:id/attributes", async ({ params, query, status }) => {
try {
const productId = parseId(params.id, "product ID");
const lang = (query as any).lang || 'th';
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) return status(404, { message: "Product not found" });
const attributes = await prisma.productAttribute.findMany({
where: { productId },
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: 'asc' } },
{ id: 'asc' },
],
});
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
} catch (err: any) {
return status(400, { message: err.message });
}
})
.post("/products/:id/attributes", async ({ params, body, status }) => { .post("/products/:id/attributes", async ({ params, body, status }) => {
try { try {
const productId = parseId(params.id, "product ID"); const productId = parseId(params.id, "product ID");
+199 -203
View File
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { purgeProducts } from "../lib/catalogMaintenance"; import { purgeProducts } from "../lib/catalogMaintenance";
import { localizeProductAttribute } from "../lib/productAttributes"; import { localizeProductAttribute } from "../lib/productAttributes";
import { adminOnly } from "../middleware/auth";
import { import {
createCustomProductDetailSection, createCustomProductDetailSection,
createProductMainDetailSection, createProductMainDetailSection,
@@ -224,6 +225,204 @@ export const productsRoute = new Elysia({ prefix: "/products" })
} }
}); });
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.use(adminOnly)
.post("/brands", async ({ body, status }) => { .post("/brands", async ({ body, status }) => {
const payload = body as any; const payload = body as any;
const name = payload.name?.trim(); const name = payload.name?.trim();
@@ -315,122 +514,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { success: true, translations: results, brand: updated }; return { success: true, translations: results, brand: updated };
}) })
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const brandId = query.brandId ? parseInt(query.brandId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }),
...(brandId && { brandId }),
...(search && {
OR: [
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ brand: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
{ variants: { some: { OR: [
{ barcode: { contains: search, mode: "insensitive" as const } },
{ sku: { contains: search, mode: "insensitive" as const } }
] } } },
],
}),
} as const;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
// Map translations
const mappedProducts = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...p,
name: nameObj,
description: descObj,
details: detailsObj,
category: { ...p.category, name: p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = p.translations.find((t: any) => t.langCode === lang) || p.translations.find((t: any) => t.langCode === 'th') || p.translations[0];
const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0];
return {
...p,
name: trans?.name || p.name || '',
description: trans?.description || p.description,
details: trans?.details || p.details,
category: { ...p.category, name: catTrans?.name || p.category.name },
attributes: p.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: mappedProducts,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
})
.get("/bom-status", async ({ query }) => { .get("/bom-status", async ({ query }) => {
const search = query.search?.trim(); const search = query.search?.trim();
const filterStatus = query.status?.trim() || "all"; const filterStatus = query.status?.trim() || "all";
@@ -520,74 +603,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
products: filtered, products: filtered,
}; };
}) })
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: { include: { translations: true } },
brand: true,
translations: true,
attributes: {
include: {
translations: true,
attributeDefinition: {
include: {
category: true,
translations: true,
},
},
},
orderBy: [
{ attributeDefinition: { sortOrder: "asc" } },
{ id: "asc" },
],
},
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" }
},
images: { orderBy: { order: "asc" } },
},
});
const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return {
...product,
name: nameObj,
description: descObj,
details: detailsObj,
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
} else {
const trans = product.translations.find((t: any) => t.langCode === lang) || product.translations.find((t: any) => t.langCode === 'th') || product.translations[0];
const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0];
return {
...product,
name: trans?.name || product.name || '',
description: trans?.description || product.description,
details: trans?.details || product.details,
category: { ...product.category, name: catTrans?.name || product.category.name },
attributes: product.attributes.map((attribute: any) => localizeProductAttribute(attribute, lang)),
};
}
})
.get("/:id/bom", async ({ params, status }) => { .get("/:id/bom", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
@@ -845,25 +860,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined, unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
}; };
}) })
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.post("/:id/detail-sections/seed-default", async ({ params, status }) => { .post("/:id/detail-sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
// ── Date Range Helper ──────────────────────────────────────────────────────── // ── Date Range Helper ────────────────────────────────────────────────────────
interface DateRangeInfo { interface DateRangeInfo {
@@ -70,6 +71,7 @@ const toNum = (val: any): number => {
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
.use(adminOnly)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 1. Cost & Profitability Matrix (Catalog-Level Baseline) // 1. Cost & Profitability Matrix (Catalog-Level Baseline)
+10 -2
View File
@@ -1,9 +1,11 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const promotionsRoute = new Elysia({ prefix: "/promotions" }) export const promotionsRoute = new Elysia({ prefix: "/promotions" })
// ── Public Storefront Endpoints ───────────────────────────────────────────
.get("/active", async () => { .get("/active", async () => {
const now = new Date() const now = new Date();
return await prisma.promotion.findMany({ return await prisma.promotion.findMany({
where: { where: {
isActive: true, isActive: true,
@@ -18,6 +20,11 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
orderBy: { level: 'asc' } orderBy: { level: 'asc' }
}); });
}) })
// ── Protected Admin Endpoints ─────────────────────────────────────────────
.guard((app) =>
app
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
return await prisma.promotion.findMany({ return await prisma.promotion.findMany({
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }] orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
@@ -76,4 +83,5 @@ export const promotionsRoute = new Elysia({ prefix: "/promotions" })
set.status = 500; set.status = 500;
return { error: "Failed to delete promotion" }; return { error: "Failed to delete promotion" };
} }
}); })
);
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" }) export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
.use(adminOnly)
.get("/", async () => { .get("/", async () => {
const rawMaterials = await prisma.rawMaterial.findMany({ const rawMaterials = await prisma.rawMaterial.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -1,21 +1,41 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { settingsRoute } from './settings' import { settingsRoute } from './settings'
import { JWT_SECRET } from '../middleware/auth'
import { jwt } from '@elysiajs/jwt'
import { Elysia } from 'elysia'
function jsonRequest(path: string, method: string, body: unknown) { const signer = new Elysia().use(jwt({ name: 'jwt', secret: JWT_SECRET }))
function jsonRequest(path: string, method: string, body: unknown, token?: string) {
return new Request(`http://localhost${path}`, { return new Request(`http://localhost${path}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
describe('footer layout route boundaries', () => { describe('footer layout route boundaries', () => {
for (const method of ['PUT', 'POST']) { for (const method of ['PUT', 'POST']) {
test(`${method} /settings rejects footerLayout writes`, async () => { test(`${method} /settings requires admin authentication`, async () => {
const response = await settingsRoute.handle(jsonRequest('/settings', method, { const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] }, footerLayout: { columns: ['brand'] },
})) }))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
})
test(`${method} /settings with admin token rejects footerLayout writes`, async () => {
const adminToken = await (signer as any).decorator.jwt.sign({ role: 'ADMIN' })
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] },
}, adminToken))
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
error: 'Use PATCH /settings/footer-layout to update footer layout', error: 'Use PATCH /settings/footer-layout to update footer layout',
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -17,6 +18,7 @@ function extractShopeeAddress(order: any): { province: string; district: string
} }
export const shopeeRoute = new Elysia({ prefix: "/shopee" }) export const shopeeRoute = new Elysia({ prefix: "/shopee" })
.use(adminOnly)
// ── Import Orders ───────────────────────────────────────────────────────── // ── Import Orders ─────────────────────────────────────────────────────────
.post( .post(
"/import/orders", "/import/orders",
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const tiktokRoute = new Elysia({ prefix: "/tiktok" }) export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
.use(adminOnly)
.post( .post(
"/import/income", "/import/income",
async ({ body, set }) => { async ({ body, set }) => {
+2
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
@@ -234,6 +235,7 @@ async function enrichOrdersWithBOM(orders: any[], shop: string) {
} }
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
.use(adminOnly)
// ── Import Orders (CSV rows as JSON) ─────────────────────────────────────── // ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
.post( .post(
+2
View File
@@ -1,7 +1,9 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const toolsRoute = new Elysia({ prefix: "/tools" }) export const toolsRoute = new Elysia({ prefix: "/tools" })
.use(adminOnly)
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ──────────── // ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
.get("/marketplace-products/master-variants", async () => { .get("/marketplace-products/master-variants", async () => {