Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f7bc0e01c | ||
|
|
44d9f1f198 | ||
|
|
9107a64637 | ||
|
|
b27afb4561 |
No files matched your search
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { adminOnly } from "../middleware/auth";
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGS,
|
SUPPORTED_LANGS,
|
||||||
localizeAttributeDefinition,
|
localizeAttributeDefinition,
|
||||||
@@ -158,6 +159,36 @@ export const productAttributesRoute = new Elysia()
|
|||||||
return status(400, { message: err.message });
|
return status(400, { message: err.message });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.get("/products/:id/attributes", async ({ params, query, status }) => {
|
||||||
|
try {
|
||||||
|
const productId = parseId(params.id, "product ID");
|
||||||
|
const lang = (query as any).lang || 'th';
|
||||||
|
const product = await prisma.product.findUnique({ where: { id: productId } });
|
||||||
|
if (!product) return status(404, { message: "Product not found" });
|
||||||
|
|
||||||
|
const attributes = await prisma.productAttribute.findMany({
|
||||||
|
where: { productId },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
attributeDefinition: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ attributeDefinition: { sortOrder: 'asc' } },
|
||||||
|
{ id: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
|
||||||
|
} catch (err: any) {
|
||||||
|
return status(400, { message: err.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.use(adminOnly)
|
||||||
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
|
.post("/product-attributes/definitions/bulk-import", async ({ body, status }) => {
|
||||||
try {
|
try {
|
||||||
const payload = body as any;
|
const payload = body as any;
|
||||||
@@ -351,35 +382,6 @@ export const productAttributesRoute = new Elysia()
|
|||||||
}, {
|
}, {
|
||||||
body: t.Any(),
|
body: t.Any(),
|
||||||
})
|
})
|
||||||
.get("/products/:id/attributes", async ({ params, query, status }) => {
|
|
||||||
try {
|
|
||||||
const productId = parseId(params.id, "product ID");
|
|
||||||
const lang = (query as any).lang || 'th';
|
|
||||||
const product = await prisma.product.findUnique({ where: { id: productId } });
|
|
||||||
if (!product) return status(404, { message: "Product not found" });
|
|
||||||
|
|
||||||
const attributes = await prisma.productAttribute.findMany({
|
|
||||||
where: { productId },
|
|
||||||
include: {
|
|
||||||
translations: true,
|
|
||||||
attributeDefinition: {
|
|
||||||
include: {
|
|
||||||
category: true,
|
|
||||||
translations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{ attributeDefinition: { sortOrder: 'asc' } },
|
|
||||||
{ id: 'asc' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return attributes.map((attribute: any) => localizeProductAttribute(attribute, lang));
|
|
||||||
} catch (err: any) {
|
|
||||||
return status(400, { message: err.message });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.post("/products/:id/attributes", async ({ params, body, status }) => {
|
.post("/products/:id/attributes", async ({ params, body, status }) => {
|
||||||
try {
|
try {
|
||||||
const productId = parseId(params.id, "product ID");
|
const productId = parseId(params.id, "product ID");
|
||||||
|
|||||||
+199
-203
@@ -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" });
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
);
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
let prisma: any;
|
||||||
|
try {
|
||||||
|
const mod = await import("../apps/backend/src/lib/prisma");
|
||||||
|
prisma = mod.default;
|
||||||
|
} catch {
|
||||||
|
const mod = await import("./src/lib/prisma");
|
||||||
|
prisma = mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunOptions {
|
||||||
|
deleteOldBrands?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reassignProductsToHomAroma(options: RunOptions = {}) {
|
||||||
|
console.log("=== Reassign Products to 'Hom Aroma' Brand ===");
|
||||||
|
|
||||||
|
// 1. Find or create 'Hom Aroma' brand
|
||||||
|
let homAroma = await prisma.productBrand.findFirst({
|
||||||
|
where: { name: { equals: "Hom Aroma", mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!homAroma) {
|
||||||
|
homAroma = await prisma.productBrand.create({
|
||||||
|
data: { name: "Hom Aroma" },
|
||||||
|
});
|
||||||
|
console.log(`✓ Created 'Hom Aroma' brand with ID: ${homAroma.id}`);
|
||||||
|
} else {
|
||||||
|
console.log(`✓ Found existing 'Hom Aroma' brand with ID: ${homAroma.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure translations for Hom Aroma
|
||||||
|
const translations = [
|
||||||
|
{ langCode: "th", name: "Hom Aroma" },
|
||||||
|
{ langCode: "en", name: "Hom Aroma" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tr of translations) {
|
||||||
|
await prisma.productBrandTranslation.upsert({
|
||||||
|
where: {
|
||||||
|
brandId_langCode: {
|
||||||
|
brandId: homAroma.id,
|
||||||
|
langCode: tr.langCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
brandId: homAroma.id,
|
||||||
|
langCode: tr.langCode,
|
||||||
|
name: tr.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: tr.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("✓ Brand translations ensured for 'th' and 'en'.");
|
||||||
|
|
||||||
|
// 3. Reassign all products to Hom Aroma
|
||||||
|
const totalProducts = await prisma.product.count();
|
||||||
|
const updateResult = await prisma.product.updateMany({
|
||||||
|
data: {
|
||||||
|
brandId: homAroma.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ Reassigned ${updateResult.count} of ${totalProducts} products to Brand '${homAroma.name}' (ID: ${homAroma.id}).`);
|
||||||
|
|
||||||
|
// 4. Handle old brands
|
||||||
|
const otherBrands = await prisma.productBrand.findMany({
|
||||||
|
where: { id: { not: homAroma.id } },
|
||||||
|
include: { _count: { select: { products: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${otherBrands.length} other brands in database.`);
|
||||||
|
|
||||||
|
if (options.deleteOldBrands && otherBrands.length > 0) {
|
||||||
|
console.log(`Deleting ${otherBrands.length} old scent brand records...`);
|
||||||
|
const delResult = await prisma.productBrand.deleteMany({
|
||||||
|
where: { id: { not: homAroma.id } },
|
||||||
|
});
|
||||||
|
console.log(`✓ Deleted ${delResult.count} old scent brands successfully.`);
|
||||||
|
} else if (!options.deleteOldBrands) {
|
||||||
|
console.log("Old brands preserved (deleteOldBrands is false).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Final summary verification
|
||||||
|
const finalHomAromaProducts = await prisma.product.count({
|
||||||
|
where: { brandId: homAroma.id },
|
||||||
|
});
|
||||||
|
const remainingBrands = await prisma.productBrand.count();
|
||||||
|
|
||||||
|
console.log("\n=== Final Summary ===");
|
||||||
|
console.log(`Total Products linked to 'Hom Aroma': ${finalHomAromaProducts} / ${totalProducts}`);
|
||||||
|
console.log(`Total Brands remaining in database: ${remainingBrands}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
homAromaId: homAroma.id,
|
||||||
|
totalProducts,
|
||||||
|
reassignedCount: updateResult.count,
|
||||||
|
otherBrandsCount: otherBrands.length,
|
||||||
|
remainingBrandsCount: remainingBrands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed directly
|
||||||
|
if (import.meta.main) {
|
||||||
|
const shouldDelete = process.argv.includes("--delete-old");
|
||||||
|
reassignProductsToHomAroma({ deleteOldBrands: shouldDelete })
|
||||||
|
.then((res) => {
|
||||||
|
console.log("\nExecution completed:", res);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("\nExecution failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user