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
|
||||
client/dist
|
||||
.env
|
||||
server/bin
|
||||
server/obj
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-8
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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