Marking Items Used

Food Tracker (React + Express + Prisma)

Chapter 8 · Marking Items Used

Every earlier chapter built toward this exact moment: an item is finally used up, and the row created back in Chapter 5 needs to reflect that — without ever disappearing from the history Chapter 7 searches. The sibling course does this with one atomic UPDATE ... WHERE id = ? AND status = 'active'. Replicating that single-query guard through Prisma turns out to need a specific, real feature — verified directly against Prisma's own documentation and issue history rather than assumed.

The Route

// routes/items.js (appended) router.patch("/:id/use", async (req, res) => { const { id } = req.params; try { const item = await prisma.item.update({ where: { id: Number(id), status: "active" }, data: { status: "used", usedAt: new Date(), expiryDate: null } }); res.json(item); } catch (error) { if (error.code === "P2025") { return res.status(404).json({ error: "Item not found, or already used" }); } throw error; } });

where: { id: Number(id), status: "active" } is doing the same job as the sibling's own WHERE id = ? AND status = 'active' — a single, atomic query that only matches a row currently active and with the requested id, run as one statement rather than a check followed by a separate write.

Verified against Prisma's own docs and issue history, not assumed
Prisma's update() normally only accepts unique fields (like id) in its where clause — a plain field like status can't be added to it on its own. Combining a non-unique field alongside the actual unique identifier, exactly as done here, is a real, specific Prisma capability: introduced as a preview feature (extendedWhereUnique) in Prisma 4.5.0, and made stable — no preview flag needed — as of Prisma 5.0.0. Without it, this guard would need updateMany() instead, which can filter on any field but doesn't return the updated record directly.
Sibling courseThis course
The guard itselfWHERE id = ? AND status = 'active' in one UPDATEwhere: { id, status: "active" } in one update() — same atomic single-query shape
Signal when the guard failsresult.changes === 0 — a value the route checks with an ifA thrown PrismaClientKnownRequestError with code P2025 — an error the route catches with try/catch
The id itselfid passed straight through as a string — SQLite's own flexible typing handles the comparisonNumber(id) — Prisma's typed Int field expects a real number, not a numeric string

That last row matters in practice, not just in theory: forgetting Number(id) here is a genuinely easy mistake, since req.params.id is always a string regardless of what the underlying field's own type is.

This must be a PATCH, never a plain link
A state-changing action like this must never be reachable via a plain GET — a browser's own link-prefetching, a crawler following every link on a page, or simply a user middle-clicking to open in a new tab could all trigger a GET request without the user ever intending to mark anything used. PATCH requires the request to come from an explicit action — a button's onClick firing a real fetch call — never something a browser might do on its own while merely loading or navigating a page.

The React Action

function ExpiryDashboard() { const { alerts, loading, refresh } = useExpiryAlerts(); const markUsed = async (id) => { await fetch(`/api/items/${id}/use`, { method: "PATCH" }); refresh(); }; if (loading) return <p>Loading...</p>; if (alerts.length === 0) return <p>Nothing expiring soon.</p>; return ( <ul> {alerts.map((item) => ( <li key={item.id}> {item.name} — expires {item.expiryDate.slice(0, 10)} <button onClick={() => markUsed(item.id)}>Mark Used</button> </li> ))} </ul> ); }
Chapter 6's refresh() finally earns its keep
Chapter 6 exposed refresh from useExpiryAlerts specifically for this moment, rather than only fetching once internally. Calling refresh() right after the PATCH succeeds re-runs the alerts query, and since the item's status is now "used", it no longer matches that query's own status: "active" filter — it disappears from the dashboard immediately, with no manual list-filtering logic on the frontend at all. The backend query is the single source of truth for "what's currently expiring soon"; the frontend just asks it again.
Wait-then-refresh, not optimistic updates
A more polished app might remove the item from the UI immediately, before the server even responds ("optimistic" updating), then roll back if the request fails. This course deliberately keeps the simpler approach — wait for the PATCH to actually succeed, then re-fetch — accepting a small, honest delay in exchange for never showing the user a state the server hasn't actually confirmed.

Where This Course Is Headed

Recipe lookup with TheMealDB next — an Express route and a React results component, matching items nearing expiry against real recipes.

Hands-On Exercises

Exercise 1

Explain what specific Prisma feature makes where: { id, status: "active" } possible on update(), and which Prisma version made it stable without a preview flag.

📄 View solution
Exercise 2

Using this chapter's own compare-table, explain why this route needs a try/catch rather than an if (result.changes === 0) check, and what P2025 actually means.

📄 View solution
Exercise 3

Explain why marking an item used must never be implemented as a plain GET request, with a concrete example of how a GET-based version could be triggered unintentionally.

📄 View solution

Chapter 8 Quick Reference

  • Route: PATCH /api/items/:id/use — update() with where: { id, status: "active" }, clearing expiryDate to null
  • The real feature: non-unique fields alongside a unique id in update()'s where clause — stable since Prisma 5.0.0
  • Different failure signal: a thrown P2025 error here, a returned changes: 0 in the sibling — same atomic guard, different way of reporting "no match"
  • Easy-to-forget detail: Number(id) — req.params.id is always a string; the sibling's SQLite comparison tolerates that, Prisma's typed Int field doesn't
  • Never a GET: a state-changing action must require an explicit request, not something a browser could trigger while merely loading a page
  • The payoff: refresh() from Chapter 6 re-runs the alerts query, which naturally excludes the now-used item
  • Next chapter: Recipe Lookup with TheMealDB