From 8dc17a6f4d0bdfd1db47518f157653c9d0519368 Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 1 Apr 2026 10:34:07 +0100 Subject: [PATCH] add reviews schema and context (phase 2) - 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 --- docs/plans/reviews-system.md | 8 +- lib/berrypod/media.ex | 19 + lib/berrypod/products.ex | 7 + lib/berrypod/reviews.ex | 233 +++++++++++ lib/berrypod/reviews/review.ex | 91 +++++ .../20260401095806_create_reviews.exs | 27 ++ test/berrypod/reviews_test.exs | 385 ++++++++++++++++++ 7 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 lib/berrypod/reviews.ex create mode 100644 lib/berrypod/reviews/review.ex create mode 100644 priv/repo/migrations/20260401095806_create_reviews.exs create mode 100644 test/berrypod/reviews_test.exs diff --git a/docs/plans/reviews-system.md b/docs/plans/reviews-system.md index b06e9dd..95cb680 100644 --- a/docs/plans/reviews-system.md +++ b/docs/plans/reviews-system.md @@ -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 diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index 059ada6..c79ba81 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -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. diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index 501b57c..dbbe65d 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -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. """ diff --git a/lib/berrypod/reviews.ex b/lib/berrypod/reviews.ex new file mode 100644 index 0000000..7ccb8e4 --- /dev/null +++ b/lib/berrypod/reviews.ex @@ -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 diff --git a/lib/berrypod/reviews/review.ex b/lib/berrypod/reviews/review.ex new file mode 100644 index 0000000..a574664 --- /dev/null +++ b/lib/berrypod/reviews/review.ex @@ -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 diff --git a/priv/repo/migrations/20260401095806_create_reviews.exs b/priv/repo/migrations/20260401095806_create_reviews.exs new file mode 100644 index 0000000..8ee5328 --- /dev/null +++ b/priv/repo/migrations/20260401095806_create_reviews.exs @@ -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 diff --git a/test/berrypod/reviews_test.exs b/test/berrypod/reviews_test.exs new file mode 100644 index 0000000..b4a3188 --- /dev/null +++ b/test/berrypod/reviews_test.exs @@ -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