Welcome back! 👋 In the last lesson you made carts useful by adding line items with a real-world add-or-increment behavior. The route validated inputs, the service enforced business rules like inventory checks, and the repository performed safe writes in a transaction.
In this final lesson, we make the cart feel editable like a real shopping experience. You’ll learn how our API targets a specific cart item row so clients can update quantity or remove an item—without resending product details.
To update or delete a line item, we address it directly by its cart item ID:
/api/carts/:id/items/:itemId
That means we always deal with two identifiers:
id: the cart UUID (from thecartstable)itemId: the cart item UUID (from thecart_itemstable)
This is important because changing quantity or deleting an item is an operation on the line item row, not on the product itself.
The file src/app/api/carts/[id]/items/[itemId]/route.ts contains both endpoints:
PATCHfor updating quantityDELETEfor removing the item
The route follows the same “fail fast” pattern you’ve seen already: validate path params, validate body (for PATCH), call the service, then translate the ServiceResult into an HTTP response.
Route setup and parameter handling:
- This route uses the same response helpers as the rest of the API (
success,error) so responses stay consistent across endpoints. RouteContextagain definesparamsas a Promise, so we alwaysawait context.paramsto readidanditemId.parseJsonis used for PATCH so malformed JSON becomes a clean , rather than a thrown exception that would force a generic .
Updating a line item is a “quantity edit.” The client sends { "quantity": number }, and we validate everything before touching the database.
Validating both IDs and parsing the request body:
- Both
idanditemIdare validated up front so invalid URLs are treated as client errors (400) instead of wasting database work. parseJson(req)protects the route from invalid JSON. If the body can’t be parsed, we return a standardized error envelope with an HTTP 400.validateUpdateItemensures the service only receives a clean DTO{ quantity: number }. That way, the service can focus on business rules instead of re-checking types.
- The route delegates the update to
updateItemService, passing both identifiers plus the validated payload. - If the service returns a failure, the route doesn’t reinterpret it. It uses
updated.error.httpStatusand returns exactly the error code/message/details the service produced. - On success, the route returns the updated
CartItem. This makes the UI experience nicer because the client can immediately reflect the new quantity from the response.
Deleting doesn’t need a request body. We still validate both IDs, then ask the service to delete that specific item.
Validating IDs and calling delete service:
- Even though the delete operation ultimately targets a
cart_itemsrow, validating bothidanditemIdkeeps the endpoint consistent and prevents confusing “half-valid” requests. deleteItemServicereturns aServiceResult<{ deleted: true }>so the route can respond with a clear confirmation payload.- Like the PATCH handler, the DELETE handler treats the service as the source of truth for HTTP mapping:
NOT_FOUNDbecomes404, cart state conflicts become409, etc., without the route needing special cases.
Update validation is intentionally strict: in this API, “changing quantity” always means setting it to a positive integer. If someone wants to remove an item, they use DELETE instead.
Validating the update payload:
This lives in src/lib/services/cartsService.ts and is called by the PATCH route before the service runs.
quantitymust be an integer and must be at least 1. This avoids “0 quantity” rows, negative quantities, and fractional quantities that would make totals unpredictable.- Returning a cleaned
{ quantity }object meansupdateItemServicereceives a properly shapedUpdateCartItemInputevery time, which keeps business logic simpler and safer. - This validation also creates a clean separation of intent: PATCH means edit quantity, and DELETE means remove the item.
updateItemService coordinates the update process and enforces important rules:
- the cart must exist and be
open - the item must exist
- the associated product must still be available (not archived)
- the new quantity must not exceed inventory
Updating quantity safely:
- The service starts by loading the cart and enforcing lifecycle rules through
ensureCartOpen. This prevents edits on carts that are already checked out or abandoned. - It confirms the item exists inside that cart using
getCartItemByCartAndId(cartId, itemId). That keeps the API honest: you can’t update an item that belongs to a different cart. - The product lookup is used for inventory validation and availability rules. If the product is missing or archived, updating the cart item is treated as a conflict (
409) because the item can’t be meaningfully maintained. - The final call to performs the actual write. The extra check handles the edge case where the row disappears between read and update (rare, but possible in concurrent systems).
Deleting an item is similar: we still enforce “cart must be open,” and we return clean 404s if the item doesn’t exist.
- We again enforce that only an
opencart can be edited. This keeps cart state transitions predictable and avoids “mutating history” after checkout. - We check existence before deleting so we can return a clear, user-friendly
404and message when the item isn’t present. - The delete function returns a boolean, so the service can distinguish “deleted successfully” from “nothing was deleted” and map that to a
404.
The repository is where SQL happens. These functions are intentionally small: they do one query, then return a domain-friendly result.
This is implemented in src/lib/repositories/cartsRepo.ts and updates by both cart_id and id.
- The
WHERE cart_id = $2 AND id = $3condition is important: it ensures we only update the item if it belongs to the given cart. That matches how the route is structured and prevents cross-cart updates. updated_at = now()makes edits observable. In real systems this is helpful for debugging, analytics, and “last modified” UI behavior.RETURNING *avoids a second query. If the update succeeds, we immediately get the updated row back to send to the client.- Returning
nullis a clean signal: “no matching row existed,” which the service then turns into a404with a consistent error envelope.
This delete function returns a boolean so the service can easily map “not found” vs “deleted.”
- Like the update, this delete is scoped to both
cart_idandid. That prevents deleting an item that belongs to another cart. DELETE ... RETURNING *is a practical pattern: if a row was deleted, Postgres returns it; if not, the result is empty. That makes it easy to compute a boolean success value.- Returning
Boolean(rows[0])gives higher layers a simple “did we delete something?” signal without exposing database details.
In this lesson you completed the cart editing experience:
-
src/app/api/carts/[id]/items/[itemId]/route.tsexposes:PATCHto update quantity (validates IDs + JSON + payload)DELETEto remove the item (validates IDs, no body)
-
validateUpdateItemenforces a strict update contract: quantity must be an integer ≥ 1. -
updateItemServiceanddeleteItemServiceenforce business rules consistently:- cart must exist and be
open - item must exist within that cart
- updates must respect product availability and inventory
- cart must exist and be
-
updateCartItemQuantityanddeleteCartItemperform the precise SQL operations scoped by both cart ID and item ID.
With create, add, update, and delete in place, your cart API now supports the full “editable cart” workflow that real checkout systems depend on.
