Skip to content
Open
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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# EventManager

.NET 10 solution: Blazor WebAssembly UI, Azure Functions API, shared entities, and xUnit tests.
Run `dotnet test MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj` and `dotnet build MW-GC.EventManager.slnx` before submitting changes. Use conventional commits and target pull requests at `dev`; do not merge or deploy without authorization.

## Agent skills

### Issue tracker

GitHub Issues in MW-GC/EventManager. See `docs/agents/issue-tracker.md`.

### Triage labels

Use the default five triage roles. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context layout. See `docs/agents/domain.md`.
53 changes: 46 additions & 7 deletions MW-GC.EventManager.API/Functions/EventFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ public async Task<IActionResult> Generate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events/generate")] HttpRequest req,
CancellationToken ct)
{
var request = await req.ReadFromJsonAsync<GenerateEventRequest>(ct) ?? new();
var request = await ReadBody<GenerateEventRequest>(req, ct);
if (request is null) return new BadRequestResult();

if (request.Count < 1)
return new BadRequestObjectResult("Count must be at least 1.");
if (request.Count is < 1 or > EventEntity.MaximumSelections)
return new BadRequestObjectResult($"Count must be between 1 and {EventEntity.MaximumSelections}.");
if (request.SelectedGameIds is null || request.SelectedThemeIds is null || request.SelectedHolidayIds is null)
return new BadRequestObjectResult("Filter lists must not be null.");

var gameEntities = await _games.GetAllAsync(ct);
var activityEntities = await _activities.GetAllAsync(ct);
Expand Down Expand Up @@ -83,6 +86,7 @@ public async Task<IActionResult> Generate(
UniqueGamesOnly = request.UniqueGamesOnly
};

entity.NormalizeWinner();
await _store.UpsertAsync(entity, ct);
return new CreatedResult($"/api/events/{entity.Id}", entity);
}
Expand All @@ -92,14 +96,35 @@ public async Task<IActionResult> SaveCustomized(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events")] HttpRequest req,
CancellationToken ct)
{
var entity = await req.ReadFromJsonAsync<EventEntity>(ct);
var entity = await ReadBody<EventEntity>(req, ct);
if (entity is null) return new BadRequestResult();
if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error);

entity.Id = Guid.NewGuid();
await _store.UpsertAsync(entity, ct);
var hasKey = req.Headers.TryGetValue("Idempotency-Key", out var keys);
var createId = Guid.NewGuid();
if (hasKey && (keys.Count != 1 || !Guid.TryParseExact(keys[0], "D", out createId) || createId == Guid.Empty))
return new BadRequestObjectResult("Idempotency-Key must be a non-empty GUID in D format.");

entity.Id = createId;
entity.NormalizeWinner();
// Older clients still get a fresh ID; all creates use atomic inserts.
if (!await _store.TryAddAsync(entity, ct))
{
var existing = await _store.GetAsync(createId, ct);
if (existing is not null && CreateDetails(existing) == CreateDetails(entity))
return new OkObjectResult(existing);
return new ConflictObjectResult("This create key already exists with different details. Reload the event list and edit the saved event; do not start another create to retry this save.");
}
return new CreatedResult($"/api/events/{entity.Id}", entity);
}

// Compare normalized domain data, not row keys, timestamps or ETags. Never overwrite
// a later edit when reconciling an ambiguous create response.
private static string CreateDetails(EventEntity entity) => System.Text.Json.JsonSerializer.Serialize(new
{
entity.Name, Date = entity.Date.ToUniversalTime(), entity.Selections, entity.UniqueGamesOnly, entity.WinnerActivityId
});

[Function("UpdateEvent")]
public async Task<IActionResult> Update(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "events/{id:guid}")] HttpRequest req,
Expand All @@ -108,14 +133,28 @@ public async Task<IActionResult> Update(
var existing = await _store.GetAsync(id, ct);
if (existing is null) return new NotFoundResult();

var entity = await req.ReadFromJsonAsync<EventEntity>(ct);
var entity = await ReadBody<EventEntity>(req, ct);
if (entity is null) return new BadRequestResult();
if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error);

entity.Id = id;
entity.NormalizeWinner();
await _store.UpsertAsync(entity, ct);
return new OkObjectResult(entity);
}

private static async Task<T?> ReadBody<T>(HttpRequest request, CancellationToken ct) where T : class
{
try
{
return await request.ReadFromJsonAsync<T>(ct);
}
catch (System.Text.Json.JsonException)
{
return null;
}
}

[Function("DeleteEvent")]
public async Task<IActionResult> Delete(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "events/{id:guid}")] HttpRequest req,
Expand Down
3 changes: 3 additions & 0 deletions MW-GC.EventManager.API/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("MW-GC.EventManager.Tests")]
4 changes: 4 additions & 0 deletions MW-GC.EventManager.API/Services/EventGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ internal sealed class EventGenerator
IReadOnlyList<Activity> activities,
GenerateEventRequest request)
{
if (request.Count is < 1 or > MW_GC.EventManager.Shared.Entities.EventEntity.MaximumSelections
|| request.SelectedGameIds is null || request.SelectedThemeIds is null || request.SelectedHolidayIds is null)
return null;

var filtered = FilterActivities(activities, request);

var gamesWithActivities = games
Expand Down
25 changes: 24 additions & 1 deletion MW-GC.EventManager.API/Services/TableStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ public async Task UpsertAsync(TEntity entity, CancellationToken ct = default)
return;
}

await _table.UpsertEntityAsync(Flatten(entity), TableUpdateMode.Replace, ct);
}

/// <summary>Atomically inserts without replacing an existing row, including on concurrent retries.</summary>
public async Task<bool> TryAddAsync(TEntity entity, CancellationToken ct = default)
{
entity.PartitionKey = _partitionKey;
try
{
if (ComplexProps.Length == 0)
await _table.AddEntityAsync(entity, ct);
else
await _table.AddEntityAsync(Flatten(entity), ct);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409 && ex.ErrorCode == "EntityAlreadyExists")
{
return false;
}
}

private static TableEntity Flatten(TEntity entity)
{
// Flatten complex properties to JSON strings.
var row = new TableEntity(entity.PartitionKey, entity.RowKey);
foreach (var prop in typeof(TEntity).GetProperties(BindingFlags.Public | BindingFlags.Instance))
Expand All @@ -112,7 +135,7 @@ or nameof(EntityBase.Timestamp) or nameof(EntityBase.ETag) or nameof(EntityBase.
else
row[prop.Name] = value;
}
await _table.UpsertEntityAsync(row, TableUpdateMode.Replace, ct);
return row;
}

public async Task DeleteAsync(Guid id, CancellationToken ct = default)
Expand Down
27 changes: 27 additions & 0 deletions MW-GC.EventManager.Shared/Entities/EventEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,31 @@ public class EventEntity : EntityBase
/// </summary>
public Guid? WinnerActivityId { get; set; }
public bool UniqueGamesOnly { get; set; } = true;

public const int MaximumSelections = 5;

/// <summary>Validate selection snapshots before normalizing or persisting an event.</summary>
public string? ValidateSelections()
{
if (Selections is null || Selections.Count is < 1 or > MaximumSelections)
return $"Select between 1 and {MaximumSelections} activities.";
if (Selections.Any(s => s is null || s.Game is null || s.Activity is null
|| s.Game.Id == Guid.Empty || s.Activity.Id == Guid.Empty
|| s.Activity.GameId != s.Game.Id))
return "Each selection must contain an activity and its matching game.";
if (Selections.Select(s => s.Activity.Id).Distinct().Count() != Selections.Count)
return "An activity may only be selected once.";
if (UniqueGamesOnly && Selections.Select(s => s.Game.Id).Distinct().Count() != Selections.Count)
return "Each selection must use a different game when unique games are required.";
return null;
}

/// <summary>Apply winner rules to the final selections before saving.</summary>
public void NormalizeWinner()
{
if (Selections.Count == 1)
WinnerActivityId = Selections[0].Activity.Id;
else if (WinnerActivityId.HasValue && !Selections.Any(s => s.Activity.Id == WinnerActivityId.Value))
WinnerActivityId = null;
}
}
Loading
Loading