Converting server to dotnet
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
MONGODB_URI=mongodb://127.0.0.1:27017/ranaze-pos
|
||||||
|
PORT=4000
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
client/dist
|
client/dist
|
||||||
.env
|
.env
|
||||||
|
server/bin
|
||||||
|
server/obj
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Self-hosted tablet-first POS for a boba shop.
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Frontend: `React` + `Vite`
|
- Frontend: `React` + `Vite`
|
||||||
- Backend: `Express`
|
- Backend: `ASP.NET Core` minimal API
|
||||||
- Database: self-hosted `MongoDB` via `Mongoose`
|
- Database: self-hosted `MongoDB` via `MongoDB.Driver`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -19,14 +19,14 @@ Self-hosted tablet-first POS for a boba shop.
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
- `client/`: React tablet UI
|
- `client/`: React tablet UI
|
||||||
- `server/`: Express API and Mongo models
|
- `server/`: ASP.NET Core API and Mongo models
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Create `.env` in the repo root:
|
Copy `.env.example` to `.env` in the repo root:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
MONGODB_URI=mongodb://localhost:27017/ranaze-pos
|
MONGODB_URI=mongodb://127.0.0.1:27017/ranaze-pos
|
||||||
PORT=4000
|
PORT=4000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ PORT=4000
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
dotnet restore server/RanazePos.Api.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run in development
|
## Run in development
|
||||||
|
|||||||
+5
-8
@@ -2,18 +2,15 @@
|
|||||||
"name": "ranaze-pos",
|
"name": "ranaze-pos",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:client": "vite --config client/vite.config.js",
|
"dev:client": "vite --config client/vite.config.js",
|
||||||
"dev:server": "node server/index.js",
|
"dev:server": "dotnet run --project server/RanazePos.Api.csproj",
|
||||||
"build": "vite build --config client/vite.config.js",
|
"build:client": "vite build --config client/vite.config.js",
|
||||||
"start": "node server/index.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": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"mongoose": "^8.9.5",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<MenuItemDocument> 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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RanazePos.Api.Configuration;
|
||||||
|
|
||||||
|
public sealed class PosSettings
|
||||||
|
{
|
||||||
|
[JsonPropertyName("sizeOptions")]
|
||||||
|
public required IReadOnlyList<SizeOption> SizeOptions { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sweetnessOptions")]
|
||||||
|
public required IReadOnlyList<string> SweetnessOptions { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("iceOptions")]
|
||||||
|
public required IReadOnlyList<string> IceOptions { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("toppings")]
|
||||||
|
public required IReadOnlyList<ToppingOption> 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace RanazePos.Api.Infrastructure;
|
||||||
|
|
||||||
|
public static class DotEnvLoader
|
||||||
|
{
|
||||||
|
public static IDictionary<string, string?> Load(string path)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<OrderLineDocument> 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<string> Toppings { get; set; } = [];
|
||||||
|
|
||||||
|
[BsonElement("notes")]
|
||||||
|
[JsonPropertyName("notes")]
|
||||||
|
public string Notes { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -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<OrderLineDocument>? Lines { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateOrderStatusRequest
|
||||||
|
{
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
@@ -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<string>(StringComparer.OrdinalIgnoreCase) { "cash", "tab", "unpaid" };
|
||||||
|
var allowedStatuses = new HashSet<string>(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<MongoDbOptions>(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<IMongoDatabase>(serviceProvider =>
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<MongoDbOptions>>().Value;
|
||||||
|
var client = new MongoClient(options.ConnectionString);
|
||||||
|
return client.GetDatabase(options.DatabaseName);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IMongoDatabase>().GetCollection<MenuItemDocument>("MenuItem"));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IMongoDatabase>().GetCollection<OrderDocument>("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<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
|
||||||
|
context.Response.StatusCode = 500;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { error = exception?.Message ?? "Internal server error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var menuCollection = app.Services.GetRequiredService<IMongoCollection<MenuItemDocument>>();
|
||||||
|
var orderCollection = app.Services.GetRequiredService<IMongoCollection<OrderDocument>>();
|
||||||
|
|
||||||
|
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<MenuItemDocument>.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<MenuItemDocument>.Filter.Empty);
|
||||||
|
await SeedMenuAsync(menuCollection);
|
||||||
|
|
||||||
|
var menu = await menuCollection
|
||||||
|
.Find(Builders<MenuItemDocument>.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<OrderDocument>.Update
|
||||||
|
.Set(order => order.Status, request.Status)
|
||||||
|
.Set(order => order.UpdatedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var options = new FindOneAndUpdateOptions<OrderDocument>
|
||||||
|
{
|
||||||
|
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<OrderDocument> orders)
|
||||||
|
{
|
||||||
|
var indexModel = new CreateIndexModel<OrderDocument>(
|
||||||
|
Builders<OrderDocument>.IndexKeys.Ascending(order => order.Number),
|
||||||
|
new CreateIndexOptions { Unique = true });
|
||||||
|
|
||||||
|
await orders.Indexes.CreateOneAsync(indexModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureSeedMenuAsync(IMongoCollection<MenuItemDocument> menuCollection)
|
||||||
|
{
|
||||||
|
var count = await menuCollection.CountDocumentsAsync(Builders<MenuItemDocument>.Filter.Empty);
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
await SeedMenuAsync(menuCollection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task SeedMenuAsync(IMongoCollection<MenuItemDocument> 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<int> NextOrderNumberAsync(IMongoCollection<OrderDocument> orders)
|
||||||
|
{
|
||||||
|
var latest = await orders.Find(Builders<OrderDocument>.Filter.Empty)
|
||||||
|
.SortByDescending(order => order.Number)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return (latest?.Number ?? 1000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static decimal ComputeOrderTotal(IEnumerable<OrderLineDocument> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://127.0.0.1:27017",
|
||||||
|
"DatabaseName": "ranaze-pos"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import mongoose from "mongoose";
|
|
||||||
import { config } from "./config.js";
|
|
||||||
|
|
||||||
export async function connectToDatabase() {
|
|
||||||
await mongoose.connect(config.mongoUri);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
-143
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user