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; }); }