Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions NainOrder.Api/Controllers/DropsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using NainOrder.Application.DTOs;
using NainOrder.Application.Interfaces;

namespace NainOrder.Api.Controllers;

/// <summary>Limited-stock drops: a fixed number of units sold to a crowd with short expiring holds.</summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class DropsController : ControllerBase
{
private const string BuyerTokenHeader = "X-Buyer-Token";

private readonly IDropService _dropService;

public DropsController(IDropService dropService) => _dropService = dropService;

/// <summary>Most recent drops.</summary>
[HttpGet]
[ProducesResponseType<IReadOnlyList<DropDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<DropDto>>> List(CancellationToken cancellationToken) =>
Ok(await _dropService.ListAsync(cancellationToken));

/// <summary>Live state of a drop. With <c>X-Buyer-Token</c>, also the caller's own holds.</summary>
[HttpGet("{id:guid}")]
[ProducesResponseType<DropDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DropDto>> GetById(Guid id, CancellationToken cancellationToken) =>
Ok(await _dropService.GetAsync(id, BuyerToken(), cancellationToken));

/// <summary>Creates a drop for a catalogue product. It opens after <c>opensInSeconds</c>.</summary>
[HttpPost]
[ProducesResponseType<DropDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DropDto>> Create(CreateDropRequest request, CancellationToken cancellationToken)
{
var drop = await _dropService.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = drop.Id }, drop);
}

/// <summary>Takes units from a live drop: an expiring hold for the caller's buyer token.</summary>
[HttpPost("{id:guid}/holds")]
[ProducesResponseType<HoldDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HoldDto>> Take(
Guid id, TakeUnitsRequest request, CancellationToken cancellationToken)
{
if (BuyerToken() is not { } token) return MissingToken();
return Ok(await _dropService.TakeAsync(id, token, request, cancellationToken));
}

/// <summary>Confirms an unexpired hold; it becomes a paid order.</summary>
[HttpPost("{id:guid}/holds/{holdId:guid}/confirm")]
[ProducesResponseType<OrderDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OrderDto>> Confirm(Guid id, Guid holdId, CancellationToken cancellationToken)
{
if (BuyerToken() is not { } token) return MissingToken();
return Ok(await _dropService.ConfirmAsync(id, holdId, token, cancellationToken));
}

private string? BuyerToken()
{
var value = Request.Headers[BuyerTokenHeader].ToString().Trim();
return value.Length is > 0 and <= 64 ? value : null;
}

private ObjectResult MissingToken() => Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Buyer token required",
detail: $"Send a visitor token of 1 to 64 characters in the {BuyerTokenHeader} header.",
extensions: new Dictionary<string, object?> { ["code"] = "buyer_token_required" });
}
6 changes: 6 additions & 0 deletions NainOrder.Api/Infrastructure/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ public async ValueTask<bool> TryHandleAsync(
{
NotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Resource not found"),
OrderItemNotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Order line not found"),
HoldNotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Hold not found"),

DropNotLiveException e => (StatusCodes.Status409Conflict, e.Code, "Drop is not live"),
BuyerLimitReachedException e => (StatusCodes.Status409Conflict, e.Code, "Buyer limit reached"),
HoldExpiredException e => (StatusCodes.Status409Conflict, e.Code, "Hold expired"),
HoldNotActiveException e => (StatusCodes.Status409Conflict, e.Code, "Hold not active"),

ConflictException e => (StatusCodes.Status409Conflict, e.Code, "Conflict"),
InsufficientStockException e => (StatusCodes.Status409Conflict, e.Code, "Insufficient stock"),
Expand Down
51 changes: 51 additions & 0 deletions NainOrder.Api/Infrastructure/HoldExpirySweeper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using NainOrder.Application.Exceptions;
using NainOrder.Application.Interfaces;

namespace NainOrder.Api.Infrastructure;

/// <summary>
/// Marks expired drop holds once a second. A failed pass is logged and retried on the next tick:
/// the sweeper is an optimisation over lazy expiry, never the source of correctness (ADR-0008).
/// </summary>
public sealed class HoldExpirySweeper : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);

private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<HoldExpirySweeper> _logger;

public HoldExpirySweeper(IServiceScopeFactory scopeFactory, ILogger<HoldExpirySweeper> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval);

while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var drops = scope.ServiceProvider.GetRequiredService<IDropService>();
var expired = await drops.ExpireDueHoldsAsync(stoppingToken);

if (expired > 0) _logger.LogInformation("Expired {Count} drop holds", expired);
}
catch (ConflictException)
{
// A buyer wrote to the same drop first; the next tick sweeps again.
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "Hold expiry sweep failed");
}
}
}
}
25 changes: 21 additions & 4 deletions NainOrder.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
builder.Services.AddApplication();
builder.Services.AddInfrastructure(connectionString);

// Returns the units of unconfirmed drop holds shortly after their window ends. Correctness does
// not depend on it (reads apply the clock themselves); it keeps stored state and streams fresh.
builder.Services.AddHostedService<HoldExpirySweeper>();

builder.Services.AddControllers();
builder.Services.AddMemoryCache();

Expand Down Expand Up @@ -46,14 +50,21 @@
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(RateLimitPolicies.Api, httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
{
// A drop is polled every second and its crowd simulator fires dozens of requests at
// once, so drops get their own, larger window; everything else keeps 120 per minute.
var isDrop = httpContext.Request.Path.StartsWithSegments("/api/drops");
var address = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";

return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: isDrop ? $"drops:{address}" : address,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
PermitLimit = isDrop ? 1_200 : 120,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
});
});
});

// Render y cualquier PaaS terminan TLS por delante: sin esto la app vería http y una IP interna.
Expand Down Expand Up @@ -174,6 +185,12 @@ static async Task InitializeDatabaseAsync(WebApplication app)
try
{
await context.Database.MigrateAsync();

// WAL lets readers proceed while a writer commits. With the default rollback journal, a
// crowd of simultaneous buyers (plus the hold sweeper) surfaces "database is locked" as
// a 500. The mode is stored in the database file, so this is a no-op after the first run.
await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");

await DatabaseSeeder.SeedAsync(context);
logger.LogInformation("Database ready at {ConnectionString}", context.Database.GetConnectionString());
}
Expand Down
104 changes: 104 additions & 0 deletions NainOrder.Api/wwwroot/assets/css/views.css
Original file line number Diff line number Diff line change
Expand Up @@ -894,8 +894,112 @@
background: var(--danger);
}

/* --------------------------------------------------------------------------
Drop
-------------------------------------------------------------------------- */
.drop {
display: grid;
gap: var(--space-5);
}

.drop__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}

.drop__name {
font-size: var(--text-xl);
font-weight: 700;
}

.drop__price {
font-size: var(--text-lg);
color: var(--text-secondary);
}

.drop__state {
display: flex;
align-items: center;
gap: var(--space-3);
}

.drop__clock {
font-size: var(--text-lg);
font-weight: 650;
}

.drop__grid {
--cell: 44px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--cell), 1fr));
gap: var(--space-2);
}

.drop__cell,
.race__legend i[data-cell] {
border-radius: var(--radius-xs);
border: 1px solid var(--border-default);
background: var(--surface-2);
}

.drop__cell {
aspect-ratio: 1;
transition: background var(--duration-base) var(--ease), border-color var(--duration-base) var(--ease);
}

.drop__cell[data-cell="available"], .race__legend i[data-cell="available"] { background: color-mix(in srgb, var(--success) 18%, transparent); border-color: var(--success); }
.drop__cell[data-cell="held"], .race__legend i[data-cell="held"] { background: color-mix(in srgb, var(--warning) 38%, transparent); border-color: var(--warning); }
.drop__cell[data-cell="sold"], .race__legend i[data-cell="sold"] { background: var(--brand-2); border-color: transparent; }
.drop__cell[data-mine] { outline: 2px solid var(--brand-3); outline-offset: 1px; }

.drop__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}

.drop__hold {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--warning);
background: color-mix(in srgb, var(--warning) 10%, transparent);
}

.drop__hold[hidden] { display: none; }

.drop__hold-bar {
flex: 1 1 140px;
height: 6px;
border-radius: var(--radius-pill);
background: var(--surface-3);
overflow: hidden;
}

.drop__hold-bar i {
display: block;
height: 100%;
background: var(--warning);
transition: width 250ms linear;
}

.drop-recent {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}

.drop-recent__item { cursor: pointer; }

@media (prefers-reduced-motion: reduce) {
.race__tile { animation: none; }
.drop__cell, .drop__hold-bar i { transition: none; }
}

@media (max-width: 620px) {
Expand Down
45 changes: 39 additions & 6 deletions NainOrder.Api/wwwroot/assets/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,30 @@ const buildUrl = (path, query) => {
return url;
};

async function request(method, path, { body, query, signal } = {}) {
/**
* Anonymous visitor identity for drops (Q-005): a random token kept in this browser, sent as
* `X-Buyer-Token`. It identifies a visitor, never a person.
*/
export const buyerToken = () => {
try {
let token = localStorage.getItem("nainorder.buyer");
if (!token) {
token = crypto.randomUUID();
localStorage.setItem("nainorder.buyer", token);
}
return token;
} catch {
// Storage blocked: a per-page token still lets this visit work.
return (buyerToken.fallback ??= crypto.randomUUID());
}
};

async function request(method, path, { body, query, signal, headers } = {}) {
const startedAt = performance.now();

if (isStaticHost) {
try {
const result = await mockDispatch(method, path, { body, query });
const result = await mockDispatch(method, path, { body, query, headers });
const elapsedMs = performance.now() - startedAt;
const queryStr = query && Object.keys(query).length > 0 ? `?${new URLSearchParams(query).toString()}` : "";
publishTrace({
Expand Down Expand Up @@ -122,18 +140,20 @@ async function request(method, path, { body, query, signal } = {}) {
response = await fetch(url, {
method,
signal,
headers: body ? { "Content-Type": "application/json" } : undefined,
headers: body || headers ? { ...(body ? { "Content-Type": "application/json" } : {}), ...headers } : undefined,
body: body ? JSON.stringify(body) : undefined
});
} catch (networkError) {
// Backend offline: conmuta con gracia al motor de dominio en memoria
isStaticHost = true;
return request(method, path, { body, query, signal });
return request(method, path, { body, query, signal, headers });
}

if (response.status === 404) {
// A 404 with a problem+json body is the API answering about a missing resource; only a 404
// without it means there is no API behind this host (static hosting), so fall back then.
if (response.status === 404 && !response.headers.get("content-type")?.includes("problem+json")) {
isStaticHost = true;
return request(method, path, { body, query, signal });
return request(method, path, { body, query, signal, headers });
}

const elapsedMs = performance.now() - startedAt;
Expand Down Expand Up @@ -191,6 +211,19 @@ export const api = {
stateMachine: () => request("GET", "/meta/order-state-machine")
},

drops: {
list: () => request("GET", "/drops"),
get: (id) => request("GET", `/drops/${id}`, { headers: { "X-Buyer-Token": buyerToken() } }),
create: (body) => request("POST", "/drops", { body }),
take: (id, quantity) =>
request("POST", `/drops/${id}/holds`, { body: { quantity }, headers: { "X-Buyer-Token": buyerToken() } }),
confirm: (id, holdId) =>
request("POST", `/drops/${id}/holds/${holdId}/confirm`, { headers: { "X-Buyer-Token": buyerToken() } }),
/** Takes as another visitor; used by the crowd simulator. */
takeAs: (id, token, quantity) =>
request("POST", `/drops/${id}/holds`, { body: { quantity }, headers: { "X-Buyer-Token": token } })
},

demo: {
simulate: () => request("POST", "/demo/simulate-purchase"),
stockRace: (buyers, units) => request("POST", "/demo/stock-race", { body: { buyers, units } })
Expand Down
2 changes: 2 additions & 0 deletions NainOrder.Api/wwwroot/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { catalogView } from "./views/catalog.js";
import { ordersView } from "./views/orders.js";
import { architectureView } from "./views/architecture.js";
import { raceView } from "./views/race.js";
import { dropView } from "./views/drop.js";

const NAV = [
{ path: "/panel", label: "Panel", icon: "dashboard", view: dashboardView },
{ path: "/drop", label: "Drop", icon: "ticket", view: dropView },
{ path: "/catalogo", label: "Catálogo", icon: "catalog", view: catalogView },
{ path: "/pedidos", label: "Pedidos", icon: "orders", view: ordersView },
{ path: "/carrera", label: "Carrera de stock", icon: "race", view: raceView },
Expand Down
Loading
Loading