Polish Store endpoints: 409 conflicts and confirm-before-delete

Backend pre-checks duplicate names on POST/PUT and returns 409
Conflict (replacing the previous 500 from the unique constraint).
DELETE-with-active-lists also returns 409 instead of 400 for
semantic accuracy.

Frontend addStore and saveEdit now surface API errors via toast
instead of failing silently. Delete is now gated by a confirmation
modal so accidental clicks no longer destroy data.
This commit is contained in:
Josh Rogers
2026-05-08 21:07:57 -05:00
parent 7fcae09afb
commit d4db819e72
3 changed files with 114 additions and 19 deletions
@@ -77,10 +77,49 @@ public class StoreEndpointsTests : AuthenticatedIntegrationTest
var response = await Client.DeleteAsync($"/api/stores/{store.Id}"); var response = await Client.DeleteAsync($"/api/stores/{store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
await Assert.That(await UseDbAsync(db => db.Stores.AnyAsync(s => s.Id == store.Id))).IsTrue(); await Assert.That(await UseDbAsync(db => db.Stores.AnyAsync(s => s.Id == store.Id))).IsTrue();
} }
[Test]
public async Task Create_returns_409_for_duplicate_name()
{
await Data.CreateStoreAsync(b => b.Named("Kroger"));
var response = await Client.PostAsJsonAsync("/api/stores",
new StoreEndpoints.CreateStoreRequest("Kroger"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
await Assert.That(await UseDbAsync(db => db.Stores.CountAsync())).IsEqualTo(1);
}
[Test]
public async Task Update_returns_409_when_renaming_to_existing_name()
{
await Data.CreateStoreAsync(b => b.Named("Kroger"));
var publix = await Data.CreateStoreAsync(b => b.Named("Publix"));
var response = await Client.PutAsJsonAsync($"/api/stores/{publix.Id}",
new StoreEndpoints.UpdateStoreRequest("Kroger", 0));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
var refreshed = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Id == publix.Id));
await Assert.That(refreshed.Name).IsEqualTo("Publix");
}
[Test]
public async Task Update_allows_keeping_same_name()
{
var store = await Data.CreateStoreAsync(b => b.Named("Kroger"));
var response = await Client.PutAsJsonAsync($"/api/stores/{store.Id}",
new StoreEndpoints.UpdateStoreRequest("Kroger", 7));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Id == store.Id));
await Assert.That(refreshed.SortOrder).IsEqualTo(7);
}
[Test] [Test]
public async Task Endpoints_require_authentication() public async Task Endpoints_require_authentication()
{ {
@@ -23,9 +23,13 @@ public static class StoreEndpoints
group.MapPost("/", async (CreateStoreRequest request, YesChefDb db, HttpContext http) => group.MapPost("/", async (CreateStoreRequest request, YesChefDb db, HttpContext http) =>
{ {
var familyId = http.User.GetFamilyId();
if (await db.Stores.AnyAsync(s => s.FamilyId == familyId && s.Name == request.Name))
return Results.Conflict(new { error = $"A store named \"{request.Name}\" already exists." });
var store = new Store var store = new Store
{ {
FamilyId = http.User.GetFamilyId(), FamilyId = familyId,
Name = request.Name, Name = request.Name,
SortOrder = request.SortOrder, SortOrder = request.SortOrder,
}; };
@@ -40,6 +44,10 @@ public static class StoreEndpoints
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId); var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId);
if (store is null) return Results.NotFound(); if (store is null) return Results.NotFound();
if (request.Name != store.Name &&
await db.Stores.AnyAsync(s => s.FamilyId == familyId && s.Name == request.Name && s.Id != id))
return Results.Conflict(new { error = $"A store named \"{request.Name}\" already exists." });
store.Name = request.Name; store.Name = request.Name;
store.SortOrder = request.SortOrder; store.SortOrder = request.SortOrder;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -53,7 +61,7 @@ public static class StoreEndpoints
if (store is null) return Results.NotFound(); if (store is null) return Results.NotFound();
var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id && l.FamilyId == familyId); var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id && l.FamilyId == familyId);
if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." }); if (hasLists) return Results.Conflict(new { error = "Store has shopping lists. Remove them first." });
db.Stores.Remove(store); db.Stores.Remove(store);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
+64 -16
View File
@@ -14,6 +14,7 @@
let editingId = $state<number | null>(null); let editingId = $state<number | null>(null);
let editName = $state(''); let editName = $state('');
let loading = $state(true); let loading = $state(true);
let pendingDelete = $state<Store | null>(null);
onMount(async () => { onMount(async () => {
stores = await api<Store[]>('/api/stores'); stores = await api<Store[]>('/api/stores');
@@ -22,12 +23,16 @@
async function addStore() { async function addStore() {
if (!newName.trim()) return; if (!newName.trim()) return;
await api('/api/stores', { try {
method: 'POST', await api('/api/stores', {
body: JSON.stringify({ name: newName, sortOrder: stores.length }) method: 'POST',
}); body: JSON.stringify({ name: newName, sortOrder: stores.length })
newName = ''; });
stores = await api<Store[]>('/api/stores'); newName = '';
stores = await api<Store[]>('/api/stores');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to add store');
}
} }
function startEdit(store: Store) { function startEdit(store: Store) {
@@ -38,20 +43,31 @@
async function saveEdit() { async function saveEdit() {
if (!editName.trim() || !editingId) return; if (!editName.trim() || !editingId) return;
const store = stores.find((s) => s.id === editingId)!; const store = stores.find((s) => s.id === editingId)!;
await api(`/api/stores/${editingId}`, { try {
method: 'PUT', await api(`/api/stores/${editingId}`, {
body: JSON.stringify({ name: editName, sortOrder: store.sortOrder }) method: 'PUT',
}); body: JSON.stringify({ name: editName, sortOrder: store.sortOrder })
editingId = null; });
stores = await api<Store[]>('/api/stores'); editingId = null;
stores = await api<Store[]>('/api/stores');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update store');
}
} }
async function deleteStore(id: number) { function requestDelete(store: Store) {
pendingDelete = store;
}
async function confirmDelete() {
if (!pendingDelete) return;
const id = pendingDelete.id;
pendingDelete = null;
try { try {
await api(`/api/stores/${id}`, { method: 'DELETE' }); await api(`/api/stores/${id}`, { method: 'DELETE' });
stores = stores.filter((s) => s.id !== id); stores = stores.filter((s) => s.id !== id);
} catch (e: any) { } catch (e) {
toast.error(e.message); toast.error(e instanceof Error ? e.message : 'Failed to delete store');
} }
} }
</script> </script>
@@ -93,10 +109,42 @@
{:else} {:else}
<span class="flex-1 font-medium">{store.name}</span> <span class="flex-1 font-medium">{store.name}</span>
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button> <button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
<button onclick={() => deleteStore(store.id)} class="text-sm text-danger">Delete</button> <button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
{/if} {/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div> </div>
{#if pendingDelete}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-store-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="delete-store-title" class="mb-2 text-lg font-semibold">Delete store?</h3>
<p class="mb-5 text-sm text-gray-600">
Delete <span class="font-medium">{pendingDelete.name}</span>? This can't be undone.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingDelete = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={confirmDelete}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Delete
</button>
</div>
</div>
</div>
{/if}