diff --git a/specifications/audit-logs.md b/specifications/audit-logs.md new file mode 100644 index 0000000..9fddfec --- /dev/null +++ b/specifications/audit-logs.md @@ -0,0 +1,408 @@ +# Audit Log Specification + +## Table of Contents + +- [Overview](#overview) +- [Database Schema](#database-schema) + - [Column Semantics](#column-semantics) + - [ResourceType Values](#resourcetype-values) + - [Outcome Values](#outcome-values) + - [Retention](#retention) +- [Per-Operation Audit Data](#per-operation-audit-data) + - [Checklist — ItemGroup](#checklist--itemgroup) + - [Checklist — Item](#checklist--item) + - [Checklist — Member](#checklist--member) + - [Authentication](#authentication) +- [Implementation Components](#implementation-components) + - [1. DatabaseInitializer Refactor](#1-databaseinitializer-refactor) + - [2. AuditEntry Record](#2-auditentry-record) + - [3. AuditContext — Scoped Service](#3-auditcontext--scoped-service) + - [4. IAuditWriter and ChannelAuditWriter](#4-iauditwriter-and-channelauditwriter) + - [5. AuditEndpointFilter](#5-auditendpointfilter) + - [6. JWT OnAuthenticationFailed Event](#6-jwt-onauthenticationfailed-event) + - [7. ForwardedHeaders Middleware](#7-forwardedheaders-middleware) + - [8. Test Infrastructure](#8-test-infrastructure) +- [Future Extensibility](#future-extensibility) + - [Field-Level Change Tracking](#field-level-change-tracking) + - [User Feature](#user-feature) + - [Admin UI](#admin-ui) + +--- + +## Overview + +Audit logging tracks all attempted and successful operations across the API, including authentication failures, to support security monitoring, attack detection, and administrative review. A future Admin UI will expose the audit log for querying and management. + +Audit logging must not affect the availability or correctness of normal API functionality. Write failures must be silently absorbed. The performance impact on request handling must be minimised. + +--- + +## Database Schema + +The `AuditLog` table is owned by the `AuditLog` feature slice and created by `AuditLogSchemaInitializer`. + +```sql +CREATE TABLE AuditLog ( + Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(), + Timestamp DATETIMEOFFSET NOT NULL, + TraceId NVARCHAR(32) NULL, + UserId UNIQUEIDENTIFIER NULL, + IpAddress NVARCHAR(45) NULL, + ResourceType NVARCHAR(20) NULL, + Operation NVARCHAR(50) NOT NULL, + ResourceId UNIQUEIDENTIFIER NULL, + SubResourceId UNIQUEIDENTIFIER NULL, + TargetUserId UNIQUEIDENTIFIER NULL, + ResourceName NVARCHAR(500) NULL, + Outcome NVARCHAR(20) NOT NULL, + FailureReason NVARCHAR(500) NULL +); + +CREATE INDEX IX_AuditLog_UserId_Timestamp ON AuditLog (UserId, Timestamp DESC); +CREATE INDEX IX_AuditLog_ResourceId_Timestamp ON AuditLog (ResourceId, Timestamp DESC); +CREATE INDEX IX_AuditLog_ResourceType_Timestamp ON AuditLog (ResourceType, Timestamp DESC); +CREATE INDEX IX_AuditLog_Outcome_Timestamp ON AuditLog (Outcome, Timestamp DESC); +CREATE INDEX IX_AuditLog_Timestamp ON AuditLog (Timestamp DESC); +``` + +### Column Semantics + +| Column | Description | +|---|---| +| `Id` | Sequential GUID for clustered index performance on high-insert tables | +| `Timestamp` | UTC timestamp of the event | +| `TraceId` | OpenTelemetry trace ID for correlation with distributed traces | +| `UserId` | The authenticated user performing the operation. NULL when unauthenticated | +| `IpAddress` | Real client IP after `ForwardedHeaders` middleware resolves `X-Forwarded-For` | +| `ResourceType` | Discriminator for the resource being acted upon — see values below | +| `Operation` | Name of the operation — matches `.WithName()` on the endpoint, or `AuthenticationFailed` | +| `ResourceId` | Primary resource identifier (always `itemGroupId` for Checklist operations) | +| `SubResourceId` | Secondary resource identifier (`itemId` for Item operations) | +| `TargetUserId` | The user being affected (populated for `AddMember` and `RemoveMember`) | +| `ResourceName` | Name of the resource at the time of the operation — see population rules below | +| `Outcome` | Result of the operation — see values below | +| `FailureReason` | Human-readable reason for failure. Populated for `AuthenticationFailed` and `MissingClaim` | + +### `ResourceType` Values + +| Value | Used for | +|---|---| +| `'ItemGroup'` | All ItemGroup and Member operations | +| `'Item'` | All Item operations | +| `'User'` | Future User profile operations | +| NULL | `AuthenticationFailed` — no resource involved | + +### `Outcome` Values + +| Value | Meaning | +|---|---| +| `Success` | 200, 201, or 204 response | +| `BadRequest` | Validation failed (e.g. blank name) | +| `NotFound` | Resource does not exist | +| `Conflict` | 409 — duplicate member or last-member removal attempt | +| `Forbidden` | Authenticated user is not a member of the target group | +| `MissingClaim` | JWT was valid and accepted, but `sub`/`user_id` claim is absent | +| `AuthenticationFailed` | Token was presented but rejected by JWT middleware | + +`Unauthorized` (HTTP 401) is not used as an outcome value. The two distinct auth failure modes are represented by `MissingClaim` and `AuthenticationFailed`. + +### Retention + +Audit rows are stored indefinitely. Deletion is a manual administrative action only. + +--- + +## Per-Operation Audit Data + +### Checklist — ItemGroup + +| Operation | `ResourceType` | `ResourceId` | `SubResourceId` | `TargetUserId` | `ResourceName` | +|---|---|---|---|---|---| +| `GetItemGroups` | `'ItemGroup'` | NULL | NULL | NULL | NULL | +| `GetItemGroup` | `'ItemGroup'` | `itemGroupId` | NULL | NULL | NULL | +| `CreateItemGroup` | `'ItemGroup'` | `itemGroupId` (after insert) | NULL | NULL | NULL | +| `UpdateItemGroup` | `'ItemGroup'` | `itemGroupId` | NULL | NULL | New name from request body | +| `DeleteItemGroup` | `'ItemGroup'` | `itemGroupId` | NULL | NULL | Name fetched via SELECT before delete | + +### Checklist — Item + +| Operation | `ResourceType` | `ResourceId` | `SubResourceId` | `TargetUserId` | `ResourceName` | +|---|---|---|---|---|---| +| `CreateItem` | `'Item'` | `itemGroupId` | NULL | NULL | NULL | +| `UpdateItem` | `'Item'` | `itemGroupId` | `itemId` | NULL | New name from request body | +| `DeleteItem` | `'Item'` | `itemGroupId` | `itemId` | NULL | Name fetched via SELECT before delete | + +### Checklist — Member + +| Operation | `ResourceType` | `ResourceId` | `SubResourceId` | `TargetUserId` | `ResourceName` | +|---|---|---|---|---|---| +| `GetMembers` | `'ItemGroup'` | `itemGroupId` | NULL | NULL | NULL | +| `AddMember` | `'ItemGroup'` | `itemGroupId` | NULL | `memberId` | NULL | +| `RemoveMember` | `'ItemGroup'` | `itemGroupId` | NULL | `memberId` | NULL | + +### Authentication + +| Operation | `ResourceType` | Fields populated | +|---|---|---| +| `AuthenticationFailed` | NULL | `IpAddress`, `TraceId`, `FailureReason` (exception message from JWT middleware) | + +--- + +## Implementation Components + +### 1. `DatabaseInitializer` Refactor + +`DatabaseInitializer` becomes a thin orchestrator. Each feature slice owns its own schema initializer. + +``` +Core/ + DatabaseInitializer.cs ← calls each slice initializer in sequence + Checklist/ + ChecklistSchemaInitializer.cs ← existing ItemGroups, Items, Members DDL moved here + AuditLog/ + AuditLogSchemaInitializer.cs ← new AuditLog DDL +``` + +`DatabaseInitializer.InitializeAsync` opens the connection then calls: +1. `ChecklistSchemaInitializer.CreateSchemaAsync` +2. `AuditLogSchemaInitializer.CreateSchemaAsync` + +Order matters only if cross-feature foreign keys exist. Currently none do. + +--- + +### 2. `AuditEntry` Record + +A plain DTO. Lives in `Core/AuditLog/`. + +```csharp +internal sealed record AuditEntry( + DateTimeOffset Timestamp, + string? TraceId, + Guid? UserId, + string? IpAddress, + string? ResourceType, + string Operation, + Guid? ResourceId, + Guid? SubResourceId, + Guid? TargetUserId, + string? ResourceName, + string Outcome, + string? FailureReason +); +``` + +--- + +### 3. `AuditContext` — Scoped Service + +Allows individual handlers to pass semantic context (name, target user) to the filter without the filter needing to re-parse the request body. + +```csharp +public sealed class AuditContext +{ + public string? ResourceName { get; set; } + public Guid? TargetUserId { get; set; } +} +``` + +Registered as `services.AddScoped()`. Injected into handlers that need it as an additional parameter — Minimal API binds it from DI automatically. + +Handlers that populate `AuditContext`: + +| Handler | Sets | +|---|---| +| `UpdateItemGroup` | `ResourceName` = new name from request body | +| `UpdateItem` | `ResourceName` = new name from request body | +| `DeleteItemGroup` | `ResourceName` = fetched from DB before delete | +| `DeleteItem` | `ResourceName` = fetched from DB before delete | +| `AddMember` | `TargetUserId` = `memberId` route value | +| `RemoveMember` | `TargetUserId` = `memberId` route value | + +All other handlers do not take `AuditContext`. + +--- + +### 4. `IAuditWriter` and `ChannelAuditWriter` + +```csharp +public interface IAuditWriter +{ + void Enqueue(AuditEntry entry); +} +``` + +`ChannelAuditWriter` is a singleton that: +- Holds an unbounded `Channel` (or bounded with drop-oldest policy to prevent unbounded memory growth under sustained load) +- Exposes `Enqueue` as a non-blocking `TryWrite` — no await, no blocking +- Implements `IHostedService` to drain the channel in the background +- Batches writes: flush when 50 entries are queued OR after 5 seconds, whichever comes first +- Opens its own `SqlConnection` using the raw connection string from `IConfiguration["ConnectionStrings:database"]` — **never reuses the scoped `IDbConnection`** from the request pipeline, ensuring audit writes are independent of any handler transactions +- Catches all exceptions during write, logs at `Warning` level via `ILogger`, and continues draining + +Registered in `Program.cs`: +```csharp +services.AddSingleton(); +services.AddHostedService(sp => (ChannelAuditWriter)sp.GetRequiredService()); +``` + +--- + +### 5. `AuditEndpointFilter` + +Registered once on the Checklist route group in `ChecklistApiEndpointRouteBuilderExtension`: + +```csharp +group.AddEndpointFilter(); +``` + +Responsibilities: +1. Call `next(context)` to execute the handler +2. Read route values (`itemGroupId`, `itemId`) from `HttpContext.Request.RouteValues` +3. Read `UserId` via `httpContext.User.GetUserId()` +4. Read `IpAddress` from `HttpContext.Connection.RemoteIpAddress` (corrected by ForwardedHeaders middleware) +5. Read `Operation` from `EndpointNameMetadata` (already set via `.WithName()` on every endpoint) +6. Read `ResourceType` from a static lookup dictionary keyed on `Operation` +7. Read `AuditContext` from `HttpContext.RequestServices` +8. Map the returned `IResult` type to an `Outcome` string via pattern matching on `TypedResults` types +9. Call `_auditWriter.Enqueue(entry)` — non-blocking +10. Wrap steps 1–9 in try/catch; log any exception at `Warning` and continue + +The `Outcome` mapping: + +| `IResult` type | `Outcome` | +|---|---| +| `Created`, `Ok`, `NoContent` | `Success` | +| `NotFound` | `NotFound` | +| `BadRequest` | `BadRequest` | +| `Conflict` | `Conflict` | +| `ForbidHttpResult` | `Forbidden` | +| `UnauthorizedHttpResult` | `MissingClaim` | + +--- + +### 6. JWT `OnAuthenticationFailed` Event + +In the `AddJwtBearer` configuration block in `Program.cs`: + +```csharp +options.Events = new JwtBearerEvents +{ + OnAuthenticationFailed = async context => + { + var writer = context.HttpContext.RequestServices.GetRequiredService(); + writer.Enqueue(new AuditEntry( + Timestamp: DateTimeOffset.UtcNow, + TraceId: Activity.Current?.TraceId.ToString(), + UserId: null, + IpAddress: context.HttpContext.Connection.RemoteIpAddress?.ToString(), + ResourceType: null, + Operation: "AuthenticationFailed", + ResourceId: null, + SubResourceId: null, + TargetUserId: null, + ResourceName: null, + Outcome: "AuthenticationFailed", + FailureReason: context.Exception.Message + )); + } +}; +``` + +`OnChallenge` is intentionally **not** handled — it fires for all unauthenticated requests including health checks and browser preflights, producing excessive noise with no useful signal. + +--- + +### 7. `ForwardedHeaders` Middleware + +Must be registered **first** in the middleware pipeline in `Program.cs`, before authentication, so that `HttpContext.Connection.RemoteIpAddress` reflects the real client IP when read by the filter and the JWT event. + +```csharp +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); +``` + +Configure `KnownProxies` or `KnownNetworks` appropriately for the production deployment environment to avoid IP spoofing via crafted `X-Forwarded-For` headers. + +--- + +### 8. Test Infrastructure + +#### `NoOpAuditWriter` + +Registered in `ApiFactory.ConfigureServices` to prevent the real `ChannelAuditWriter` from attempting SQL Server connections during tests: + +```csharp +services.AddSingleton(); +``` + +```csharp +internal sealed class NoOpAuditWriter : IAuditWriter +{ + public void Enqueue(AuditEntry entry) { } +} +``` + +#### `CapturingAuditWriter` + +For tests that assert on audit behavior, inject a `CapturingAuditWriter`: + +```csharp +internal sealed class CapturingAuditWriter : IAuditWriter +{ + public List Entries { get; } = []; + public void Enqueue(AuditEntry entry) => Entries.Add(entry); +} +``` + +#### `TestDatabase` + +Add the `AuditLog` table to `CreateTablesAsync` in SQLite-compatible DDL (no `NEWSEQUENTIALID()`, TEXT instead of UNIQUEIDENTIFIER): + +```sql +CREATE TABLE AuditLog ( + Id TEXT NOT NULL PRIMARY KEY, + Timestamp TEXT NOT NULL, + TraceId TEXT NULL, + UserId TEXT NULL, + IpAddress TEXT NULL, + ResourceType TEXT NULL, + Operation TEXT NOT NULL, + ResourceId TEXT NULL, + SubResourceId TEXT NULL, + TargetUserId TEXT NULL, + ResourceName TEXT NULL, + Outcome TEXT NOT NULL, + FailureReason TEXT NULL +); +``` + +--- + +## Future Extensibility + +### Field-Level Change Tracking + +Deferred to a future version. When needed: +- Add a nullable `Changes NVARCHAR(MAX)` column containing a JSON diff (e.g. `{"IsComplete": {"from": true, "to": false}}`) +- Only `UpdateItem` and `UpdateItemGroup` handlers need changes: one extra SELECT before the update, compute diff, set on `AuditContext` +- No changes to the filter, writer, or other handlers +- Existing rows will have NULL in the new column — no backfill needed + +### User Feature + +When the `User` feature slice is added: +- `UserSchemaInitializer.CreateSchemaAsync` is added to `DatabaseInitializer` +- User profile handlers use `ResourceType = 'User'`, `ResourceId = null` or the user's own ID as appropriate +- No schema changes to `AuditLog` required + +### Admin UI + +The `AuditLog` table is designed for direct query access. Recommended Admin UI queries: +- All events by user: `WHERE UserId = @userId ORDER BY Timestamp DESC` +- All events on a resource: `WHERE ResourceId = @id ORDER BY Timestamp DESC` +- All auth failures: `WHERE Outcome IN ('AuthenticationFailed', 'MissingClaim') ORDER BY Timestamp DESC` +- All forbidden probes by IP: `WHERE Outcome = 'Forbidden' AND IpAddress = @ip ORDER BY Timestamp DESC` +- Activity within a time window: `WHERE Timestamp BETWEEN @from AND @to ORDER BY Timestamp DESC` diff --git a/specifications/checklist.md b/specifications/checklist.md new file mode 100644 index 0000000..cf84d07 --- /dev/null +++ b/specifications/checklist.md @@ -0,0 +1,426 @@ +# Checklist Feature Specification + +## Table of Contents + +- [Overview](#overview) +- [Domain Model](#domain-model) + - [ItemGroup](#itemgroup) + - [Item](#item) + - [Members](#members) +- [Authorization Model](#authorization-model) +- [Database Schema](#database-schema) +- [API Endpoints](#api-endpoints) + - [Item Group Endpoints](#item-group-endpoints) + - [GET /api/list — GetItemGroups](#get-apilist--getitemgroups) + - [GET /api/list/{itemGroupId} — GetItemGroup](#get-apilistitemgroupid--getitemgroup) + - [POST /api/list — CreateItemGroup](#post-apilist--createitemgroup) + - [PUT /api/list/{itemGroupId} — UpdateItemGroup](#put-apilistitemgroupid--updateitemgroup) + - [DELETE /api/list/{itemGroupId} — DeleteItemGroup](#delete-apilistitemgroupid--deleteitemgroup) + - [Item Endpoints](#item-endpoints) + - [POST /api/list/{itemGroupId} — CreateItem](#post-apilistitemgroupid--createitem) + - [PUT /api/list/{itemGroupId}/{itemId} — UpdateItem](#put-apilistitemgroupiditemid--updateitem) + - [DELETE /api/list/{itemGroupId}/{itemId} — DeleteItem](#delete-apilistitemgroupiditemid--deleteitem) + - [Member Endpoints](#member-endpoints) + - [GET /api/list/{itemGroupId}/member — GetMembers](#get-apilistitemgroupidmember--getmembers) + - [POST /api/list/{itemGroupId}/member/{memberId} — AddMember](#post-apilistitemgroupidmembermemberid--addmember) + - [DELETE /api/list/{itemGroupId}/member/{memberId} — RemoveMember](#delete-apilistitemgroupidmembermemberid--removemember) +- [Shared Patterns](#shared-patterns) +- [Structural Conventions](#structural-conventions) + +--- + +## Overview + +The Checklist feature is a collaborative list management system. Users can create item groups (shared lists), add items to them, and invite other users as members. All members of a group have equal read and write access to the group and its items. + +The feature is implemented as a vertical slice under `Core/Checklist/` using ASP.NET Core Minimal APIs and Dapper for data access. + +--- + +## Domain Model + +### `ItemGroup` + +Represents a shared list owned collectively by its members. + +| Property | Type | Description | +|---|---|---| +| `Id` | `Guid` | Unique identifier | +| `Name` | `string` | Display name. Must not be blank | +| `Items` | `IReadOnlyList` | Items belonging to this group | +| `Members` | `IReadOnlyList` | User IDs of all members | + +`Items` and `Members` are populated contextually — see individual endpoint descriptions for what is included in each response. + +### `Item` + +Represents a task within an item group. + +| Property | Type | Description | +|---|---|---| +| `Id` | `Guid` | Unique identifier | +| `Name` | `string` | Display name. Must not be blank | +| `Description` | `string?` | Optional longer description | +| `IsComplete` | `bool` | Completion flag. Defaults to `false` | +| `ItemGroupId` | `Guid` | The group this item belongs to | + +### Members + +Membership is a join between a user (`MemberId: Guid`) and an item group (`ItemGroupId: Guid`). There is no `Member` entity — membership is represented as a list of `Guid` on `ItemGroup` and queried directly from the `Members` table. + +--- + +## Authorization Model + +All endpoints require a valid Bearer token. Authorization is membership-based — a user must be a member of an item group to perform any operation on it or its items. There are no roles or elevated permissions; all members have equal access. + +The authorization check order applied in every handler: + +1. Extract `UserId` from JWT claims (`sub`, `user_id`, or `NameIdentifier`). If absent → `MissingClaim` +2. Check membership via `IsMember(itemGroupId, userId)`. If false → `Forbidden` + +The exception is `CreateItemGroup`, which has no membership pre-condition — any authenticated user can create a group and becomes its first member automatically. + +--- + +## Database Schema + +Owned by `ChecklistSchemaInitializer`. Created idempotently at application startup. + +```sql +CREATE TABLE ItemGroups ( + Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, + Name NVARCHAR(MAX) NOT NULL +); + +CREATE TABLE Items ( + Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, + Name NVARCHAR(MAX) NOT NULL, + Description NVARCHAR(MAX) NULL, + IsComplete BIT NOT NULL DEFAULT 0, + ItemGroupId UNIQUEIDENTIFIER NOT NULL, + FOREIGN KEY (ItemGroupId) REFERENCES ItemGroups(Id) ON DELETE CASCADE +); + +CREATE TABLE Members ( + MemberId UNIQUEIDENTIFIER NOT NULL, + ItemGroupId UNIQUEIDENTIFIER NOT NULL, + PRIMARY KEY (MemberId, ItemGroupId), + FOREIGN KEY (ItemGroupId) REFERENCES ItemGroups(Id) ON DELETE CASCADE +); + +CREATE INDEX IX_Items_ItemGroupId ON Items(ItemGroupId); +CREATE INDEX IX_Members_ItemGroupId ON Members(ItemGroupId); +CREATE INDEX IX_Members_MemberId_ItemGroupId ON Members(MemberId, ItemGroupId); +``` + +Deleting an `ItemGroup` cascades to both `Items` and `Members`. + +--- + +## API Endpoints + +All endpoints are registered under the route group `/api/list` with `.RequireAuthorization()`. + +--- + +### Item Group Endpoints + +#### `GET /api/list` — GetItemGroups + +Returns all item groups where the authenticated user is a member. Each group is populated with its **incomplete items only** (`IsComplete = 0`). `Members` is always an empty list in this response. + +**Responses** + +| Status | Condition | +|---|---| +| `200 OK` | `List` — may be empty | +| `401 Unauthorized` | Missing user ID claim | + +**Query behaviour** + +Executes two queries: +1. SELECT all groups where the user is a member +2. SELECT all incomplete items for those groups (using `IN` clause on group IDs) + +Items are then grouped in memory. If the user has no groups, the second query is skipped. + +--- + +#### `GET /api/list/{itemGroupId}` — GetItemGroup + +Returns a single item group including **all items** (complete and incomplete) and the full list of member IDs. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to retrieve | + +**Responses** + +| Status | Condition | +|---|---| +| `200 OK` | `ItemGroup` with all items and all member IDs | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | +| `404 Not Found` | Item group does not exist | + +**Note:** The 403 check runs before the existence check. A non-member probing for a non-existent group receives 403, not 404. This is intentional — it avoids leaking whether a group exists to non-members. + +--- + +#### `POST /api/list` — CreateItemGroup + +Creates a new item group and automatically adds the authenticated user as its first and only member. The group creation and member insertion are performed in a single database transaction. + +**Request body** + +| Field | Type | Validation | +|---|---|---| +| `Name` | `string` | Required. Must not be blank | + +**Responses** + +| Status | Condition | +|---|---| +| `201 Created` | `ItemGroup` with `Members` containing the creator's ID. `Location` header set to `/list/{id}` | +| `400 Bad Request` | `Name` is blank or whitespace | +| `401 Unauthorized` | Missing user ID claim | + +--- + +#### `PUT /api/list/{itemGroupId}` — UpdateItemGroup + +Renames an existing item group. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to update | + +**Request body** + +| Field | Type | Validation | +|---|---|---| +| `Name` | `string` | Required. Must not be blank | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Update successful | +| `400 Bad Request` | `Name` is blank or whitespace | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +**Note:** If `itemGroupId` does not exist, the UPDATE affects zero rows and still returns `204 No Content`. There is no 404 response for this endpoint. + +--- + +#### `DELETE /api/list/{itemGroupId}` — DeleteItemGroup + +Permanently deletes an item group. All associated items and member records are removed via cascade delete. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to delete | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Deletion successful | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +**Note:** If `itemGroupId` does not exist, the DELETE affects zero rows and still returns `204 No Content`. There is no 404 response for this endpoint. + +--- + +### Item Endpoints + +#### `POST /api/list/{itemGroupId}` — CreateItem + +Creates a new item within the specified item group. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to add the item to | + +**Request body** + +| Field | Type | Validation | Default | +|---|---|---|---| +| `Name` | `string` | Required. Must not be blank | — | +| `Description` | `string?` | Optional | `null` | +| `IsComplete` | `bool` | Optional | `false` | + +**Responses** + +| Status | Condition | +|---|---| +| `201 Created` | `Item`. `Location` header set to `/list/{itemGroupId}/{itemId}` | +| `400 Bad Request` | `Name` is blank or whitespace | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +--- + +#### `PUT /api/list/{itemGroupId}/{itemId}` — UpdateItem + +Updates the name, description, and/or completion status of an existing item. All fields in the request body are replaced — this is a full replacement, not a patch. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group the item belongs to | +| `itemId` | `Guid` | The item to update | + +**Request body** + +| Field | Type | Validation | Default | +|---|---|---|---| +| `Name` | `string` | Required. Must not be blank | — | +| `Description` | `string?` | Optional | `null` | +| `IsComplete` | `bool` | Optional | `false` | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Update successful | +| `400 Bad Request` | `Name` is blank or whitespace | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +**Note:** The UPDATE is scoped to both `Id = @itemId AND ItemGroupId = @itemGroupId`. If either does not exist or they do not match, zero rows are affected and `204 No Content` is still returned. There is no 404 response. + +--- + +#### `DELETE /api/list/{itemGroupId}/{itemId}` — DeleteItem + +Permanently deletes an item from an item group. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group the item belongs to | +| `itemId` | `Guid` | The item to delete | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Deletion successful | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +**Note:** The DELETE is scoped to both `Id = @itemId AND ItemGroupId = @itemGroupId`. Non-existent items return `204 No Content`. There is no 404 response. + +--- + +### Member Endpoints + +#### `GET /api/list/{itemGroupId}/member` — GetMembers + +Returns the list of user IDs that are members of the specified item group. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to query | + +**Responses** + +| Status | Condition | +|---|---| +| `200 OK` | `List` — all member IDs | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | + +--- + +#### `POST /api/list/{itemGroupId}/member/{memberId}` — AddMember + +Grants another user access to an item group by adding them as a member. The authenticated user must already be a member — there is no concept of an owner or admin; any member can invite others. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to add the member to | +| `memberId` | `Guid` | The user ID to add as a member | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Member added successfully | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | +| `409 Conflict` | `memberId` is already a member of the group | + +--- + +#### `DELETE /api/list/{itemGroupId}/member/{memberId}` — RemoveMember + +Revokes a user's access to an item group. Any member can remove any other member. A member can remove themselves. + +**Route parameters** + +| Parameter | Type | Description | +|---|---|---| +| `itemGroupId` | `Guid` | The item group to remove the member from | +| `memberId` | `Guid` | The user ID to remove | + +**Responses** + +| Status | Condition | +|---|---| +| `204 No Content` | Member removed successfully | +| `401 Unauthorized` | Missing user ID claim | +| `403 Forbidden` | Authenticated user is not a member | +| `409 Conflict` | `memberId` is the last remaining member of the group. Removing them would leave the group permanently unreachable | + +**Orphan prevention rule:** A group must always have at least one member. Attempting to remove the last member returns `409 Conflict`. The check is performed in a single query that simultaneously verifies the group has exactly one member and that member is the target `memberId`. + +--- + +## Shared Patterns + +### Validation + +Name fields (`ItemGroup.Name`, `Item.Name`) are validated with `string.IsNullOrWhiteSpace`. This check runs before the authentication and membership checks so that `400 Bad Request` is always returned for invalid input regardless of auth status. + +### Idempotent Deletes and Updates + +`DELETE` and `PUT` endpoints do not return `404 Not Found` if the target resource does not exist. Zero-row operations return `204 No Content`. This is intentional — it keeps the interface simple and avoids race condition handling on the client side. + +### Membership Check Helper + +`ChecklistConnectionExtensions` provides two shared query helpers used across all handlers: + +- `IsMember(itemGroupId, userId)` — returns `false` immediately if `userId` is null, otherwise queries the `Members` table +- `IsLastMember(itemGroupId, memberId)` — single query that checks both total member count and whether the target is the sole member + +--- + +## Structural Conventions + +Each operation is a standalone `static` class following the same structure: + +- `MapEndpoint(IEndpointRouteBuilder)` — registers the route with summary, description, tag, and name +- `Execute(...)` — the handler method bound by Minimal API. Contains validation, auth checks, and delegates to data methods +- `CreateData` / `UpdateData` / `RemoveData` / `LoadData` — `internal static` data access methods, made internal for direct testing without the HTTP stack +- `Request` record — nested inside the handler class where a request body is required + +All endpoints are registered in `ChecklistApiEndpointRouteBuilderExtension.MapChecklistApi()` on a single route group, which applies `.RequireAuthorization()` uniformly.