add reviews schema and context (phase 2)
All checks were successful
deploy / deploy (push) Successful in 1m4s

- Create reviews table with product/order associations
- Add Review schema with create/update/moderation changesets
- Add Reviews context with CRUD, purchase verification, aggregates
- Add get_images/1 to Media, get_variant/1 to Products
- 23 tests covering all context functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-04-01 10:34:07 +01:00
parent 34822254e3
commit 8dc17a6f4d
7 changed files with 766 additions and 4 deletions

View File

@@ -134,6 +134,25 @@ defmodule Berrypod.Media do
Repo.get(ImageSchema, id)
end
@doc """
Gets multiple images by their IDs.
Returns images in the order they appear in the IDs list.
"""
def get_images(ids) when is_list(ids) and ids != [] do
images =
Repo.all(
from i in ImageSchema,
where: i.id in ^ids
)
# Return in the order of the input IDs
Map.new(images, &{&1.id, &1})
|> then(fn map -> Enum.map(ids, &Map.get(map, &1)) end)
|> Enum.reject(&is_nil/1)
end
def get_images(_), do: []
@doc """
Gets the current logo image.

View File

@@ -1004,6 +1004,13 @@ defmodule Berrypod.Products do
|> Map.new(&{&1.id, &1})
end
@doc """
Gets a variant by ID.
"""
def get_variant(id) do
Repo.get(ProductVariant, id)
end
@doc """
Creates a product variant.
"""

233
lib/berrypod/reviews.ex Normal file
View File

@@ -0,0 +1,233 @@
defmodule Berrypod.Reviews do
@moduledoc """
Product review management.
Reviews require verified purchase (via email session) and are moderated
before public display. Each email can only review a product once.
"""
import Ecto.Query
alias Berrypod.Media
alias Berrypod.Orders
alias Berrypod.Repo
alias Berrypod.Reviews.Review
# ── Queries ────────────────────────────────────────────────────────
@doc """
Gets a review by ID.
"""
def get_review(id) do
Repo.get(Review, id)
end
@doc """
Gets a review by ID, raising if not found.
"""
def get_review!(id) do
Repo.get!(Review, id)
end
@doc """
Gets the review for a specific email and product combination.
Returns nil if no review exists.
"""
def get_review_by_email_and_product(email, product_id) do
email = normalise_email(email)
Repo.one(
from r in Review,
where: r.email == ^email and r.product_id == ^product_id
)
end
@doc """
Lists approved reviews for a product, newest first.
"""
def list_reviews_for_product(product_id, opts \\ []) do
status = Keyword.get(opts, :status, "approved")
limit = Keyword.get(opts, :limit)
query =
from r in Review,
where: r.product_id == ^product_id and r.status == ^status,
order_by: [desc: r.inserted_at]
query = if limit, do: limit(query, ^limit), else: query
Repo.all(query)
end
@doc """
Lists pending reviews for admin moderation queue.
"""
def list_pending_reviews do
Repo.all(
from r in Review,
where: r.status == "pending",
order_by: [asc: r.inserted_at],
preload: [:product]
)
end
@doc """
Lists all reviews with optional status filter, for admin.
"""
def list_reviews(opts \\ []) do
status = Keyword.get(opts, :status)
query =
from r in Review,
order_by: [desc: r.inserted_at],
preload: [:product]
query = if status, do: where(query, [r], r.status == ^status), else: query
Repo.all(query)
end
@doc """
Counts pending reviews for admin nav badge.
"""
def count_pending_reviews do
Repo.one(from r in Review, where: r.status == "pending", select: count(r.id))
end
# ── Aggregates ─────────────────────────────────────────────────────
@doc """
Returns the average rating and count for a product's approved reviews.
Returns `{nil, 0}` if no approved reviews exist.
"""
def average_rating_for_product(product_id) do
result =
Repo.one(
from r in Review,
where: r.product_id == ^product_id and r.status == "approved",
select: {avg(r.rating), count(r.id)}
)
case result do
{nil, 0} -> {nil, 0}
{avg, count} when is_float(avg) -> {Decimal.from_float(avg) |> Decimal.round(1), count}
{avg, count} -> {Decimal.round(avg, 1), count}
end
end
# ── Purchase verification ──────────────────────────────────────────
@doc """
Checks if an email has purchased a specific product (via any paid order).
Returns true if they have a paid order containing the product.
"""
def can_review?(email, product_id) do
email = normalise_email(email)
orders = Orders.list_orders_by_email(email)
Enum.any?(orders, fn order ->
Enum.any?(order.items, fn item ->
item_matches_product?(item, product_id)
end)
end)
end
@doc """
Finds the first order where this email purchased this product.
Used to link the review to an order for "verified purchase" badge.
"""
def find_matching_order(email, product_id) do
email = normalise_email(email)
orders = Orders.list_orders_by_email(email)
Enum.find(orders, fn order ->
Enum.any?(order.items, fn item ->
item_matches_product?(item, product_id)
end)
end)
end
# Check if an order item matches a product by looking up the variant
defp item_matches_product?(item, product_id) do
case Berrypod.Products.get_variant(item.variant_id) do
%{product_id: ^product_id} -> true
_ -> false
end
end
# ── CRUD ───────────────────────────────────────────────────────────
@doc """
Creates a new review.
The review starts in "pending" status. If the email has a matching order
for this product, the order_id is automatically linked.
"""
def create_review(attrs) do
email = attrs[:email] || attrs["email"]
product_id = attrs[:product_id] || attrs["product_id"]
# Auto-link to matching order for "verified purchase" badge
order = if email && product_id, do: find_matching_order(email, product_id), else: nil
attrs = if order, do: Map.put(attrs, :order_id, order.id), else: attrs
%Review{}
|> Review.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates an existing review.
Resets status to pending for re-moderation.
"""
def update_review(%Review{} = review, attrs) do
review
|> Review.update_changeset(attrs)
|> Repo.update()
end
@doc """
Approves a review for public display.
"""
def approve_review(%Review{} = review) do
review
|> Review.moderation_changeset("approved")
|> Repo.update()
end
@doc """
Rejects a review (won't be displayed).
"""
def reject_review(%Review{} = review) do
review
|> Review.moderation_changeset("rejected")
|> Repo.update()
end
@doc """
Deletes a review.
"""
def delete_review(%Review{} = review) do
Repo.delete(review)
end
# ── Images ─────────────────────────────────────────────────────────
@doc """
Gets the image records for a review's image_ids.
Returns a list of Image structs.
"""
def get_review_images(%Review{image_ids: ids}) when is_list(ids) and ids != [] do
Media.get_images(ids)
end
def get_review_images(_), do: []
# ── Helpers ────────────────────────────────────────────────────────
defp normalise_email(email) when is_binary(email) do
email |> String.trim() |> String.downcase()
end
defp normalise_email(_), do: ""
end

View File

@@ -0,0 +1,91 @@
defmodule Berrypod.Reviews.Review do
@moduledoc """
Schema for product reviews.
Reviews are linked to a product and optionally to an order (for "verified
purchase" badge). Each email can only review a product once. Reviews start
in pending status and must be approved by an admin before displaying.
"""
use Ecto.Schema
import Ecto.Changeset
alias Berrypod.Orders.Order
alias Berrypod.Products.Product
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "reviews" do
field :email, :string
field :author_name, :string
field :rating, :integer
field :title, :string
field :body, :string
field :status, :string, default: "pending"
field :image_ids, {:array, :binary_id}, default: []
belongs_to :product, Product
belongs_to :order, Order
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating a new review.
"""
def changeset(review, attrs) do
review
|> cast(attrs, [
:product_id,
:order_id,
:email,
:author_name,
:rating,
:title,
:body,
:image_ids
])
|> validate_required([:product_id, :email, :author_name, :rating])
|> validate_inclusion(:rating, 1..5)
|> validate_length(:title, max: 100)
|> validate_length(:body, max: 2000)
|> validate_length(:author_name, max: 50)
|> validate_length(:image_ids, max: 3)
|> foreign_key_constraint(:product_id)
|> foreign_key_constraint(:order_id)
|> unique_constraint([:email, :product_id], message: "you have already reviewed this product")
|> normalise_email()
end
@doc """
Changeset for updating an existing review.
Resets status to pending for re-moderation.
"""
def update_changeset(review, attrs) do
review
|> cast(attrs, [:author_name, :rating, :title, :body, :image_ids])
|> validate_required([:author_name, :rating])
|> validate_inclusion(:rating, 1..5)
|> validate_length(:title, max: 100)
|> validate_length(:body, max: 2000)
|> validate_length(:author_name, max: 50)
|> validate_length(:image_ids, max: 3)
|> put_change(:status, "pending")
end
@doc """
Changeset for admin moderation (approve/reject).
"""
def moderation_changeset(review, status) when status in ["approved", "rejected"] do
review
|> change(status: status)
end
defp normalise_email(changeset) do
case get_change(changeset, :email) do
nil -> changeset
email -> put_change(changeset, :email, String.downcase(String.trim(email)))
end
end
end