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

@ -1,6 +1,6 @@
# Product reviews system
Status: In Progress (Phase 1 complete)
Status: In Progress (Phase 1-2 complete)
## Overview
@ -416,13 +416,13 @@ Only include if product has approved reviews.
- Orders page -> read from email session
- Checkout success -> set email session (via /checkout/complete controller)
### Phase 2: Reviews schema and context (~2.5h)
### Phase 2: Reviews schema and context (~2.5h)
3. **Reviews migration and schema** (1h)
3. **Reviews migration and schema** (1h)
- Create migration with image_ids array
- Create `Review` schema with changeset
4. **Reviews context** (1.5h)
4. **Reviews context** (1.5h)
- CRUD functions
- Query helpers (by product, by email, pending)
- Purchase verification

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

View File

@ -0,0 +1,27 @@
defmodule Berrypod.Repo.Migrations.CreateReviews do
use Ecto.Migration
def change do
create table(:reviews, primary_key: false) do
add :id, :binary_id, primary_key: true
add :product_id, references(:products, type: :binary_id, on_delete: :delete_all),
null: false
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
add :email, :string, null: false
add :author_name, :string, null: false
add :rating, :integer, null: false
add :title, :string
add :body, :text
add :status, :string, null: false, default: "pending"
add :image_ids, {:array, :binary_id}, default: []
timestamps(type: :utc_datetime)
end
create unique_index(:reviews, [:email, :product_id])
create index(:reviews, [:product_id, :status])
create index(:reviews, [:status])
end
end

View File

@ -0,0 +1,385 @@
defmodule Berrypod.ReviewsTest do
use Berrypod.DataCase, async: true
alias Berrypod.Reviews
alias Berrypod.Reviews.Review
import Berrypod.ProductsFixtures
import Berrypod.OrdersFixtures
# ── Create ─────────────────────────────────────────────────────────
describe "create_review/1" do
test "creates a review with valid attrs" do
product = product_fixture()
attrs = %{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
}
assert {:ok, %Review{} = review} = Reviews.create_review(attrs)
assert review.product_id == product.id
assert review.email == "buyer@example.com"
assert review.author_name == "Jane"
assert review.rating == 5
assert review.status == "pending"
end
test "normalises email to lowercase" do
product = product_fixture()
attrs = %{
product_id: product.id,
email: "BUYER@EXAMPLE.COM",
author_name: "Jane",
rating: 5
}
{:ok, review} = Reviews.create_review(attrs)
assert review.email == "buyer@example.com"
end
test "auto-links to matching order for verified purchase badge" do
conn = provider_connection_fixture(%{provider_type: "printify"})
product = product_fixture(%{provider_connection: conn})
variant = product_variant_fixture(%{product: product})
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
variant_id: variant.id
})
attrs = %{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
}
{:ok, review} = Reviews.create_review(attrs)
assert review.order_id == order.id
end
test "allows optional title and body" do
product = product_fixture()
attrs = %{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 4,
title: "Great product!",
body: "Would buy again."
}
{:ok, review} = Reviews.create_review(attrs)
assert review.title == "Great product!"
assert review.body == "Would buy again."
end
test "validates rating is 1-5" do
product = product_fixture()
attrs = %{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 6
}
assert {:error, changeset} = Reviews.create_review(attrs)
assert "is invalid" in errors_on(changeset).rating
end
test "enforces one review per email+product" do
product = product_fixture()
attrs = %{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
}
{:ok, _} = Reviews.create_review(attrs)
{:error, changeset} = Reviews.create_review(attrs)
assert "you have already reviewed this product" in errors_on(changeset).email
end
end
# ── Read ───────────────────────────────────────────────────────────
describe "get_review/1" do
test "returns the review" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
assert Reviews.get_review(review.id) == review
end
test "returns nil for unknown id" do
assert Reviews.get_review(Ecto.UUID.generate()) == nil
end
end
describe "get_review_by_email_and_product/2" do
test "returns the review for the email+product" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
assert Reviews.get_review_by_email_and_product("buyer@example.com", product.id) == review
end
test "returns nil when no review exists" do
product = product_fixture()
assert Reviews.get_review_by_email_and_product("nobody@example.com", product.id) == nil
end
end
describe "list_reviews_for_product/2" do
test "lists only approved reviews by default" do
product = product_fixture()
{:ok, pending} =
Reviews.create_review(%{
product_id: product.id,
email: "pending@example.com",
author_name: "Pending",
rating: 3
})
{:ok, approved} =
Reviews.create_review(%{
product_id: product.id,
email: "approved@example.com",
author_name: "Approved",
rating: 5
})
Reviews.approve_review(approved)
reviews = Reviews.list_reviews_for_product(product.id)
assert length(reviews) == 1
assert hd(reviews).email == "approved@example.com"
refute Enum.any?(reviews, &(&1.id == pending.id))
end
test "can filter by status" do
product = product_fixture()
{:ok, _} =
Reviews.create_review(%{
product_id: product.id,
email: "pending@example.com",
author_name: "Pending",
rating: 3
})
reviews = Reviews.list_reviews_for_product(product.id, status: "pending")
assert length(reviews) == 1
end
end
describe "list_pending_reviews/0" do
test "lists pending reviews with preloaded product" do
product = product_fixture()
{:ok, _} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
reviews = Reviews.list_pending_reviews()
assert length(reviews) == 1
assert hd(reviews).product.id == product.id
end
end
# ── Update ─────────────────────────────────────────────────────────
describe "update_review/2" do
test "updates the review and resets status to pending" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
Reviews.approve_review(review)
{:ok, updated} = Reviews.update_review(review, %{rating: 4, body: "Updated!"})
assert updated.rating == 4
assert updated.body == "Updated!"
assert updated.status == "pending"
end
end
# ── Moderation ─────────────────────────────────────────────────────
describe "approve_review/1" do
test "sets status to approved" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
{:ok, approved} = Reviews.approve_review(review)
assert approved.status == "approved"
end
end
describe "reject_review/1" do
test "sets status to rejected" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
{:ok, rejected} = Reviews.reject_review(review)
assert rejected.status == "rejected"
end
end
# ── Aggregates ─────────────────────────────────────────────────────
describe "average_rating_for_product/1" do
test "returns average and count for approved reviews" do
product = product_fixture()
{:ok, r1} =
Reviews.create_review(%{
product_id: product.id,
email: "a@example.com",
author_name: "A",
rating: 5
})
{:ok, r2} =
Reviews.create_review(%{
product_id: product.id,
email: "b@example.com",
author_name: "B",
rating: 4
})
Reviews.approve_review(r1)
Reviews.approve_review(r2)
{avg, count} = Reviews.average_rating_for_product(product.id)
assert Decimal.equal?(avg, Decimal.new("4.5"))
assert count == 2
end
test "returns {nil, 0} when no approved reviews" do
product = product_fixture()
assert {nil, 0} = Reviews.average_rating_for_product(product.id)
end
test "excludes pending and rejected reviews" do
product = product_fixture()
{:ok, _} =
Reviews.create_review(%{
product_id: product.id,
email: "pending@example.com",
author_name: "Pending",
rating: 1
})
assert {nil, 0} = Reviews.average_rating_for_product(product.id)
end
end
# ── Purchase verification ──────────────────────────────────────────
describe "can_review?/2" do
test "returns true when email has purchased the product" do
conn = provider_connection_fixture(%{provider_type: "printify"})
product = product_fixture(%{provider_connection: conn})
variant = product_variant_fixture(%{product: product})
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
variant_id: variant.id
})
assert Reviews.can_review?("buyer@example.com", product.id)
end
test "returns false when email has not purchased the product" do
product = product_fixture()
refute Reviews.can_review?("nobody@example.com", product.id)
end
test "returns false when order exists but for different product" do
conn = provider_connection_fixture(%{provider_type: "printify"})
product1 = product_fixture(%{provider_connection: conn})
product2 = product_fixture(%{provider_connection: conn})
variant1 = product_variant_fixture(%{product: product1})
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
variant_id: variant1.id
})
refute Reviews.can_review?("buyer@example.com", product2.id)
end
end
# ── Delete ─────────────────────────────────────────────────────────
describe "delete_review/1" do
test "deletes the review" do
product = product_fixture()
{:ok, review} =
Reviews.create_review(%{
product_id: product.id,
email: "buyer@example.com",
author_name: "Jane",
rating: 5
})
{:ok, _} = Reviews.delete_review(review)
assert Reviews.get_review(review.id) == nil
end
end
end