326 lines
9.7 KiB
C#
326 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, OrderDocument>
|
|
{
|
|
ReturnDocument = ReturnDocument.After
|
|
};
|
|
|
|
var order = await orderCollection.FindOneAndUpdateAsync<OrderDocument>(
|
|
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;
|
|
});
|
|
}
|