From 095eb4b50d2a50b6fa7544022c2850db9e7ff444 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Tue, 5 May 2026 16:22:37 +0000 Subject: [PATCH] Converting server to dotnet --- .env.example | 2 + .gitignore | 2 + README.md | 11 +- package.json | 13 +- server/Configuration/Defaults.cs | 56 +++++ server/Configuration/MongoDbOptions.cs | 10 + server/Configuration/PosSettings.cs | 36 +++ server/Infrastructure/DotEnvLoader.cs | 34 +++ server/Models/MenuItemDocument.cs | 40 +++ server/Models/OrderDocument.cs | 84 +++++++ server/Models/Requests.cs | 21 ++ server/Program.cs | 322 +++++++++++++++++++++++++ server/RanazePos.Api.csproj | 11 + server/appsettings.json | 7 + server/config.js | 8 - server/db.js | 6 - server/defaults.js | 41 ---- server/index.js | 143 ----------- server/models/MenuItem.js | 13 - server/models/Order.js | 37 --- server/services.js | 35 --- 21 files changed, 636 insertions(+), 296 deletions(-) create mode 100644 .env.example create mode 100644 server/Configuration/Defaults.cs create mode 100644 server/Configuration/MongoDbOptions.cs create mode 100644 server/Configuration/PosSettings.cs create mode 100644 server/Infrastructure/DotEnvLoader.cs create mode 100644 server/Models/MenuItemDocument.cs create mode 100644 server/Models/OrderDocument.cs create mode 100644 server/Models/Requests.cs create mode 100644 server/Program.cs create mode 100644 server/RanazePos.Api.csproj create mode 100644 server/appsettings.json delete mode 100644 server/config.js delete mode 100644 server/db.js delete mode 100644 server/defaults.js delete mode 100644 server/index.js delete mode 100644 server/models/MenuItem.js delete mode 100644 server/models/Order.js delete mode 100644 server/services.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8d15b88 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +MONGODB_URI=mongodb://127.0.0.1:27017/ranaze-pos +PORT=4000 diff --git a/.gitignore b/.gitignore index 29d7aaf..f1c485f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules client/dist .env +server/bin +server/obj diff --git a/README.md b/README.md index c55c392..47ebd91 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Self-hosted tablet-first POS for a boba shop. ## Stack - Frontend: `React` + `Vite` -- Backend: `Express` -- Database: self-hosted `MongoDB` via `Mongoose` +- Backend: `ASP.NET Core` minimal API +- Database: self-hosted `MongoDB` via `MongoDB.Driver` ## Features @@ -19,14 +19,14 @@ Self-hosted tablet-first POS for a boba shop. ## Project layout - `client/`: React tablet UI -- `server/`: Express API and Mongo models +- `server/`: ASP.NET Core API and Mongo models ## Environment -Create `.env` in the repo root: +Copy `.env.example` to `.env` in the repo root: ```env -MONGODB_URI=mongodb://localhost:27017/ranaze-pos +MONGODB_URI=mongodb://127.0.0.1:27017/ranaze-pos PORT=4000 ``` @@ -34,6 +34,7 @@ PORT=4000 ```bash npm install +dotnet restore server/RanazePos.Api.csproj ``` ## Run in development diff --git a/package.json b/package.json index fc76ca2..79ce7f4 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,15 @@ "name": "ranaze-pos", "version": "1.0.0", "private": true, - "type": "module", "scripts": { "dev:client": "vite --config client/vite.config.js", - "dev:server": "node server/index.js", - "build": "vite build --config client/vite.config.js", - "start": "node server/index.js" + "dev:server": "dotnet run --project server/RanazePos.Api.csproj", + "build:client": "vite build --config client/vite.config.js", + "build:server": "dotnet build server/RanazePos.Api.csproj", + "build": "npm run build:client && npm run build:server", + "start": "dotnet run --project server/RanazePos.Api.csproj" }, "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.21.2", - "mongoose": "^8.9.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/server/Configuration/Defaults.cs b/server/Configuration/Defaults.cs new file mode 100644 index 0000000..6342d65 --- /dev/null +++ b/server/Configuration/Defaults.cs @@ -0,0 +1,56 @@ +using RanazePos.Api.Models; + +namespace RanazePos.Api.Configuration; + +public static class Defaults +{ + public static readonly PosSettings Settings = new() + { + SizeOptions = + [ + new() { Name = "Regular", Delta = 0m }, + new() { Name = "Large", Delta = 1m } + ], + SweetnessOptions = ["0%", "25%", "50%", "75%", "100%"], + IceOptions = ["No Ice", "Less Ice", "Regular Ice"], + Toppings = + [ + new() { Name = "Boba", Price = 0.75m }, + new() { Name = "Pudding", Price = 0.85m }, + new() { Name = "Cheese Foam", Price = 1.25m }, + new() { Name = "Lychee Jelly", Price = 0.75m } + ] + }; + + public static readonly IReadOnlyList Menu = + [ + new() + { + Name = "Classic Milk Tea", + Category = "Milk Tea", + Description = "Black tea, milk, and brown sugar tapioca.", + BasePrice = 5.5m + }, + new() + { + Name = "Taro Milk Tea", + Category = "Milk Tea", + Description = "Creamy taro with optional boba or pudding.", + BasePrice = 6m + }, + new() + { + Name = "Mango Green Tea", + Category = "Fruit Tea", + Description = "Bright green tea with mango puree.", + BasePrice = 5.75m + }, + new() + { + Name = "Strawberry Yakult", + Category = "Specialty", + Description = "Strawberry fruit tea with probiotic yogurt.", + BasePrice = 6.25m + } + ]; +} diff --git a/server/Configuration/MongoDbOptions.cs b/server/Configuration/MongoDbOptions.cs new file mode 100644 index 0000000..923746a --- /dev/null +++ b/server/Configuration/MongoDbOptions.cs @@ -0,0 +1,10 @@ +namespace RanazePos.Api.Configuration; + +public sealed class MongoDbOptions +{ + public const string SectionName = "MongoDb"; + + public string ConnectionString { get; set; } = "mongodb://127.0.0.1:27017"; + + public string DatabaseName { get; set; } = "ranaze-pos"; +} diff --git a/server/Configuration/PosSettings.cs b/server/Configuration/PosSettings.cs new file mode 100644 index 0000000..d7a3044 --- /dev/null +++ b/server/Configuration/PosSettings.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace RanazePos.Api.Configuration; + +public sealed class PosSettings +{ + [JsonPropertyName("sizeOptions")] + public required IReadOnlyList SizeOptions { get; init; } + + [JsonPropertyName("sweetnessOptions")] + public required IReadOnlyList SweetnessOptions { get; init; } + + [JsonPropertyName("iceOptions")] + public required IReadOnlyList IceOptions { get; init; } + + [JsonPropertyName("toppings")] + public required IReadOnlyList Toppings { get; init; } +} + +public sealed class SizeOption +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("delta")] + public decimal Delta { get; init; } +} + +public sealed class ToppingOption +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("price")] + public decimal Price { get; init; } +} diff --git a/server/Infrastructure/DotEnvLoader.cs b/server/Infrastructure/DotEnvLoader.cs new file mode 100644 index 0000000..2c869b1 --- /dev/null +++ b/server/Infrastructure/DotEnvLoader.cs @@ -0,0 +1,34 @@ +namespace RanazePos.Api.Infrastructure; + +public static class DotEnvLoader +{ + public static IDictionary Load(string path) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!File.Exists(path)) + { + return values; + } + + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) + { + continue; + } + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + var key = line[..separatorIndex].Trim(); + var value = line[(separatorIndex + 1)..].Trim().Trim('"'); + values[key] = value; + } + + return values; + } +} diff --git a/server/Models/MenuItemDocument.cs b/server/Models/MenuItemDocument.cs new file mode 100644 index 0000000..5449186 --- /dev/null +++ b/server/Models/MenuItemDocument.cs @@ -0,0 +1,40 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System.Text.Json.Serialization; + +namespace RanazePos.Api.Models; + +public sealed class MenuItemDocument +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [JsonPropertyName("_id")] + public string? Id { get; set; } + + [BsonElement("name")] + [JsonPropertyName("name")] + public required string Name { get; set; } + + [BsonElement("category")] + [JsonPropertyName("category")] + public required string Category { get; set; } + + [BsonElement("description")] + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [BsonElement("basePrice")] + [BsonRepresentation(BsonType.Decimal128)] + [JsonPropertyName("basePrice")] + public decimal BasePrice { get; set; } + + [BsonElement("createdAt")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [BsonElement("updatedAt")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } +} diff --git a/server/Models/OrderDocument.cs b/server/Models/OrderDocument.cs new file mode 100644 index 0000000..10e4af9 --- /dev/null +++ b/server/Models/OrderDocument.cs @@ -0,0 +1,84 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System.Text.Json.Serialization; + +namespace RanazePos.Api.Models; + +public sealed class OrderDocument +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [JsonPropertyName("_id")] + public string? Id { get; set; } + + [BsonElement("number")] + [JsonPropertyName("number")] + public int Number { get; set; } + + [BsonElement("customerName")] + [JsonPropertyName("customerName")] + public string CustomerName { get; set; } = ""; + + [BsonElement("paymentMethod")] + [JsonPropertyName("paymentMethod")] + public string PaymentMethod { get; set; } = "cash"; + + [BsonElement("status")] + [JsonPropertyName("status")] + public string Status { get; set; } = "new"; + + [BsonElement("lines")] + [JsonPropertyName("lines")] + public required List Lines { get; set; } + + [BsonElement("total")] + [BsonRepresentation(BsonType.Decimal128)] + [JsonPropertyName("total")] + public decimal Total { get; set; } + + [BsonElement("createdAt")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [BsonElement("updatedAt")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } +} + +public sealed class OrderLineDocument +{ + [BsonElement("menuItemId")] + [JsonPropertyName("menuItemId")] + public required string MenuItemId { get; set; } + + [BsonElement("name")] + [JsonPropertyName("name")] + public required string Name { get; set; } + + [BsonElement("basePrice")] + [BsonRepresentation(BsonType.Decimal128)] + [JsonPropertyName("basePrice")] + public decimal BasePrice { get; set; } + + [BsonElement("size")] + [JsonPropertyName("size")] + public required string Size { get; set; } + + [BsonElement("sweetness")] + [JsonPropertyName("sweetness")] + public required string Sweetness { get; set; } + + [BsonElement("ice")] + [JsonPropertyName("ice")] + public required string Ice { get; set; } + + [BsonElement("toppings")] + [JsonPropertyName("toppings")] + public List Toppings { get; set; } = []; + + [BsonElement("notes")] + [JsonPropertyName("notes")] + public string Notes { get; set; } = ""; +} diff --git a/server/Models/Requests.cs b/server/Models/Requests.cs new file mode 100644 index 0000000..b393cd7 --- /dev/null +++ b/server/Models/Requests.cs @@ -0,0 +1,21 @@ +namespace RanazePos.Api.Models; + +public sealed class CreateMenuItemRequest +{ + public string? Name { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public decimal? BasePrice { get; set; } +} + +public sealed class CreateOrderRequest +{ + public string? CustomerName { get; set; } + public string? PaymentMethod { get; set; } + public List? Lines { get; set; } +} + +public sealed class UpdateOrderStatusRequest +{ + public string? Status { get; set; } +} diff --git a/server/Program.cs b/server/Program.cs new file mode 100644 index 0000000..ce64acc --- /dev/null +++ b/server/Program.cs @@ -0,0 +1,322 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using RanazePos.Api.Configuration; +using RanazePos.Api.Infrastructure; +using RanazePos.Api.Models; + +var builder = WebApplication.CreateBuilder(args); +var allowedPaymentMethods = new HashSet(StringComparer.OrdinalIgnoreCase) { "cash", "tab", "unpaid" }; +var allowedStatuses = new HashSet(StringComparer.OrdinalIgnoreCase) { "new", "making", "ready", "completed" }; + +var dotEnvPath = Path.Combine(builder.Environment.ContentRootPath, "..", ".env"); +builder.Configuration.AddInMemoryCollection(DotEnvLoader.Load(dotEnvPath)); + +var port = builder.Configuration.GetValue("PORT", 4000); +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + +builder.Services + .AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy + .WithOrigins("http://localhost:5173", "http://127.0.0.1:5173") + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + +builder.Services.Configure(options => +{ + var connectionString = builder.Configuration["MONGODB_URI"]; + var databaseName = builder.Configuration["MONGODB_DATABASE"]; + + builder.Configuration.GetSection(MongoDbOptions.SectionName).Bind(options); + + if (!string.IsNullOrWhiteSpace(connectionString)) + { + options.ConnectionString = connectionString; + } + + if (!string.IsNullOrWhiteSpace(databaseName)) + { + options.DatabaseName = databaseName; + } + else if (!string.IsNullOrWhiteSpace(connectionString)) + { + var databaseFromConnection = MongoUrl.Create(connectionString).DatabaseName; + if (!string.IsNullOrWhiteSpace(databaseFromConnection)) + { + options.DatabaseName = databaseFromConnection; + } + } +}); + +builder.Services.AddSingleton(serviceProvider => +{ + var options = serviceProvider.GetRequiredService>().Value; + var client = new MongoClient(options.ConnectionString); + return client.GetDatabase(options.DatabaseName); +}); + +builder.Services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService().GetCollection("MenuItem")); + +builder.Services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService().GetCollection("Order")); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = null; +}); + +var app = builder.Build(); + +app.UseCors(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var exception = context.Features.Get()?.Error; + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync(new { error = exception?.Message ?? "Internal server error" }); + }); +}); + +var menuCollection = app.Services.GetRequiredService>(); +var orderCollection = app.Services.GetRequiredService>(); + +await EnsureIndexesAsync(orderCollection); + +app.MapGet("/api/health", () => Results.Json(new { ok = true })); + +app.MapGet("/api/bootstrap", async () => +{ + await EnsureSeedMenuAsync(menuCollection); + + var menu = await menuCollection + .Find(Builders.Filter.Empty) + .SortBy(item => item.Category) + .ThenBy(item => item.Name) + .ToListAsync(); + + var orders = await orderCollection + .Find(order => order.Status != "completed") + .SortByDescending(order => order.CreatedAt) + .ToListAsync(); + + return Results.Json(new + { + menu, + orders, + settings = Defaults.Settings + }); +}); + +app.MapPost("/api/menu", async (CreateMenuItemRequest request) => +{ + if (string.IsNullOrWhiteSpace(request.Name) || + string.IsNullOrWhiteSpace(request.Category) || + request.BasePrice is null || + request.BasePrice < 0) + { + return Results.BadRequest(new { error = "Invalid menu item payload." }); + } + + var timestamp = DateTime.UtcNow; + var menuItem = new MenuItemDocument + { + Name = request.Name.Trim(), + Category = request.Category.Trim(), + Description = request.Description?.Trim() ?? "", + BasePrice = request.BasePrice.Value, + CreatedAt = timestamp, + UpdatedAt = timestamp + }; + + await menuCollection.InsertOneAsync(menuItem); + return Results.Json(new { menuItem }, statusCode: 201); +}); + +app.MapDelete("/api/menu/{menuItemId}", async (string menuItemId) => +{ + await menuCollection.DeleteOneAsync(item => item.Id == menuItemId); + return Results.Json(new { ok = true }); +}); + +app.MapPost("/api/menu/reset", async () => +{ + await menuCollection.DeleteManyAsync(Builders.Filter.Empty); + await SeedMenuAsync(menuCollection); + + var menu = await menuCollection + .Find(Builders.Filter.Empty) + .SortBy(item => item.Category) + .ThenBy(item => item.Name) + .ToListAsync(); + + return Results.Json(new { menu }); +}); + +app.MapPost("/api/orders", async (CreateOrderRequest request) => +{ + if (request.Lines is null || request.Lines.Count == 0) + { + return Results.BadRequest(new { error = "Orders require at least one line item." }); + } + + var paymentMethod = string.IsNullOrWhiteSpace(request.PaymentMethod) ? "cash" : request.PaymentMethod.Trim(); + if (!allowedPaymentMethods.Contains(paymentMethod)) + { + return Results.BadRequest(new { error = "Invalid payment method." }); + } + + if (request.Lines.Any(line => + string.IsNullOrWhiteSpace(line.MenuItemId) || + string.IsNullOrWhiteSpace(line.Name) || + string.IsNullOrWhiteSpace(line.Size) || + string.IsNullOrWhiteSpace(line.Sweetness) || + string.IsNullOrWhiteSpace(line.Ice))) + { + return Results.BadRequest(new { error = "Invalid order payload." }); + } + + var timestamp = DateTime.UtcNow; + var order = new OrderDocument + { + Number = await NextOrderNumberAsync(orderCollection), + CustomerName = request.CustomerName?.Trim() ?? "", + PaymentMethod = paymentMethod, + Status = "new", + Lines = request.Lines.Select(line => new OrderLineDocument + { + MenuItemId = line.MenuItemId, + Name = line.Name, + BasePrice = line.BasePrice, + Size = line.Size, + Sweetness = line.Sweetness, + Ice = line.Ice, + Toppings = line.Toppings ?? [], + Notes = line.Notes ?? "" + }).ToList(), + Total = ComputeOrderTotal(request.Lines, Defaults.Settings), + CreatedAt = timestamp, + UpdatedAt = timestamp + }; + + await orderCollection.InsertOneAsync(order); + return Results.Json(new { order }, statusCode: 201); +}); + +app.MapPatch("/api/orders/{orderId}/status", async (string orderId, UpdateOrderStatusRequest request) => +{ + if (string.IsNullOrWhiteSpace(request.Status) || !allowedStatuses.Contains(request.Status)) + { + return Results.BadRequest(new { error = "Invalid order status." }); + } + + var update = Builders.Update + .Set(order => order.Status, request.Status) + .Set(order => order.UpdatedAt, DateTime.UtcNow); + + var options = new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }; + + var order = await orderCollection.FindOneAndUpdateAsync(order => order.Id == orderId, update, options); + return Results.Json(new { order }); +}); + +app.MapDelete("/api/orders/{orderId}", async (string orderId) => +{ + await orderCollection.DeleteOneAsync(order => order.Id == orderId); + return Results.Json(new { ok = true }); +}); + +var clientDistPath = Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "client", "dist")); +if (Directory.Exists(clientDistPath)) +{ + var staticFiles = new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(clientDistPath), + ContentTypeProvider = new FileExtensionContentTypeProvider() + }; + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = new PhysicalFileProvider(clientDistPath) + }); + app.UseStaticFiles(staticFiles); + + app.MapFallback(async context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = 404; + return; + } + + await context.Response.SendFileAsync(Path.Combine(clientDistPath, "index.html")); + }); +} + +app.Run(); + +static async Task EnsureIndexesAsync(IMongoCollection orders) +{ + var indexModel = new CreateIndexModel( + Builders.IndexKeys.Ascending(order => order.Number), + new CreateIndexOptions { Unique = true }); + + await orders.Indexes.CreateOneAsync(indexModel); +} + +static async Task EnsureSeedMenuAsync(IMongoCollection menuCollection) +{ + var count = await menuCollection.CountDocumentsAsync(Builders.Filter.Empty); + if (count == 0) + { + await SeedMenuAsync(menuCollection); + } +} + +static async Task SeedMenuAsync(IMongoCollection menuCollection) +{ + var now = DateTime.UtcNow; + var seededMenu = Defaults.Menu.Select(item => new MenuItemDocument + { + Name = item.Name, + Category = item.Category, + Description = item.Description, + BasePrice = item.BasePrice, + CreatedAt = now, + UpdatedAt = now + }); + + await menuCollection.InsertManyAsync(seededMenu); +} + +static async Task NextOrderNumberAsync(IMongoCollection orders) +{ + var latest = await orders.Find(Builders.Filter.Empty) + .SortByDescending(order => order.Number) + .FirstOrDefaultAsync(); + + return (latest?.Number ?? 1000) + 1; +} + +static decimal ComputeOrderTotal(IEnumerable lines, PosSettings settings) +{ + return lines.Sum(line => + { + var sizeDelta = settings.SizeOptions.FirstOrDefault(option => option.Name == line.Size)?.Delta ?? 0m; + var toppingsTotal = (line.Toppings ?? []).Sum(toppingName => + settings.Toppings.FirstOrDefault(item => item.Name == toppingName)?.Price ?? 0m); + + return line.BasePrice + sizeDelta + toppingsTotal; + }); +} diff --git a/server/RanazePos.Api.csproj b/server/RanazePos.Api.csproj new file mode 100644 index 0000000..c104522 --- /dev/null +++ b/server/RanazePos.Api.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/server/appsettings.json b/server/appsettings.json new file mode 100644 index 0000000..5711251 --- /dev/null +++ b/server/appsettings.json @@ -0,0 +1,7 @@ +{ + "MongoDb": { + "ConnectionString": "mongodb://127.0.0.1:27017", + "DatabaseName": "ranaze-pos" + }, + "AllowedHosts": "*" +} diff --git a/server/config.js b/server/config.js deleted file mode 100644 index 763e347..0000000 --- a/server/config.js +++ /dev/null @@ -1,8 +0,0 @@ -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" -}; diff --git a/server/db.js b/server/db.js deleted file mode 100644 index b26199e..0000000 --- a/server/db.js +++ /dev/null @@ -1,6 +0,0 @@ -import mongoose from "mongoose"; -import { config } from "./config.js"; - -export async function connectToDatabase() { - await mongoose.connect(config.mongoUri); -} diff --git a/server/defaults.js b/server/defaults.js deleted file mode 100644 index 06a5587..0000000 --- a/server/defaults.js +++ /dev/null @@ -1,41 +0,0 @@ -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 - } -]; diff --git a/server/index.js b/server/index.js deleted file mode 100644 index 350eb86..0000000 --- a/server/index.js +++ /dev/null @@ -1,143 +0,0 @@ -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); - }); diff --git a/server/models/MenuItem.js b/server/models/MenuItem.js deleted file mode 100644 index b2d618e..0000000 --- a/server/models/MenuItem.js +++ /dev/null @@ -1,13 +0,0 @@ -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); diff --git a/server/models/Order.js b/server/models/Order.js deleted file mode 100644 index f3b3e0e..0000000 --- a/server/models/Order.js +++ /dev/null @@ -1,37 +0,0 @@ -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); diff --git a/server/services.js b/server/services.js deleted file mode 100644 index 3cad066..0000000 --- a/server/services.js +++ /dev/null @@ -1,35 +0,0 @@ -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); -}