Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT || 4000),
|
||||
mongoUri: process.env.MONGODB_URI || "mongodb://localhost:27017/ranaze-pos"
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import mongoose from "mongoose";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export async function connectToDatabase() {
|
||||
await mongoose.connect(config.mongoUri);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const defaultSettings = {
|
||||
sizeOptions: [
|
||||
{ name: "Regular", delta: 0 },
|
||||
{ name: "Large", delta: 1 }
|
||||
],
|
||||
sweetnessOptions: ["0%", "25%", "50%", "75%", "100%"],
|
||||
iceOptions: ["No Ice", "Less Ice", "Regular Ice"],
|
||||
toppings: [
|
||||
{ name: "Boba", price: 0.75 },
|
||||
{ name: "Pudding", price: 0.85 },
|
||||
{ name: "Cheese Foam", price: 1.25 },
|
||||
{ name: "Lychee Jelly", price: 0.75 }
|
||||
]
|
||||
};
|
||||
|
||||
export const defaultMenu = [
|
||||
{
|
||||
name: "Classic Milk Tea",
|
||||
category: "Milk Tea",
|
||||
description: "Black tea, milk, and brown sugar tapioca.",
|
||||
basePrice: 5.5
|
||||
},
|
||||
{
|
||||
name: "Taro Milk Tea",
|
||||
category: "Milk Tea",
|
||||
description: "Creamy taro with optional boba or pudding.",
|
||||
basePrice: 6
|
||||
},
|
||||
{
|
||||
name: "Mango Green Tea",
|
||||
category: "Fruit Tea",
|
||||
description: "Bright green tea with mango puree.",
|
||||
basePrice: 5.75
|
||||
},
|
||||
{
|
||||
name: "Strawberry Yakult",
|
||||
category: "Specialty",
|
||||
description: "Strawberry fruit tea with probiotic yogurt.",
|
||||
basePrice: 6.25
|
||||
}
|
||||
];
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { connectToDatabase } from "./db.js";
|
||||
import { config } from "./config.js";
|
||||
import { MenuItem } from "./models/MenuItem.js";
|
||||
import { Order } from "./models/Order.js";
|
||||
import {
|
||||
computeOrderTotal,
|
||||
ensureSeedMenu,
|
||||
getSettings,
|
||||
nextOrderNumber,
|
||||
resetMenuToDefaults
|
||||
} from "./services.js";
|
||||
|
||||
const app = express();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const clientDist = path.resolve(__dirname, "../client/dist");
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_request, response) => {
|
||||
response.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/bootstrap", async (_request, response, next) => {
|
||||
try {
|
||||
await ensureSeedMenu();
|
||||
const [menu, orders] = await Promise.all([
|
||||
MenuItem.find().sort({ category: 1, name: 1 }),
|
||||
Order.find({ status: { $ne: "completed" } }).sort({ createdAt: -1 })
|
||||
]);
|
||||
response.json({
|
||||
menu,
|
||||
orders,
|
||||
settings: getSettings()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/menu", async (request, response, next) => {
|
||||
try {
|
||||
const { name, category, description = "", basePrice } = request.body;
|
||||
if (!name || !category || typeof basePrice !== "number" || Number.isNaN(basePrice)) {
|
||||
return response.status(400).json({ error: "Invalid menu item payload." });
|
||||
}
|
||||
const menuItem = await MenuItem.create({ name, category, description, basePrice });
|
||||
response.status(201).json({ menuItem });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/menu/:menuItemId", async (request, response, next) => {
|
||||
try {
|
||||
await MenuItem.findByIdAndDelete(request.params.menuItemId);
|
||||
response.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/menu/reset", async (_request, response, next) => {
|
||||
try {
|
||||
const menu = await resetMenuToDefaults();
|
||||
response.json({ menu });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/orders", async (request, response, next) => {
|
||||
try {
|
||||
const { customerName = "", paymentMethod = "cash", lines } = request.body;
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
return response.status(400).json({ error: "Orders require at least one line item." });
|
||||
}
|
||||
const total = computeOrderTotal(lines);
|
||||
const order = await Order.create({
|
||||
number: await nextOrderNumber(),
|
||||
customerName,
|
||||
paymentMethod,
|
||||
lines,
|
||||
total
|
||||
});
|
||||
response.status(201).json({ order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/orders/:orderId/status", async (request, response, next) => {
|
||||
try {
|
||||
const { status } = request.body;
|
||||
const order = await Order.findByIdAndUpdate(
|
||||
request.params.orderId,
|
||||
{ status },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
response.json({ order });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/orders/:orderId", async (request, response, next) => {
|
||||
try {
|
||||
await Order.findByIdAndDelete(request.params.orderId);
|
||||
response.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.static(clientDist));
|
||||
|
||||
app.get("*", (request, response, next) => {
|
||||
if (request.path.startsWith("/api/")) {
|
||||
return next();
|
||||
}
|
||||
response.sendFile(path.join(clientDist, "index.html"));
|
||||
});
|
||||
|
||||
app.use((error, _request, response, _next) => {
|
||||
console.error(error);
|
||||
response.status(500).json({ error: error.message || "Internal server error" });
|
||||
});
|
||||
|
||||
connectToDatabase()
|
||||
.then(() => {
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Ranaze POS server listening on port ${config.port}`);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to connect to MongoDB", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const menuItemSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true, trim: true },
|
||||
category: { type: String, required: true, trim: true },
|
||||
description: { type: String, default: "", trim: true },
|
||||
basePrice: { type: Number, required: true, min: 0 }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const MenuItem = mongoose.model("MenuItem", menuItemSchema);
|
||||
@@ -0,0 +1,37 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const orderLineSchema = new mongoose.Schema(
|
||||
{
|
||||
menuItemId: { type: mongoose.Schema.Types.ObjectId, ref: "MenuItem", required: true },
|
||||
name: { type: String, required: true },
|
||||
basePrice: { type: Number, required: true },
|
||||
size: { type: String, required: true },
|
||||
sweetness: { type: String, required: true },
|
||||
ice: { type: String, required: true },
|
||||
toppings: { type: [String], default: [] },
|
||||
notes: { type: String, default: "" }
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const orderSchema = new mongoose.Schema(
|
||||
{
|
||||
number: { type: Number, required: true, unique: true },
|
||||
customerName: { type: String, default: "", trim: true },
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ["cash", "tab", "unpaid"],
|
||||
default: "cash"
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["new", "making", "ready", "completed"],
|
||||
default: "new"
|
||||
},
|
||||
lines: { type: [orderLineSchema], required: true },
|
||||
total: { type: Number, required: true, min: 0 }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const Order = mongoose.model("Order", orderSchema);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defaultMenu, defaultSettings } from "./defaults.js";
|
||||
import { MenuItem } from "./models/MenuItem.js";
|
||||
import { Order } from "./models/Order.js";
|
||||
|
||||
export function getSettings() {
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
export async function ensureSeedMenu() {
|
||||
const count = await MenuItem.countDocuments();
|
||||
if (count === 0) {
|
||||
await MenuItem.insertMany(defaultMenu);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetMenuToDefaults() {
|
||||
await MenuItem.deleteMany({});
|
||||
await MenuItem.insertMany(defaultMenu);
|
||||
return MenuItem.find().sort({ category: 1, name: 1 });
|
||||
}
|
||||
|
||||
export async function nextOrderNumber() {
|
||||
const latest = await Order.findOne().sort({ number: -1 }).lean();
|
||||
return (latest?.number ?? 1000) + 1;
|
||||
}
|
||||
|
||||
export function computeOrderTotal(lines, settings = defaultSettings) {
|
||||
return lines.reduce((sum, line) => {
|
||||
const sizeDelta = settings.sizeOptions.find((option) => option.name === line.size)?.delta ?? 0;
|
||||
const toppings = line.toppings.reduce((toppingSum, toppingName) => {
|
||||
return toppingSum + (settings.toppings.find((item) => item.name === toppingName)?.price ?? 0);
|
||||
}, 0);
|
||||
return sum + line.basePrice + sizeDelta + toppings;
|
||||
}, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user