add reviews schema and context (phase 2)
All checks were successful
deploy / deploy (push) Successful in 1m4s
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
233
lib/berrypod/reviews.ex
Normal 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
|
||||
91
lib/berrypod/reviews/review.ex
Normal file
91
lib/berrypod/reviews/review.ex
Normal 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
|
||||
Reference in New Issue
Block a user