diff --git a/.DS_Store b/.DS_Store index 2ebc3725..f7bf7757 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/microservices/.gitignore b/microservices/.gitignore index ae4d4909..a6ff3d00 100644 --- a/microservices/.gitignore +++ b/microservices/.gitignore @@ -93,6 +93,10 @@ infra/traefik/certs/* *storybook.log storybook-static +# Local MVP app secrets +apps/tpos-mvp-next/.env.local +apps/tpos-mvp-next/.env*.local + # MAUI obj bin diff --git a/microservices/apps/tpos-mvp-next/.env.example b/microservices/apps/tpos-mvp-next/.env.example new file mode 100644 index 00000000..209e4dd5 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/.env.example @@ -0,0 +1,28 @@ +DATABASE_URL="postgresql://user:password@host:5432/database" +DATABASE_SSL="false" +SESSION_SECRET="replace-with-32-byte-secret" +AI_DEFAULT_PROVIDER="openai" +OPENAI_API_KEY="" +OPENAI_MODEL="gpt-5.1" +ANTHROPIC_API_KEY="" +ANTHROPIC_MODEL="claude-sonnet-4-5-20250929" +OPENROUTER_API_KEY="" +OPENROUTER_MODEL="openai/gpt-5.1" +S3_ENDPOINT="" +S3_REGION="us-east-1" +S3_BUCKET="" +S3_ACCESS_KEY_ID="" +S3_SECRET_ACCESS_KEY="" +S3_PUBLIC_BASE_URL="" +FACEBOOK_APP_ID="" +FACEBOOK_APP_SECRET="" +FACEBOOK_PAGE_ID="" +FACEBOOK_PAGE_TOKEN="" +ZALO_OA_ID="" +ZALO_OA_ACCESS_TOKEN="" +WHATSAPP_ACCESS_TOKEN="" +WHATSAPP_PHONE_NUMBER_ID="" +X_API_KEY="" +X_API_SECRET="" +X_ACCESS_TOKEN="" +X_ACCESS_SECRET="" diff --git a/microservices/apps/tpos-mvp-next/README.md b/microservices/apps/tpos-mvp-next/README.md new file mode 100644 index 00000000..a8ed2487 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/README.md @@ -0,0 +1,36 @@ +# GoodGo TPOS MVP Next.js + +Next.js/TypeScript MVP that consolidates the core GoodGo microservices into one deployable app: + +- Merchant + Shop management +- Catalog + category management +- POS checkout +- Orders + payment metadata +- Inventory and stock movements +- FnB tables +- Dashboard and health check + +The app uses PostgreSQL directly and bootstraps the core tables idempotently on startup. Keep `DATABASE_URL` outside git; use the connection string provided for this project in your shell or local environment. + +```bash +cd microservices/apps/tpos-mvp-next +DATABASE_URL="..." pnpm db:setup +DATABASE_URL="..." pnpm db:seed +DATABASE_URL="..." pnpm dev +``` + +Default dev URL: `http://localhost:3010`. + +## MVP service mapping + +This app keeps the public workflow close to the current microservices while deploying as one Next.js app: + +- IAM/Merchant: workspace, shop, branch/staff-ready boundaries. +- Catalog: products, categories, SKU/barcode lookup surface. +- Inventory: stock items, stock transactions, low-stock control. +- Order: POS checkout, paid orders, discounts, cash tender/change. +- FnB Engine: tables/rooms and table status for cafe/restaurant/karaoke. +- Wallet/Promotion: MVP payment ledger and manual voucher/discount metadata. +- Booking: deferred domain for spa/beauty appointments, represented by service product types for now. + +The BFF-compatible route group is `/api/bff/[...path]`; direct checkout uses `/api/orders`. diff --git a/microservices/apps/tpos-mvp-next/next-env.d.ts b/microservices/apps/tpos-mvp-next/next-env.d.ts new file mode 100644 index 00000000..9edff1c7 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/microservices/apps/tpos-mvp-next/next.config.mjs b/microservices/apps/tpos-mvp-next/next.config.mjs new file mode 100644 index 00000000..8fc0f79c --- /dev/null +++ b/microservices/apps/tpos-mvp-next/next.config.mjs @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + turbopack: { + root + } +}; + +export default nextConfig; diff --git a/microservices/apps/tpos-mvp-next/package.json b/microservices/apps/tpos-mvp-next/package.json new file mode 100644 index 00000000..f4b81391 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/package.json @@ -0,0 +1,27 @@ +{ + "name": "@goodgo/tpos-mvp-next", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev -p 3010", + "build": "next build", + "start": "next start -p 3010", + "typecheck": "tsc --noEmit", + "db:setup": "tsx scripts/setup-db.ts", + "db:seed": "tsx scripts/seed.ts" + }, + "dependencies": { + "lucide-react": "^1.16.0", + "next": "^16.2.6", + "pg": "^8.21.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/pg": "^8.20.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3" + } +} diff --git a/microservices/apps/tpos-mvp-next/public/favicon.svg b/microservices/apps/tpos-mvp-next/public/favicon.svg new file mode 100644 index 00000000..4f95843a --- /dev/null +++ b/microservices/apps/tpos-mvp-next/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/microservices/apps/tpos-mvp-next/scripts/seed.ts b/microservices/apps/tpos-mvp-next/scripts/seed.ts new file mode 100644 index 00000000..735a25f7 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/scripts/seed.ts @@ -0,0 +1,100 @@ +import { getPool } from "../src/server/db/pool"; +import { + createCategory, + createInventoryItem, + createProduct, + createShop, + createTable, + listInventory, + listCategories, + listProducts, + listShops, + listTables +} from "../src/server/db/queries"; +import { seedParityData } from "../src/server/services/parity"; + +const shops = await listShops(); +const shop = + shops[0] ?? + (await createShop({ + name: "GoodGo Cafe MVP", + vertical: "cafe", + phone: "0900000000", + email: "ops@goodgo.vn", + description: "MVP shop created from Next.js seed" + })); + +let categories = await listCategories(shop.id); +if (categories.length === 0) { + categories = [ + await createCategory({ shopId: shop.id, name: "Coffee", displayOrder: 1 }), + await createCategory({ shopId: shop.id, name: "Tea", displayOrder: 2 }), + await createCategory({ shopId: shop.id, name: "Food", displayOrder: 3 }) + ]; +} + +const products = await listProducts(shop.id); +if (products.length === 0) { + await createProduct({ + shopId: shop.id, + name: "Americano", + price: 45000, + vertical: shop.vertical, + categoryId: categories[0]?.id, + sku: "CF-AMERICANO", + initialQuantity: 40 + }); + await createProduct({ + shopId: shop.id, + name: "Latte", + price: 59000, + vertical: shop.vertical, + categoryId: categories[0]?.id, + sku: "CF-LATTE", + initialQuantity: 35 + }); + await createProduct({ + shopId: shop.id, + name: "Peach Tea", + price: 52000, + vertical: shop.vertical, + categoryId: categories[1]?.id, + sku: "TEA-PEACH", + initialQuantity: 28 + }); + await createProduct({ + shopId: shop.id, + name: "Croissant", + price: 39000, + vertical: shop.vertical, + categoryId: categories[2]?.id, + sku: "FD-CROISSANT", + initialQuantity: 16 + }); +} + +const tables = await listTables(shop.id); +if (tables.length === 0) { + await createTable({ shopId: shop.id, tableNumber: "A1", capacity: 2, zone: "Front" }); + await createTable({ shopId: shop.id, tableNumber: "A2", capacity: 4, zone: "Front" }); + await createTable({ shopId: shop.id, tableNumber: "B1", capacity: 6, zone: "Garden" }); +} + +const inventory = await listInventory(shop.id); +if (!inventory.some((item) => item.name === "Arabica beans")) { + await createInventoryItem({ + shopId: shop.id, + name: "Arabica beans", + itemTypeId: 1, + unit: "kg", + costPerUnit: 240000, + quantity: 8, + reorderLevel: 5, + supplierName: "GoodGo Supply" + }); +} + +console.log(`Seed completed for shop: ${shop.name}`); +await seedParityData(shop.id); +console.log("Full TPOS parity seed completed"); +await getPool().end(); diff --git a/microservices/apps/tpos-mvp-next/scripts/setup-db.ts b/microservices/apps/tpos-mvp-next/scripts/setup-db.ts new file mode 100644 index 00000000..2b34dc73 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/scripts/setup-db.ts @@ -0,0 +1,6 @@ +import { getPool } from "../src/server/db/pool"; +import { setupDatabase } from "../src/server/db/queries"; + +await setupDatabase(); +console.log("GoodGo TPOS MVP database schema is ready."); +await getPool().end(); diff --git a/microservices/apps/tpos-mvp-next/src/app/actions.ts b/microservices/apps/tpos-mvp-next/src/app/actions.ts new file mode 100644 index 00000000..f6e8c4d5 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/actions.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createShopService } from "@/server/services/shop"; +import { + createCatalogCategory, + createCatalogProduct +} from "@/server/services/catalog"; +import { inventoryAdjust, createInventoryItemService } from "@/server/services/inventory"; +import { createTableService, updateTableStatusService } from "@/server/services/fnb"; + +function requiredText(formData: FormData, key: string) { + const value = String(formData.get(key) ?? "").trim(); + if (!value) throw new Error(`${key} is required`); + return value; +} + +function optionalText(formData: FormData, key: string) { + const value = String(formData.get(key) ?? "").trim(); + return value.length ? value : null; +} + +function numberValue(formData: FormData, key: string, fallback = 0) { + const raw = String(formData.get(key) ?? ""); + const value = Number(raw); + return Number.isFinite(value) ? value : fallback; +} + +export async function createShopAction(formData: FormData) { + await createShopService({ + name: requiredText(formData, "name"), + vertical: requiredText(formData, "vertical"), + phone: optionalText(formData, "phone"), + email: optionalText(formData, "email"), + description: optionalText(formData, "description") + }); + revalidatePath("/"); + revalidatePath("/settings"); +} + +export async function createCategoryAction(formData: FormData) { + await createCatalogCategory({ + shopId: requiredText(formData, "shopId"), + name: requiredText(formData, "name"), + description: optionalText(formData, "description"), + displayOrder: numberValue(formData, "displayOrder", 0) + }); + revalidatePath("/catalog"); + revalidatePath("/pos"); +} + +export async function createProductAction(formData: FormData) { + await createCatalogProduct({ + shopId: requiredText(formData, "shopId"), + name: requiredText(formData, "name"), + price: numberValue(formData, "price", 0), + vertical: optionalText(formData, "vertical"), + categoryId: optionalText(formData, "categoryId"), + description: optionalText(formData, "description"), + sku: optionalText(formData, "sku"), + barcode: optionalText(formData, "barcode"), + initialQuantity: numberValue(formData, "initialQuantity", 0) + }); + revalidatePath("/catalog"); + revalidatePath("/inventory"); + revalidatePath("/pos"); + revalidatePath("/"); +} + +export async function createInventoryItemAction(formData: FormData) { + await createInventoryItemService({ + shopId: requiredText(formData, "shopId"), + name: requiredText(formData, "name"), + itemTypeId: numberValue(formData, "itemTypeId", 1), + unit: requiredText(formData, "unit"), + costPerUnit: numberValue(formData, "costPerUnit", 0), + quantity: numberValue(formData, "quantity", 0), + reorderLevel: numberValue(formData, "reorderLevel", 10), + supplierName: optionalText(formData, "supplierName") + }); + revalidatePath("/inventory"); + revalidatePath("/"); +} + +export async function adjustStockAction(formData: FormData) { + await inventoryAdjust({ + inventoryId: requiredText(formData, "inventoryId"), + quantity: numberValue(formData, "quantity", 0), + notes: optionalText(formData, "notes") + }); + revalidatePath("/inventory"); + revalidatePath("/catalog"); + revalidatePath("/"); +} + +export async function createTableAction(formData: FormData) { + await createTableService({ + shopId: requiredText(formData, "shopId"), + tableNumber: requiredText(formData, "tableNumber"), + capacity: numberValue(formData, "capacity", 2), + zone: optionalText(formData, "zone"), + hourlyRate: numberValue(formData, "hourlyRate", 0) + }); + revalidatePath("/tables"); + revalidatePath("/"); +} + +export async function updateTableStatusAction(formData: FormData) { + await updateTableStatusService( + requiredText(formData, "tableId"), + numberValue(formData, "statusId", 1) + ); + revalidatePath("/tables"); + revalidatePath("/pos"); +} diff --git a/microservices/apps/tpos-mvp-next/src/app/admin/[...path]/page.tsx b/microservices/apps/tpos-mvp-next/src/app/admin/[...path]/page.tsx new file mode 100644 index 00000000..fc640da0 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/admin/[...path]/page.tsx @@ -0,0 +1,68 @@ +import { TposPortal, buildPortalPayload } from "@/components/TposPortal"; +import { getDashboardStats, listOrders } from "@/server/db/queries"; +import { getShopService, listShopsService } from "@/server/services/shop"; +import { listStaff, listCampaigns, listAppointments } from "@/server/services/parity"; + +export default async function AdminCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) { + const path = (await params).path ?? []; + const shopId = path[0] === "shop" ? path[1] : undefined; + const shop = await getShopService(shopId); + const stats = await getDashboardStats(shop?.id); + const section = path[0] === "shop" ? path[2] ?? "overview" : path.join("/") || "dashboard"; + const items = await loadItems(section, shop?.id); + + return ( + + ); +} + +async function loadItems(section: string, shopId?: string | null) { + if (section === "stores") { + const shops = await listShopsService(); + return shops.map((shop) => ({ title: shop.name, meta: shop.category, value: shop.status, href: `/admin/shop/${shop.id}/overview` })); + } + if (section === "staff" || section === "users") { + const staff = await listStaff(shopId); + return staff.map((item) => ({ title: `${item.first_name ?? "Nhân viên"} ${item.last_name ?? ""}`, meta: String(item.role ?? "Staff"), value: String(item.status ?? "active") })); + } + if (section === "promotions") { + const campaigns = await listCampaigns(); + return campaigns.map((item) => ({ title: String(item.name), meta: String(item.description ?? "Campaign"), value: String(item.status) })); + } + if (section === "appointments" || section === "spa/appointments") { + const appointments = shopId ? await listAppointments(shopId) : []; + return appointments.map((item) => ({ title: String(item.customer_name ?? "Khách"), meta: String(item.service_name ?? "Dịch vụ"), value: String(item.status) })); + } + const orders = await listOrders(shopId, 8); + return orders.map((order) => ({ title: order.id.slice(0, 8).toUpperCase(), meta: `${order.itemCount} món`, value: new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 }).format(order.totalAmount), href: `/orders` })); +} + +function adminTitle(section: string) { + const labels: Record = { + stores: "Quản lý cửa hàng", + "stores/create": "Tạo cửa hàng", + users: "Quản lý người dùng", + roles: "Vai trò & phân quyền", + "reports/eod": "Báo cáo cuối ngày", + settings: "Cài đặt admin", + overview: "Tổng quan cửa hàng", + menu: "Menu & sản phẩm", + inventory: "Kho hàng", + tables: "Bàn/phòng", + kitchen: "Bếp", + finance: "Tài chính", + promotions: "Khuyến mãi", + "ai-chat": "AI chat", + drive: "Drive lưu trữ" + }; + return labels[section] ?? `TPOS ${section}`; +} diff --git a/microservices/apps/tpos-mvp-next/src/app/admin/page.tsx b/microservices/apps/tpos-mvp-next/src/app/admin/page.tsx new file mode 100644 index 00000000..891f167f --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/admin/page.tsx @@ -0,0 +1,19 @@ +import { TposPortal, buildPortalPayload } from "@/components/TposPortal"; +import { getDashboardStats } from "@/server/db/queries"; +import { getShopService } from "@/server/services/shop"; + +export default async function AdminPage() { + const shop = await getShopService(); + const stats = await getDashboardStats(shop?.id); + return ( + + ); +} diff --git a/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/route.ts b/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/route.ts new file mode 100644 index 00000000..1ee0ac77 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/api/bff/[...path]/route.ts @@ -0,0 +1,537 @@ +import { cookies } from "next/headers"; +import { + createCatalogCategory, + createCatalogProduct, + deleteCatalogCategory, + deleteCatalogProduct, + getCatalogProduct, + listCatalogCategories, + listCatalogCategoriesByShop, + listCatalogProducts, + listCatalogProductsByShop, + updateCatalogCategory, + updateCatalogProduct +} from "@/server/services/catalog"; +import { + createTableService, + getTableFromToken, + listTablesByShop, + removeTableService, + regenerateTableQrService, + updateTableService, + updateTableStatusService +} from "@/server/services/fnb"; +import { + createInventoryItemService, + deleteInventoryItemService, + inventoryAdjust, + listInventoryItems, + listInventoryWithTransactions, + stockIn, + stockOut, + updateInventoryItemService +} from "@/server/services/inventory"; +import { + cancelOrderService, + createPosOrder, + getOrderService, + getPosDashboardService, + listActiveOrdersByTableService, + listOrdersService, + payOrderService +} from "@/server/services/order"; +import { + createShopService, + getShopService, + getShopSettingsService, + listShopsService, + getShopStatsService, + updateShopSettingsService, + updateShopService +} from "@/server/services/shop"; +import { + addExperience, + auditLogs, + baristaStats, + checkIn, + checkOut, + createAppointment, + createCampaign, + createFileRecord, + createFolder, + createKitchenTicket, + createLeaveRequest, + createMember, + createReservation, + createStaff, + getAiConfig, + getAttendance, + getSessionUser, + getStaffProfile, + listAppointments, + listBaristaQueue, + listCampaigns, + listFeatureFlags, + listFiles, + listFolders, + listKitchenTickets, + listLeaveRequests, + listMembers, + listMembershipLevels, + listPlans, + listRecipes, + listReservations, + listResources, + listSchedules, + listStaff, + listTherapists, + listUsers, + listWallets, + listWalletTransactions, + loginUser, + logoutSession, + platformStats, + redeemVoucher, + reportRevenue, + reportTopProducts, + saveAiConfig, + saveAiMessage, + sessionCookieName, + setCampaignStatus, + systemHealth, + updateAppointmentStatus, + updateBaristaQueue, + updateFeatureFlag, + updateKitchenTicket, + updateLeaveStatus, + updateReservationStatus, + validateVoucher +} from "@/server/services/parity"; +import { + buildS3ObjectKey, + callConfiguredAi, + providerCredentialStatus, + publishSocial, + uploadS3Object +} from "@/server/integrations/external"; +import { fail, ok } from "@/server/shared/api"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ path?: string[] }>; +}; + +async function readJson(request: Request) { + try { + return (await request.json()) as Record; + } catch { + return {}; + } +} + +function numberValue(value: unknown, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function boolValue(value: unknown, fallback = false) { + if (value === "true" || value === true) return true; + if (value === "false" || value === false) return false; + return fallback; +} + +async function sessionToken() { + return (await cookies()).get(sessionCookieName())?.value ?? null; +} + +async function currentUser() { + return getSessionUser(await sessionToken()); +} + +function periodParam(value: string | null) { + return value === "week" || value === "month" || value === "30d" ? value : "today"; +} + +export async function GET(request: Request, context: RouteContext) { + try { + const path = (await context.params).path ?? []; + const url = new URL(request.url); + + if (path[0] === "auth" && path[1] === "session") { + return ok(await currentUser()); + } + + if (path[0] === "account") { + if (path[1] === "me" || path[1] === "profile") return ok(await currentUser()); + if (path[1] === "subscription") return ok({ plan: "growth", status: "active", usage: await platformStats() }); + if (path[1] === "subscription" && path[2] === "plans") return ok(await listPlans()); + return ok({ user: await currentUser(), linkedAccounts: [], twoFactorEnabled: false }); + } + + if (path[0] === "shops") { + if (path[1] === "stats") return ok(await getShopStatsService()); + if (path.length === 1) return ok(await listShopsService()); + + const shopId = path[1]; + if (!shopId) return fail("Shop id is required", { status: 400 }); + + if (path.length === 2) { + const shop = await getShopService(shopId); + return shop ? ok(shop) : fail("Shop not found", { status: 404 }); + } + + if (path[2] === "products") return ok(await listCatalogProductsByShop(shopId)); + if (path[2] === "categories") return ok(await listCatalogCategoriesByShop(shopId)); + if (path[2] === "inventory") return ok(await listInventoryItems(shopId)); + if (path[2] === "tables") return ok(await listTablesByShop(shopId)); + if (path[2] === "orders") { + return ok( + await listOrdersService({ + shopId, + page: numberValue(url.searchParams.get("page"), 1), + pageSize: numberValue(url.searchParams.get("pageSize"), 40), + filter: stringValue(url.searchParams.get("filter")) === "all" ? "all" : "today" + }) + ); + } + if (path[2] === "active-orders") return ok(await listActiveOrdersByTableService(shopId)); + if (path[2] === "dashboard") return ok(await getPosDashboardService(shopId, periodParam(url.searchParams.get("period")))); + if (path[2] === "settings") return ok(await getShopSettingsService(shopId)); + if (path[2] === "appointments") return ok(await listAppointments(shopId, stringValue(url.searchParams.get("date")))); + if (path[2] === "resources") return ok(await listResources(shopId)); + if (path[2] === "therapists") return ok(await listTherapists(shopId)); + if (path[2] === "reservations") return ok(await listReservations(shopId, stringValue(url.searchParams.get("date")))); + if (path[2] === "recipes") return ok(await listRecipes(shopId)); + if (path[2] === "kitchen-tickets") return ok(await listKitchenTickets(shopId, stringValue(url.searchParams.get("status")))); + if (path[2] === "barista-queue" && path[3] === "stats") return ok(await baristaStats(shopId)); + if (path[2] === "barista-queue") return ok(await listBaristaQueue(shopId)); + if (path[2] === "leave-requests") return ok(await listLeaveRequests(shopId)); + if (path[2] === "attendance") return ok(await getAttendance(null, shopId)); + } + + if (path[0] === "products") { + if (path.length === 1) { + return ok(await listCatalogProducts({ + shopId: stringValue(url.searchParams.get("shopId")), + includeInactive: boolValue(url.searchParams.get("includeInactive")) + })); + } + const product = await getCatalogProduct(path[1] ?? ""); + return product ? ok(product) : fail("Product not found", { status: 404 }); + } + + if (path[0] === "categories") { + return ok(await listCatalogCategories({ + shopId: stringValue(url.searchParams.get("shopId")), + includeInactive: boolValue(url.searchParams.get("includeInactive")) + })); + } + + if (path[0] === "inventory") { + if (path[1] === "transactions") return ok((await listInventoryWithTransactions(stringValue(url.searchParams.get("shopId")))).transactions); + if (path[1] === "low-stock") return ok((await listInventoryItems(stringValue(url.searchParams.get("shopId")) ?? "")).filter((item) => item.quantity <= item.reorderLevel)); + return ok(await listInventoryItems(stringValue(url.searchParams.get("shopId")) ?? "")); + } + + if (path[0] === "tables" && path[1] === "by-token" && path[2]) { + const table = await getTableFromToken(path[2]); + return table ? ok(table) : fail("Table not found", { status: 404 }); + } + + if (path[0] === "orders") { + if (path[1] === "active-by-table") return ok(await listActiveOrdersByTableService(stringValue(url.searchParams.get("shopId")))); + if (path.length === 1) { + return ok( + await listOrdersService({ + shopId: stringValue(url.searchParams.get("shopId")), + page: numberValue(url.searchParams.get("page"), 1), + pageSize: numberValue(url.searchParams.get("pageSize"), 40), + filter: stringValue(url.searchParams.get("filter")) === "today" ? "today" : "all" + }) + ); + } + const order = await getOrderService(path[1] ?? "", stringValue(url.searchParams.get("shopId"))); + return order ? ok(order) : fail("Order not found", { status: 404 }); + } + + if (path[0] === "pos" && path[1] === "dashboard") { + return ok(await getPosDashboardService(stringValue(url.searchParams.get("shopId")), periodParam(url.searchParams.get("period")))); + } + + if (path[0] === "staff") { + if (path.length === 1) return ok(await listStaff(stringValue(url.searchParams.get("shopId")))); + if (path[1] === "roles") return ok([{ id: 1, name: "Owner" }, { id: 2, name: "Manager" }, { id: 3, name: "Staff" }]); + if (path[1] === "schedules") return ok(await listSchedules(stringValue(url.searchParams.get("shopId")))); + if (path[1] === "me") { + const user = await currentUser(); + if (path[2] === "attendance") return ok(await getAttendance()); + if (path[2] === "leave-requests") return ok(await listLeaveRequests(null)); + if (path[2] === "notifications") return ok([]); + return ok(await getStaffProfile(user?.id)); + } + } + + if (path[0] === "kitchen" && path[1] === "tickets") { + return ok(await listKitchenTickets(stringValue(url.searchParams.get("shopId")), stringValue(url.searchParams.get("status")))); + } + + if (path[0] === "members") { + if (path.length === 1) return ok(await listMembers(stringValue(url.searchParams.get("search")))); + if (path[2] === "progress") return ok({ memberId: path[1], currentLevel: 1, expToNextLevel: 250, progressPercent: 45 }); + if (path[2] === "experience") return ok([]); + } + + if (path[0] === "membership" && path[1] === "levels") return ok(await listMembershipLevels()); + if (path[0] === "wallets") return ok(await listWallets((await currentUser())?.id)); + if (path[0] === "wallet" && path[1] === "transactions") return ok(await listWalletTransactions(numberValue(url.searchParams.get("limit"), 50))); + if (path[0] === "promotions" || path[0] === "campaigns") return ok(await listCampaigns()); + if (path[0] === "vouchers" && path[1] === "validate" && path[2]) return ok(await validateVoucher(path[2])); + + if (path[0] === "reports") { + const shopId = stringValue(url.searchParams.get("shopId")); + if (path[1] === "top-products") return ok(await reportTopProducts(shopId)); + if (path[1] === "revenue" || path[1] === "revenue-analytics") return ok(await reportRevenue(shopId)); + if (path[1] === "eod") return ok({ closed: false, revenue: await reportRevenue(shopId), generatedAt: new Date().toISOString() }); + if (path[1] === "staff-performance") return ok(await listStaff(shopId)); + } + + if (path[0] === "files") return ok(await listFiles(stringValue(url.searchParams.get("shopId")))); + if (path[0] === "folders") return ok(await listFolders(stringValue(url.searchParams.get("parentId")))); + + if (path[0] === "ai") { + if (path[1] === "config") { + const shopId = stringValue(url.searchParams.get("shopId")); + return ok(shopId ? await getAiConfig(shopId) : null); + } + if (path[1] === "providers") return ok(providerCredentialStatus()); + } + + if (path[0] === "superadmin") { + if (path[1] === "stats") return ok(await platformStats()); + if (path[1] === "merchants") return ok(await listShopsService()); + if (path[1] === "plans") return ok(await listPlans()); + if (path[1] === "system" && path[2] === "health") return ok(await systemHealth()); + if (path[1] === "feature-flags") return ok(await listFeatureFlags()); + if (path[1] === "system" && path[2] === "audit") return ok(await auditLogs()); + if (path[1] === "users") return ok(await listUsers()); + } + + if (path[0] === "devices") return ok([]); + if (path[0] === "integrations") return ok(providerCredentialStatus()); + + return fail("BFF route not found", { status: 404 }); + } catch (error) { + return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + const path = (await context.params).path ?? []; + const body = await readJson(request); + + if (path[0] === "auth" && path[1] === "login") { + const result = await loginUser(String(body.email ?? ""), String(body.password ?? "")); + const jar = await cookies(); + jar.set(sessionCookieName(), result.token, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + path: "/", + expires: new Date(result.expiresAt) + }); + return ok(result.user); + } + + if (path[0] === "auth" && path[1] === "logout") { + await logoutSession(await sessionToken()); + const jar = await cookies(); + jar.delete(sessionCookieName()); + return ok({ ok: true }); + } + + if (path[0] === "shops" && path.length === 1) { + return ok( + await createShopService({ + name: String(body.name ?? ""), + vertical: String(body.vertical ?? "retail"), + phone: stringValue(body.phone), + email: stringValue(body.email), + description: stringValue(body.description) + }), + { status: 201 } + ); + } + if (path[0] === "shops" && path[2] === "publish") return ok(await updateShopService(path[1] ?? "", { statusId: 2 })); + if (path[0] === "shops" && path[2] === "deactivate") return ok(await updateShopService(path[1] ?? "", { statusId: 3 })); + if (path[0] === "shops" && path[2] === "close") return ok(await updateShopService(path[1] ?? "", { statusId: 4 })); + + if (path[0] === "products") return ok(await createCatalogProduct({ + shopId: String(body.shopId ?? ""), + name: String(body.name ?? ""), + price: numberValue(body.price), + vertical: stringValue(body.vertical), + categoryId: stringValue(body.categoryId), + description: stringValue(body.description), + sku: stringValue(body.sku), + barcode: stringValue(body.barcode), + initialQuantity: numberValue(body.initialQuantity) + }), { status: 201 }); + + if (path[0] === "categories") return ok(await createCatalogCategory({ + shopId: String(body.shopId ?? ""), + name: String(body.name ?? ""), + description: stringValue(body.description), + displayOrder: numberValue(body.displayOrder) + }), { status: 201 }); + + if (path[0] === "inventory" && path[1] === "items") return ok(await createInventoryItemService({ + shopId: String(body.shopId ?? ""), + name: String(body.name ?? ""), + itemTypeId: numberValue(body.itemTypeId, 1), + unit: String(body.unit ?? "pcs"), + costPerUnit: numberValue(body.costPerUnit), + quantity: numberValue(body.quantity), + reorderLevel: numberValue(body.reorderLevel, 10), + supplierName: stringValue(body.supplierName) + }), { status: 201 }); + if (path[0] === "inventory" && path[1] === "stock-in") return ok(await stockIn(body as never)); + if (path[0] === "inventory" && path[1] === "stock-out") return ok(await stockOut(body as never)); + if (path[0] === "inventory" && path[1] === "adjust") return ok(await inventoryAdjust(body as never)); + if (path[0] === "inventory" && path[1] === "wastage") return ok(await stockOut({ ...body, notes: stringValue(body.reason) ?? "Wastage" } as never)); + if (path[0] === "inventory" && path[1] === "stocktake") return ok({ discrepancies: [], totalItemsCounted: Array.isArray(body.items) ? body.items.length : 0 }); + + if (path[0] === "tables") return ok(await createTableService({ + shopId: String(body.shopId ?? ""), + tableNumber: String(body.tableNumber ?? ""), + capacity: numberValue(body.capacity, 2), + zone: stringValue(body.zone), + hourlyRate: numberValue(body.hourlyRate) + }), { status: 201 }); + if (path[0] === "tables" && path[2] === "generate-qr") return ok(await regenerateTableQrService(path[1] ?? "")); + + if ((path[0] === "orders" && path.length === 1) || (path[0] === "pos" && path[1] === "orders")) { + return ok(await createPosOrder(body as Parameters[0]), { status: 201 }); + } + if (path[0] === "orders" && path[2] === "pay") return ok(await payOrderService(path[1] ?? "", { + shopId: stringValue(body.shopId), + paymentMethod: stringValue(body.paymentMethod), + amountTendered: body.amountTendered == null ? null : numberValue(body.amountTendered) + })); + if (path[0] === "orders" && path[2] === "cancel") return ok(await cancelOrderService(path[1] ?? "", stringValue(body.shopId), stringValue(body.reason))); + + if (path[0] === "staff" && path.length === 1) return ok(await createStaff(body), { status: 201 }); + if (path[0] === "staff" && path[1] === "me" && path[2] === "attendance" && path[3] === "check-in") return ok(await checkIn()); + if (path[0] === "staff" && path[1] === "me" && path[2] === "attendance" && path[3] === "check-out") return ok(await checkOut()); + if (path[0] === "staff" && path[1] === "me" && path[2] === "leave-requests") return ok(await createLeaveRequest(body), { status: 201 }); + if (path[0] === "leave-requests" && path[2] === "approve") return ok(await updateLeaveStatus(path[1] ?? "", "approved")); + if (path[0] === "leave-requests" && path[2] === "reject") return ok(await updateLeaveStatus(path[1] ?? "", "rejected")); + if (path[0] === "staff" && path[1] === "invite-with-account") return ok(await createStaff(body), { status: 201 }); + if (path[0] === "staff" && path[1] === "reset-password") return ok({ ok: true }); + + if (path[0] === "members" && path.length === 1) return ok(await createMember(body), { status: 201 }); + if (path[0] === "members" && path[2] === "experience") return ok(await addExperience(path[1] ?? "", numberValue(body.points), String(body.sourceId ?? "manual"), stringValue(body.referenceId))); + if (path[0] === "campaigns" && path[2] === "activate") return ok(await setCampaignStatus(path[1] ?? "", "active")); + if (path[0] === "campaigns" && path[2] === "pause") return ok(await setCampaignStatus(path[1] ?? "", "paused")); + if (path[0] === "campaigns") return ok(await createCampaign(body), { status: 201 }); + if (path[0] === "vouchers" && path[1] === "redeem") return ok(await redeemVoucher(body)); + if (path[0] === "vouchers" && path[2] === "revoke") return ok(await redeemVoucher({ voucherId: path[1] })); + + if (path[0] === "appointments") return ok(await createAppointment(body), { status: 201 }); + if (path[0] === "reservations") return ok(await createReservation(body), { status: 201 }); + if (path[0] === "kitchen" && path[1] === "tickets") return ok(await createKitchenTicket(body), { status: 201 }); + if (path[0] === "cafe" && path[1] === "barista-queue" && path[2]) { + const status = path[3] === "ready" ? "Ready" : path[3] === "delivered" ? "Delivered" : "InProgress"; + return ok(await updateBaristaQueue(path[2], status, stringValue(body.baristaName))); + } + if (path[0] === "folders") return ok(await createFolder(body), { status: 201 }); + + if (path[0] === "files" && path[1] === "upload") { + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return fail("file is required", { status: 400 }); + const key = buildS3ObjectKey(file.name); + const publicUrl = await uploadS3Object(key, file, stringValue(urlFromRequest(request).searchParams.get("accessLevel")) ?? "public"); + return ok(await createFileRecord({ + fileName: file.name, + contentType: file.type, + byteSize: file.size, + objectKey: key, + publicUrl, + accessLevel: stringValue(urlFromRequest(request).searchParams.get("accessLevel")) ?? "public" + })); + } + + if (path[0] === "ai" && path[1] === "chat") { + const provider = stringValue(body.provider) ?? process.env.AI_DEFAULT_PROVIDER ?? "openai"; + const message = Array.isArray(body.messages) + ? String((body.messages[body.messages.length - 1] as { content?: unknown } | undefined)?.content ?? "") + : String(body.message ?? ""); + const response = await callConfiguredAi(provider, message); + await saveAiMessage({ shopId: stringValue(body.shopId), role: "user", content: message }); + await saveAiMessage({ shopId: stringValue(body.shopId), role: "assistant", content: JSON.stringify(response) }); + return ok({ content: response, toolsUsed: [] }); + } + + if (path[0] === "marketing" && path[1] === "publish" && path[2]) { + return ok(await publishSocial(path[2], body)); + } + + if (path[0] === "reports" && path[1] === "close-day") return ok({ closed: true, closedAt: new Date().toISOString() }); + + return fail("BFF route not found", { status: 404 }); + } catch (error) { + return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 }); + } +} + +export async function PUT(request: Request, context: RouteContext) { + try { + const path = (await context.params).path ?? []; + const body = await readJson(request); + + if (path[0] === "account" && (path[1] === "profile" || path[1] === "me")) return ok({ ...(await currentUser()), ...body }); + if (path[0] === "shops" && path[2] === "settings") return ok(await updateShopSettingsService(path[1] ?? "", body)); + if (path[0] === "shops" && path.length === 2) return ok(await updateShopService(path[1] ?? "", body)); + if (path[0] === "products") return ok(await updateCatalogProduct(path[1] ?? "", body)); + if (path[0] === "categories") return ok(await updateCatalogCategory(path[1] ?? "", body)); + if (path[0] === "tables" && path[2] === "status") return ok(await updateTableStatusService(path[1] ?? "", numberValue(body.statusId, 1))); + if (path[0] === "tables") return ok(await updateTableService(path[1] ?? "", body)); + if (path[0] === "inventory" && path[2] === "adjust") return ok(await inventoryAdjust({ inventoryId: path[1] ?? "", quantity: numberValue(body.quantity), notes: stringValue(body.notes) })); + if (path[0] === "inventory") return ok(await updateInventoryItemService(path[1] ?? "", body)); + if (path[0] === "orders" && path[2] === "cancel") return ok(await cancelOrderService(path[1] ?? "", stringValue(body.shopId), stringValue(body.reason))); + if (path[0] === "appointments") return ok(await updateAppointmentStatus(path[1] ?? "", String(body.action ?? body.status ?? "confirmed"))); + if (path[0] === "reservations") return ok(await updateReservationStatus(path[1] ?? "", String(body.status ?? "confirmed"))); + if (path[0] === "kitchen" && path[1] === "tickets") return ok(await updateKitchenTicket(path[2] ?? "", String(body.status ?? "InProgress"))); + if (path[0] === "ai" && path[1] === "config") return ok(await saveAiConfig(body)); + if (path[0] === "superadmin" && path[1] === "feature-flags" && path[2]) return ok(await updateFeatureFlag(path[2], Boolean(body.enabled))); + return fail("BFF route not found", { status: 404 }); + } catch (error) { + return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 }); + } +} + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const path = (await context.params).path ?? []; + if (path[0] === "products") return ok(await deleteCatalogProduct(path[1] ?? "")); + if (path[0] === "categories") return ok(await deleteCatalogCategory(path[1] ?? "")); + if (path[0] === "tables") return ok(await removeTableService(path[1] ?? "")); + if (path[0] === "inventory" && path[1] === "items") return ok(await deleteInventoryItemService(path[2] ?? "")); + if (path[0] === "campaigns") return ok(await setCampaignStatus(path[1] ?? "", "disabled")); + if (path[0] === "folders") return ok({ id: path[1], deleted: true }); + if (path[0] === "files") return ok({ id: path[1], deleted: true }); + return fail("BFF route not found", { status: 404 }); + } catch (error) { + return fail(error instanceof Error ? error.message : "BFF request failed", { status: 400 }); + } +} + +function urlFromRequest(request: Request) { + return new URL(request.url); +} diff --git a/microservices/apps/tpos-mvp-next/src/app/api/health/route.ts b/microservices/apps/tpos-mvp-next/src/app/api/health/route.ts new file mode 100644 index 00000000..f6ee8441 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/api/health/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { query } from "@/server/db/pool"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const started = performance.now(); + try { + await query("SELECT 1 AS ok"); + return NextResponse.json({ + ok: true, + service: "tpos-mvp-next", + database: "connected", + latencyMs: Math.round(performance.now() - started) + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Database check failed"; + return NextResponse.json( + { + ok: false, + service: "tpos-mvp-next", + database: "unavailable", + error: message + }, + { status: 503 } + ); + } +} diff --git a/microservices/apps/tpos-mvp-next/src/app/api/orders/route.ts b/microservices/apps/tpos-mvp-next/src/app/api/orders/route.ts new file mode 100644 index 00000000..729975e0 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/api/orders/route.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { createPosOrder } from "@/server/services/order"; +import { fail, ok } from "@/server/shared/api"; + +export const dynamic = "force-dynamic"; + +const createOrderSchema = z.object({ + shopId: z.string().uuid(), + tableId: z.string().uuid().nullable().optional(), + paymentMethod: z.string().min(1).default("cash"), + amountTendered: z.number().nullable().optional(), + discountAmount: z.number().min(0).nullable().optional(), + discountType: z.string().nullable().optional(), + discountReference: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + items: z + .array( + z.object({ + productId: z.string().uuid(), + quantity: z.number().int().positive() + }) + ) + .min(1) +}); + +export async function POST(request: Request) { + try { + const body = createOrderSchema.parse(await request.json()); + const order = await createPosOrder(body); + return ok(order, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Cannot create order"; + return fail(message, { status: 400 }); + } +} diff --git a/microservices/apps/tpos-mvp-next/src/app/api/public/[...path]/route.ts b/microservices/apps/tpos-mvp-next/src/app/api/public/[...path]/route.ts new file mode 100644 index 00000000..7b8b4f3a --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/api/public/[...path]/route.ts @@ -0,0 +1,22 @@ +import { publicMenu, publicShop } from "@/server/services/parity"; +import { fail, ok } from "@/server/shared/api"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ path?: string[] }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + try { + const path = (await context.params).path ?? []; + if (path[0] === "shops" && path[1]) { + if (path[2] === "menu") return ok(await publicMenu(path[1])); + const shop = await publicShop(path[1]); + return shop ? ok(shop) : fail("Shop not found", { status: 404 }); + } + return fail("Public route not found", { status: 404 }); + } catch (error) { + return fail(error instanceof Error ? error.message : "Public request failed", { status: 400 }); + } +} diff --git a/microservices/apps/tpos-mvp-next/src/app/auth/[...path]/page.tsx b/microservices/apps/tpos-mvp-next/src/app/auth/[...path]/page.tsx new file mode 100644 index 00000000..31bda459 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/auth/[...path]/page.tsx @@ -0,0 +1,8 @@ +import { TposAuthBoundary } from "@/components/TposAuthBoundary"; + +export default async function AuthCatchAllPage({ params }: { params: Promise<{ path?: string[] }> }) { + const path = (await params).path ?? []; + const mode = path.includes("register") ? "register" : path.includes("forgot-password-new") || path.includes("password-reset") || path.includes("otp-verify") || path.includes("two-factor") || path.includes("email-sent") ? "recover" : "login"; + const role = path[path.length - 1] ?? "admin"; + return ; +} diff --git a/microservices/apps/tpos-mvp-next/src/app/catalog/page.tsx b/microservices/apps/tpos-mvp-next/src/app/catalog/page.tsx new file mode 100644 index 00000000..b79c46f6 --- /dev/null +++ b/microservices/apps/tpos-mvp-next/src/app/catalog/page.tsx @@ -0,0 +1,169 @@ +import { PackagePlus, Tags } from "lucide-react"; +import { createCategoryAction, createProductAction } from "@/app/actions"; +import { AppFrame } from "@/components/AppFrame"; +import { EmptyState, PageHeader, StatusPill, money } from "@/components/Primitives"; +import { getShopService } from "@/server/services/shop"; +import { listCatalogCategoriesByShop, listCatalogProductsByShop } from "@/server/services/catalog"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams?: Promise<{ shopId?: string }>; +}; + +export default async function CatalogPage({ searchParams }: PageProps) { + const params = (await searchParams) ?? {}; + const shop = await getShopService(params.shopId); + const [categories, products] = shop + ? await Promise.all([listCatalogCategoriesByShop(shop.id), listCatalogProductsByShop(shop.id)]) + : [[], []]; + + return ( + + + {shop ? ( +
+
+
+
+

Sản phẩm

+ +
+ {products.length ? ( + + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
TênDanh mụcLoạiTồnGiá
+ {product.name} + {product.sku ?
{product.sku}
: null} +
{product.categoryName ?? "Chung"}{product.productType}{product.stockQuantity ?? "-"}{money(product.price)}
+ ) : ( + + )} +
+ +
+
+

Danh mục

+ +
+ {categories.length ? ( + + + + + + + + + + {categories.map((category) => ( + + + + + + ))} + +
TênThứ tựMô tả
{category.name}{category.displayOrder}{category.description ?? "-"}
+ ) : ( + + )} +
+
+ +