Files
ranaze-pos/server/Program.cs
T
2026-05-05 16:22:37 +00:00

323 lines
9.7 KiB
C#

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