Initial commit

This commit is contained in:
Zeeshaun
2026-05-05 15:47:17 +00:00
commit 398df1d2e3
17 changed files with 4184 additions and 0 deletions
+8
View File
@@ -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"
};
+6
View File
@@ -0,0 +1,6 @@
import mongoose from "mongoose";
import { config } from "./config.js";
export async function connectToDatabase() {
await mongoose.connect(config.mongoUri);
}
+41
View File
@@ -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
View File
@@ -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);
});
+13
View File
@@ -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);
+37
View File
@@ -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);
+35
View File
@@ -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);
}