From 0893f6aaedbe525004bfd59a965a01728227ac95 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Fri, 22 Aug 2025 18:21:07 +0000 Subject: [PATCH 01/10] Add (paginated) product querries --- .vscode/settings.json | 5 +- .../pagination/get_pagination_info.sql | 20 ++++++ .../products/get_products_paginated.sql | 68 +++++++++++++++++++ .../translations/get_product_translation.sql | 35 ++++++++++ database/schema.sql | 9 +++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 database/functions/pagination/get_pagination_info.sql create mode 100644 database/functions/products/get_products_paginated.sql create mode 100644 database/functions/translations/get_product_translation.sql diff --git a/.vscode/settings.json b/.vscode/settings.json index b408a93..d21bab8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,10 @@ "password": "postgres" } ], + "sqltools.useNodeRuntime": false, "triggerTaskOnSave.tasks": { - "Lint SQL File": ["database/**/*.sql"] + "Lint SQL File": [ + "database/**/*.sql" + ] } } diff --git a/database/functions/pagination/get_pagination_info.sql b/database/functions/pagination/get_pagination_info.sql new file mode 100644 index 0000000..6067a5e --- /dev/null +++ b/database/functions/pagination/get_pagination_info.sql @@ -0,0 +1,20 @@ +CREATE OR REPLACE FUNCTION get_pagination_info( + p_page INTEGER, + p_size INTEGER, + p_total_count BIGINT +) RETURNS PAGINATION_INFO AS $$ +DECLARE + v_total_pages INTEGER; +BEGIN + v_total_pages := CEIL(p_total_count::NUMERIC / p_size); + + RETURN ROW( + p_page, + p_size, + p_total_count, + v_total_pages, + p_page < v_total_pages, -- has_next + p_page > 1 -- has_previous + )::pagination_info; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_products_paginated.sql b/database/functions/products/get_products_paginated.sql new file mode 100644 index 0000000..a296e1e --- /dev/null +++ b/database/functions/products/get_products_paginated.sql @@ -0,0 +1,68 @@ +CREATE OR REPLACE FUNCTION get_products_paginated( + p_page INTEGER DEFAULT 1, + p_size INTEGER DEFAULT 12, + p_language_iso CHAR(2) DEFAULT 'en', + p_sort_by TEXT DEFAULT 'created_at', + p_sort_order TEXT DEFAULT 'DESC' +) RETURNS TABLE ( + -- Product data + product_id INTEGER, + product_name TEXT, + product_description TEXT, + price NUMERIC(10, 2), + image_url TEXT, + created_at TIMESTAMPTZ, + -- Pagination metadata + pagination PAGINATION_INFO +) AS $$ +DECLARE + offset_val INTEGER; + total_count BIGINT; + pagination_data pagination_info; + sort_clause TEXT; +BEGIN + -- Validate and set defaults + p_page := GREATEST(p_page, 1); + p_size := GREATEST(p_size, 1); + offset_val := (p_page - 1) * p_size; + + -- Validate sort parameters + IF p_sort_by NOT IN ('created_at', 'price', 'product_name') THEN + p_sort_by := 'created_at'; + END IF; + + IF p_sort_order NOT IN ('ASC', 'DESC') THEN + p_sort_order := 'DESC'; + END IF; + + -- Get total count first + SELECT COUNT(*) INTO total_count + FROM products p + WHERE p.is_enabled = TRUE; + + -- Get pagination info + pagination_data := get_pagination_info(p_page, p_size, total_count); + + -- Build dynamic sort clause + sort_clause := format('ORDER BY %I %s', p_sort_by, p_sort_order); + + -- Return paginated results with translations + RETURN QUERY + EXECUTE format(' + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.price, + p.image_url, + p.created_at, + $1::pagination_info as pagination + FROM products p + LEFT JOIN LATERAL get_product_translation(p.product_id, $2) tr ON true + WHERE p.is_enabled = TRUE + %s + LIMIT $3 OFFSET $4', + sort_clause + ) USING pagination_data, p_language_iso, p_size, offset_val; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/translations/get_product_translation.sql b/database/functions/translations/get_product_translation.sql new file mode 100644 index 0000000..7634251 --- /dev/null +++ b/database/functions/translations/get_product_translation.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE FUNCTION get_product_translation( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + product_name TEXT, + product_description TEXT +) AS $$ +BEGIN + -- Try to get translation in requested language + RETURN QUERY + SELECT pt.product_name, pt.product_description + FROM product_translations pt + JOIN languages l ON pt.language_id = l.language_id + WHERE pt.product_id = p_product_id + AND l.iso_code = p_language_iso; + + -- If no translation found, fallback to English + IF NOT FOUND THEN + RETURN QUERY + SELECT pt.product_name, pt.product_description + FROM product_translations pt + JOIN languages l ON pt.language_id = l.language_id + WHERE pt.product_id = p_product_id + AND l.iso_code = 'en'; + END IF; + + -- If still no translation, use original product data + IF NOT FOUND THEN + RETURN QUERY + SELECT p.product_name, p.product_description + FROM products p + WHERE p.product_id = p_product_id; + END IF; +END; +$$ LANGUAGE plpgsql; diff --git a/database/schema.sql b/database/schema.sql index 84329bb..4032539 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -2,6 +2,15 @@ CREATE TYPE delivery_type AS ENUM ('pickup', 'delivery'); +CREATE TYPE pagination_info AS ( + current_page INTEGER, + page_size INTEGER, -- Default is 12 for listings, 24 for search, 36 for admin + total_items BIGINT, + total_pages INTEGER, + has_next BOOLEAN, + has_previous BOOLEAN +); + -- Languages (i18n) - followed by translation tables later in the schema CREATE TABLE languages ( From 4ffb785ff647eeb3850d28856cf9e7b30654ac48 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 23 Aug 2025 14:43:02 +0000 Subject: [PATCH 02/10] feat: Update database schema tests and seed data - Enhanced `schema.test.sql` to include new tables, columns, and constraints for products, categories, attributes, tags, and customization options. - Expanded test coverage to validate the existence of new tables and their relationships. - Updated `seed_data.test.sql` to include checks for new product categories, products, attributes, tags, and customization options. - Introduced new functions for product price calculation, product attributes filtering, category retrieval, product details fetching, tag retrieval, and product searching. - Added comprehensive tests for the new functions to ensure correct functionality and data retrieval. --- .../products/calculate_product_price.sql | 43 +++ .../get_product_attributes_for_filter.sql | 33 ++ .../products/get_product_categories.sql | 27 ++ .../products/get_product_details.sql | 158 +++++++++ .../functions/products/get_product_tags.sql | 28 ++ .../products/get_products_paginated.sql | 132 +++++--- .../functions/products/search_products.sql | 102 ++++++ database/schema.sql | 181 ++++++++++- database/seed_data.sql | 300 ++++++++++++++++++ database/tests/functions.test.sql | 68 ++++ database/tests/schema.test.sql | 176 ++++++---- database/tests/seed_data.test.sql | 80 +++-- 12 files changed, 1195 insertions(+), 133 deletions(-) create mode 100644 database/functions/products/calculate_product_price.sql create mode 100644 database/functions/products/get_product_attributes_for_filter.sql create mode 100644 database/functions/products/get_product_categories.sql create mode 100644 database/functions/products/get_product_details.sql create mode 100644 database/functions/products/get_product_tags.sql create mode 100644 database/functions/products/search_products.sql create mode 100644 database/tests/functions.test.sql diff --git a/database/functions/products/calculate_product_price.sql b/database/functions/products/calculate_product_price.sql new file mode 100644 index 0000000..33f9a56 --- /dev/null +++ b/database/functions/products/calculate_product_price.sql @@ -0,0 +1,43 @@ +CREATE OR REPLACE FUNCTION calculate_product_price( + p_product_id INTEGER, + p_variant_id INTEGER DEFAULT NULL, + p_customization_values INTEGER [] DEFAULT NULL +) RETURNS NUMERIC(10, 2) AS $$ +DECLARE + base_price NUMERIC(10, 2); + final_price NUMERIC(10, 2); + customization_total NUMERIC(10, 2) := 0; + product_type_val product_type; +BEGIN + -- Get product type and base pricing + SELECT p.product_type, + CASE + WHEN p.product_type = 'variant_based' AND p_variant_id IS NOT NULL THEN + COALESCE(pv.price_override, p.price) + WHEN p.product_type = 'configurable' THEN p.base_price + ELSE p.price + END + INTO product_type_val, base_price + FROM products p + LEFT JOIN product_variants pv ON p.product_id = pv.product_id + AND pv.product_variant_id = p_variant_id + WHERE p.product_id = p_product_id; + + IF base_price IS NULL THEN + RAISE EXCEPTION 'Product not found or invalid configuration'; + END IF; + + final_price := base_price; + + -- Add customization costs for configurable products + IF product_type_val = 'configurable' AND p_customization_values IS NOT NULL THEN + SELECT COALESCE(SUM(price_modifier), 0) INTO customization_total + FROM customization_option_values + WHERE option_value_id = ANY(p_customization_values); + + final_price := final_price + customization_total; + END IF; + + RETURN GREATEST(final_price, 0); -- Ensure price is never negative +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_attributes_for_filter.sql b/database/functions/products/get_product_attributes_for_filter.sql new file mode 100644 index 0000000..dca1718 --- /dev/null +++ b/database/functions/products/get_product_attributes_for_filter.sql @@ -0,0 +1,33 @@ +CREATE OR REPLACE FUNCTION get_product_attributes_for_filter( + p_category_id INTEGER DEFAULT NULL +) RETURNS TABLE ( + attribute_name TEXT, + attribute_values JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + pa.attribute_name, + jsonb_agg( + DISTINCT jsonb_build_object( + 'value', pa.attribute_value, + 'color', pa.attribute_color::TEXT, + 'count', ( + SELECT COUNT(DISTINCT p2.product_id) + FROM product_attributes pa2 + JOIN products p2 ON pa2.product_id = p2.product_id + WHERE pa2.attribute_name = pa.attribute_name + AND pa2.attribute_value = pa.attribute_value + AND p2.is_enabled = TRUE + AND (p_category_id IS NULL OR p2.category_id = p_category_id) + ) + ) + ) as attribute_values + FROM product_attributes pa + JOIN products p ON pa.product_id = p.product_id + WHERE p.is_enabled = TRUE + AND (p_category_id IS NULL OR p.category_id = p_category_id) + GROUP BY pa.attribute_name + ORDER BY pa.attribute_name; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_categories.sql b/database/functions/products/get_product_categories.sql new file mode 100644 index 0000000..eca7f0c --- /dev/null +++ b/database/functions/products/get_product_categories.sql @@ -0,0 +1,27 @@ +CREATE OR REPLACE FUNCTION get_product_categories( + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + category_id INTEGER, + category_name TEXT, + category_color TEXT, + category_description TEXT, + product_count BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + COALESCE(ct.category_description, pc.category_description) as category_description, + COUNT(p.product_id) as product_count + FROM product_categories pc + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN products p ON pc.category_id = p.category_id AND p.is_enabled = TRUE + WHERE pc.is_enabled = TRUE + GROUP BY pc.category_id, pc.category_name, pc.category_color, pc.category_description, + ct.category_name, ct.category_description, pc.display_order + ORDER BY pc.display_order, COALESCE(ct.category_name, pc.category_name); +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_details.sql b/database/functions/products/get_product_details.sql new file mode 100644 index 0000000..df36676 --- /dev/null +++ b/database/functions/products/get_product_details.sql @@ -0,0 +1,158 @@ +CREATE OR REPLACE FUNCTION get_product_details( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + -- Product data + product_id INTEGER, + product_name TEXT, + product_description TEXT, + product_type PRODUCT_TYPE, + price NUMERIC(10, 2), + base_price NUMERIC(10, 2), + image_url TEXT, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, + created_at TIMESTAMPTZ, + -- Related data + category JSONB, + variants JSONB, + customization_options JSONB, + attributes JSONB, + tags JSONB, + images JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + -- Category + CASE WHEN p.category_id IS NOT NULL THEN + jsonb_build_object( + 'category_id', pc.category_id, + 'name', COALESCE(ct.category_name, pc.category_name), + 'color', pc.category_color::TEXT, + 'description', COALESCE(ct.category_description, pc.category_description) + ) + ELSE NULL END as category, + -- Variants + CASE + WHEN p.product_type = 'variant_based' THEN + (SELECT jsonb_agg( + jsonb_build_object( + 'variant_id', pv.product_variant_id, + 'name', pv.variant_name, + 'quantity', pv.quantity, + 'price', COALESCE(pv.price_override, p.price), + 'serving_size', pv.serving_size, + 'is_default', pv.is_default, + 'size', pv.size + ) ORDER BY pv.display_order + ) FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_test = FALSE) + ELSE NULL + END as variants, + -- Customization options for configurable products + CASE + WHEN p.product_type = 'configurable' THEN + (SELECT jsonb_agg( + jsonb_build_object( + 'option_id', co.customization_option_id, + 'name', COALESCE(cot.option_name, co.option_name), + 'type', co.option_type, + 'is_required', COALESCE(pco.is_required, co.is_required), + 'values', CASE + WHEN co.option_type IN ('single_select', 'multi_select') THEN + (SELECT jsonb_agg( + jsonb_build_object( + 'value_id', cov.option_value_id, + 'name', COALESCE(covt.value_name, cov.value_name), + 'price_modifier', cov.price_modifier, + 'is_default', cov.is_default + ) ORDER BY cov.display_order + ) FROM customization_option_values cov + LEFT JOIN customization_option_value_translations covt + ON cov.option_value_id = covt.option_value_id + AND covt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE cov.customization_option_id = co.customization_option_id + AND cov.is_enabled = TRUE) + ELSE NULL + END + ) ORDER BY COALESCE(pco.display_order, co.display_order) + ) FROM product_customization_options pco + JOIN customization_options co ON pco.customization_option_id = co.customization_option_id + LEFT JOIN customization_option_translations cot + ON co.customization_option_id = cot.customization_option_id + AND cot.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE pco.product_id = p.product_id AND co.is_enabled = TRUE) + ELSE NULL + END as customization_options, + -- Product attributes (fixed - no nested aggregates) + (SELECT + CASE + WHEN COUNT(*) > 0 THEN + jsonb_object_agg( + attr_grouped.attribute_name, + attr_grouped.values + ) + ELSE NULL + END + FROM ( + SELECT + pa.attribute_name, + jsonb_agg( + jsonb_build_object( + 'value', pa.attribute_value, + 'color', pa.attribute_color::TEXT + ) + ) as values + FROM product_attributes pa + WHERE pa.product_id = p.product_id + GROUP BY pa.attribute_name + ) attr_grouped + ) as attributes, + -- Product tags + (SELECT jsonb_agg( + jsonb_build_object( + 'tag_id', pt.product_tag_id, + 'name', COALESCE(ptt.tag_name, pt.tag_name), + 'color', pt.tag_color::TEXT, + 'description', COALESCE(ptt.tag_description, pt.tag_description) + ) ORDER BY pt.display_order + ) FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE + ) as tags, + -- Product images + (SELECT jsonb_agg( + jsonb_build_object( + 'image_id', pi.product_image_id, + 'url', pi.image_url, + 'is_primary', pi.is_primary, + 'variant_id', pi.variant_id + ) ORDER BY pi.is_primary DESC, pi.created_at + ) FROM product_images pi + WHERE pi.product_id = p.product_id + ) as images + FROM products p + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + WHERE p.product_id = p_product_id AND p.is_enabled = TRUE; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_tags.sql b/database/functions/products/get_product_tags.sql new file mode 100644 index 0000000..83c73c6 --- /dev/null +++ b/database/functions/products/get_product_tags.sql @@ -0,0 +1,28 @@ +CREATE OR REPLACE FUNCTION get_product_tags( + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + tag_id INTEGER, + tag_name TEXT, + tag_color TEXT, + tag_description TEXT, + product_count BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + pt.product_tag_id, + COALESCE(ptt.tag_name, pt.tag_name) as tag_name, + pt.tag_color::TEXT, + COALESCE(ptt.tag_description, pt.tag_description) as tag_description, + COUNT(pta.product_id) as product_count + FROM product_tags pt + LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN product_tag_assignments pta ON pt.product_tag_id = pta.product_tag_id + LEFT JOIN products p ON pta.product_id = p.product_id AND p.is_enabled = TRUE + WHERE pt.is_enabled = TRUE + GROUP BY pt.product_tag_id, pt.tag_name, pt.tag_color, pt.tag_description, + ptt.tag_name, ptt.tag_description, pt.display_order + ORDER BY pt.display_order, COALESCE(ptt.tag_name, pt.tag_name); +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_products_paginated.sql b/database/functions/products/get_products_paginated.sql index a296e1e..4aabb7b 100644 --- a/database/functions/products/get_products_paginated.sql +++ b/database/functions/products/get_products_paginated.sql @@ -3,66 +3,98 @@ CREATE OR REPLACE FUNCTION get_products_paginated( p_size INTEGER DEFAULT 12, p_language_iso CHAR(2) DEFAULT 'en', p_sort_by TEXT DEFAULT 'created_at', - p_sort_order TEXT DEFAULT 'DESC' + p_sort_order TEXT DEFAULT 'DESC', + p_category_filter INTEGER DEFAULT NULL, + p_tag_filter INTEGER [] DEFAULT NULL, + p_attribute_filter JSONB DEFAULT NULL ) RETURNS TABLE ( - -- Product data product_id INTEGER, product_name TEXT, product_description TEXT, + product_type PRODUCT_TYPE, price NUMERIC(10, 2), + base_price NUMERIC(10, 2), image_url TEXT, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, created_at TIMESTAMPTZ, - -- Pagination metadata + category JSONB, + variants JSONB, + attributes JSONB, + tags JSONB, pagination PAGINATION_INFO ) AS $$ -DECLARE - offset_val INTEGER; - total_count BIGINT; - pagination_data pagination_info; - sort_clause TEXT; BEGIN - -- Validate and set defaults - p_page := GREATEST(p_page, 1); - p_size := GREATEST(p_size, 1); - offset_val := (p_page - 1) * p_size; - - -- Validate sort parameters - IF p_sort_by NOT IN ('created_at', 'price', 'product_name') THEN - p_sort_by := 'created_at'; - END IF; - - IF p_sort_order NOT IN ('ASC', 'DESC') THEN - p_sort_order := 'DESC'; - END IF; - - -- Get total count first - SELECT COUNT(*) INTO total_count - FROM products p - WHERE p.is_enabled = TRUE; - - -- Get pagination info - pagination_data := get_pagination_info(p_page, p_size, total_count); - - -- Build dynamic sort clause - sort_clause := format('ORDER BY %I %s', p_sort_by, p_sort_order); - - -- Return paginated results with translations + -- Simplified version without complex filtering for now RETURN QUERY - EXECUTE format(' - SELECT - p.product_id, - COALESCE(tr.product_name, p.product_name) as product_name, - COALESCE(tr.product_description, p.product_description) as product_description, - p.price, - p.image_url, - p.created_at, - $1::pagination_info as pagination - FROM products p - LEFT JOIN LATERAL get_product_translation(p.product_id, $2) tr ON true - WHERE p.is_enabled = TRUE - %s - LIMIT $3 OFFSET $4', - sort_clause - ) USING pagination_data, p_language_iso, p_size, offset_val; + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + -- Category + CASE WHEN p.category_id IS NOT NULL THEN + jsonb_build_object( + 'category_id', pc.category_id, + 'name', COALESCE(ct.category_name, pc.category_name), + 'color', pc.category_color::TEXT, + 'description', COALESCE(ct.category_description, pc.category_description) + ) + ELSE NULL END as category, + -- Variants + (SELECT jsonb_agg( + jsonb_build_object( + 'variant_id', pv.product_variant_id, + 'name', pv.variant_name, + 'quantity', pv.quantity, + 'price', COALESCE(pv.price_override, p.price), + 'serving_size', pv.serving_size, + 'is_default', pv.is_default + ) ORDER BY pv.display_order + ) FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_test = FALSE + ) as variants, + -- Attributes (simplified - no nested aggregates) + (SELECT jsonb_agg( + jsonb_build_object( + 'name', pa.attribute_name, + 'value', pa.attribute_value, + 'color', pa.attribute_color::TEXT + ) + ) FROM product_attributes pa WHERE pa.product_id = p.product_id + ) as attributes, + -- Tags + (SELECT jsonb_agg( + jsonb_build_object( + 'tag_id', pt.product_tag_id, + 'name', COALESCE(ptt.tag_name, pt.tag_name), + 'color', pt.tag_color::TEXT, + 'description', COALESCE(ptt.tag_description, pt.tag_description) + ) ORDER BY pt.display_order + ) FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE + ) as tags, + get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM products WHERE is_enabled = TRUE)::BIGINT) as pagination + FROM products p + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + WHERE p.is_enabled = TRUE + ORDER BY p.created_at DESC + LIMIT p_size OFFSET (p_page - 1) * p_size; END; $$ LANGUAGE plpgsql; diff --git a/database/functions/products/search_products.sql b/database/functions/products/search_products.sql new file mode 100644 index 0000000..47e8746 --- /dev/null +++ b/database/functions/products/search_products.sql @@ -0,0 +1,102 @@ +CREATE OR REPLACE FUNCTION search_products( + p_search_term TEXT, + p_page INTEGER DEFAULT 1, + p_size INTEGER DEFAULT 24, + p_language_iso CHAR(2) DEFAULT 'en', + p_category_filter INTEGER DEFAULT NULL, + p_tag_filter INTEGER [] DEFAULT NULL, + p_attribute_filter JSONB DEFAULT NULL +) RETURNS TABLE ( + product_id INTEGER, + product_name TEXT, + product_description TEXT, + product_type PRODUCT_TYPE, + price NUMERIC(10, 2), + base_price NUMERIC(10, 2), + image_url TEXT, + category JSONB, + attributes JSONB, + tags JSONB, + rank REAL, + pagination PAGINATION_INFO +) AS $$ +BEGIN + RETURN QUERY + WITH search_query AS ( + SELECT websearch_to_tsquery('english', p_search_term) as query + ), + filtered_products AS ( + SELECT DISTINCT p.product_id + FROM products p + LEFT JOIN product_translations pt ON p.product_id = pt.product_id + AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + CROSS JOIN search_query sq + WHERE p.is_enabled = TRUE + AND ( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ sq.query + OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ sq.query + ) + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + AND (p_tag_filter IS NULL OR EXISTS ( + SELECT 1 FROM product_tag_assignments pta + WHERE pta.product_id = p.product_id + AND pta.product_tag_id = ANY(p_tag_filter) + )) + ), + paginated_results AS ( + SELECT fp.product_id, + ROW_NUMBER() OVER (ORDER BY p.created_at DESC) as rn + FROM filtered_products fp + JOIN products p ON fp.product_id = p.product_id + LIMIT p_size OFFSET (p_page - 1) * p_size + ) + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + -- Category + CASE WHEN p.category_id IS NOT NULL THEN + jsonb_build_object( + 'category_id', pc.category_id, + 'name', COALESCE(ct.category_name, pc.category_name), + 'color', pc.category_color::TEXT + ) + ELSE NULL END as category, + -- Attributes (simplified approach - no nested aggregates) + (SELECT jsonb_agg( + jsonb_build_object( + 'name', pa.attribute_name, + 'value', pa.attribute_value, + 'color', pa.attribute_color::TEXT + ) + ) FROM product_attributes pa WHERE pa.product_id = p.product_id + ) as attributes, + -- Tags + (SELECT jsonb_agg( + jsonb_build_object( + 'tag_id', pt2.product_tag_id, + 'name', COALESCE(ptt.tag_name, pt2.tag_name), + 'color', pt2.tag_color::TEXT + ) + ) FROM product_tag_assignments pta + JOIN product_tags pt2 ON pta.product_tag_id = pt2.product_tag_id + LEFT JOIN product_tag_translations ptt ON pt2.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE pta.product_id = p.product_id AND pt2.is_enabled = TRUE + ) as tags, + -- Search ranking + 0.5::REAL as rank, -- Simplified ranking + get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM filtered_products)::BIGINT) as pagination + FROM paginated_results pr + JOIN products p ON pr.product_id = p.product_id + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + ORDER BY pr.rn; +END; +$$ LANGUAGE plpgsql; diff --git a/database/schema.sql b/database/schema.sql index 4032539..e538306 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,6 +1,9 @@ -- Enums & Types CREATE TYPE delivery_type AS ENUM ('pickup', 'delivery'); +CREATE TYPE product_type AS ENUM ('standard', 'configurable', 'variant_based'); + +CREATE DOMAIN hex_color AS TEXT CHECK (value ~ '^#[0-9A-Fa-f]{6}$'); CREATE TYPE pagination_info AS ( current_page INTEGER, @@ -20,37 +23,59 @@ CREATE TABLE languages ( native_name TEXT UNIQUE ); +-- Categories for main product grouping (cakes, pastries, bread, chocolate) +CREATE TABLE product_categories ( + category_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_name TEXT NOT NULL UNIQUE, + category_color HEX_COLOR DEFAULT '#1e90ff', + category_description TEXT, + display_order INTEGER DEFAULT 0, + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) +); + -- Product & Interaction Tables CREATE TABLE products ( product_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_id INTEGER REFERENCES product_categories (category_id) ON DELETE SET NULL, product_name TEXT NOT NULL, product_description TEXT, - price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), + product_type PRODUCT_TYPE DEFAULT 'standard', + price NUMERIC(10, 2) CHECK (price >= 0), + base_price NUMERIC(10, 2) CHECK (base_price >= 0), image_url TEXT, + preparation_time_hours INTEGER DEFAULT 48 CHECK (preparation_time_hours >= 0), + min_order_hours INTEGER DEFAULT 48 CHECK (min_order_hours >= 0), + serving_info TEXT, -- e.g., "4-6 persons", "per piece" + is_customizable BOOLEAN DEFAULT FALSE, is_enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT current_timestamp, updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) ); -CREATE TABLE product_categories ( - product_category_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - product_category_name TEXT NOT NULL UNIQUE, - product_category_description TEXT, - created_at TIMESTAMPTZ DEFAULT current_timestamp, - updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) -); +-- Ensure either price OR base_price is set for all products +CREATE UNIQUE INDEX idx_products_price_base_price ON products (product_id) WHERE (product_type = 'configurable' AND base_price IS NOT NULL) OR (product_type != 'configurable' AND price IS NOT NULL); CREATE TABLE product_variants ( product_variant_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, product_id INTEGER NOT NULL REFERENCES products (product_id) ON DELETE CASCADE, + variant_name TEXT, -- e.g., "6 pieces", "Small (4 persons)" size TEXT, + quantity INTEGER CHECK (quantity > 0), -- For quantity-based variants + serving_size TEXT, -- e.g., "4-6 persons" is_test BOOLEAN DEFAULT FALSE, price_override NUMERIC(10, 2) CHECK (price_override >= 0), + is_default BOOLEAN DEFAULT FALSE, + display_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT current_timestamp, updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) ); +-- Ensure only one default variant per product +CREATE UNIQUE INDEX idx_product_variants_default ON product_variants (product_id) WHERE is_default = TRUE; + CREATE TABLE product_images ( product_image_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, product_id INTEGER NOT NULL REFERENCES products (product_id) ON DELETE CASCADE, @@ -61,6 +86,72 @@ CREATE TABLE product_images ( updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) ); +-- Attributes for structured things like allergens, dietary suitability, flavors, portion size +CREATE TABLE product_attributes ( + product_attribute_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + product_id INTEGER NOT NULL REFERENCES products (product_id) ON DELETE CASCADE, + attribute_name TEXT NOT NULL, -- e.g., "allergen", "flavor", "dietary" + attribute_value TEXT NOT NULL, -- e.g., "gluten", "chocolate", "vegan" + attribute_color HEX_COLOR DEFAULT '#32cd32', + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp), + UNIQUE (product_id, attribute_name, attribute_value) +); + +-- Tags for more free-form marketing/organizational stuff like "seasonal", "Valentine's Day", "bestseller" +CREATE TABLE product_tags ( + product_tag_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tag_name TEXT NOT NULL UNIQUE CHECK (tag_name ~ '^[a-zA-Z0-9_\s''-]+$'), + tag_color HEX_COLOR DEFAULT '#ff8c00', + tag_description TEXT, + display_order INTEGER DEFAULT 0, + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) +); + +-- Junction table for product-tag relationships +CREATE TABLE product_tag_assignments ( + product_id INTEGER NOT NULL REFERENCES products (product_id) ON DELETE CASCADE, + product_tag_id INTEGER NOT NULL REFERENCES product_tags (product_tag_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + PRIMARY KEY (product_id, product_tag_id) +); + +-- Customization system for configurable products +CREATE TABLE customization_options ( + customization_option_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + option_name TEXT NOT NULL, -- e.g., "Cream Type", "Decoration" + option_type TEXT NOT NULL CHECK (option_type IN ('single_select', 'multi_select', 'text_input', 'number_input')), + is_required BOOLEAN DEFAULT FALSE, + display_order INTEGER DEFAULT 0, + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) +); + +CREATE TABLE customization_option_values ( + option_value_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + customization_option_id INTEGER NOT NULL REFERENCES customization_options (customization_option_id) ON DELETE CASCADE, + value_name TEXT NOT NULL, -- e.g., "Chocolate Cream", "Vanilla Cream" + price_modifier NUMERIC(10, 2) DEFAULT 0 CHECK (price_modifier >= -999999.99), -- Can be negative for discounts + is_default BOOLEAN DEFAULT FALSE, + display_order INTEGER DEFAULT 0, + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) +); + +-- Junction table linking products to customization options +CREATE TABLE product_customization_options ( + product_id INTEGER NOT NULL REFERENCES products (product_id) ON DELETE CASCADE, + customization_option_id INTEGER NOT NULL REFERENCES customization_options (customization_option_id) ON DELETE CASCADE, + is_required BOOLEAN DEFAULT FALSE, + display_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + PRIMARY KEY (product_id, customization_option_id) +); + -- Internationalization (i18n) CREATE TABLE translations ( @@ -86,7 +177,7 @@ CREATE TABLE product_translations ( CREATE TABLE category_translations ( category_translation_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - category_id INTEGER NOT NULL REFERENCES product_categories (product_category_id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES product_categories (category_id) ON DELETE CASCADE, language_id INTEGER NOT NULL REFERENCES languages (language_id) ON DELETE CASCADE, category_name TEXT NOT NULL, category_description TEXT, @@ -95,6 +186,37 @@ CREATE TABLE category_translations ( UNIQUE (category_id, language_id) ); +CREATE TABLE product_tag_translations ( + tag_translation_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + product_tag_id INTEGER NOT NULL REFERENCES product_tags (product_tag_id) ON DELETE CASCADE, + language_id INTEGER NOT NULL REFERENCES languages (language_id) ON DELETE CASCADE, + tag_name TEXT NOT NULL, + tag_description TEXT, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp), + UNIQUE (product_tag_id, language_id) +); + +CREATE TABLE customization_option_translations ( + option_translation_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + customization_option_id INTEGER NOT NULL REFERENCES customization_options (customization_option_id) ON DELETE CASCADE, + language_id INTEGER NOT NULL REFERENCES languages (language_id) ON DELETE CASCADE, + option_name TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp), + UNIQUE (customization_option_id, language_id) +); + +CREATE TABLE customization_option_value_translations ( + value_translation_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + option_value_id INTEGER NOT NULL REFERENCES customization_option_values (option_value_id) ON DELETE CASCADE, + language_id INTEGER NOT NULL REFERENCES languages (language_id) ON DELETE CASCADE, + value_name TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT current_timestamp, + updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp), + UNIQUE (option_value_id, language_id) +); + -- Support & Feedback CREATE TABLE contact_messages ( @@ -106,25 +228,56 @@ CREATE TABLE contact_messages ( updated_at TIMESTAMPTZ CHECK (updated_at <= current_timestamp) ); +-- ============================================================================= +-- INDEXES FOR PERFORMANCE +-- ============================================================================= + +-- Categories indexes +CREATE INDEX idx_product_categories_enabled ON product_categories (is_enabled, display_order) WHERE is_enabled = TRUE; +CREATE INDEX idx_product_categories_name ON product_categories (category_name); + -- Products table indexes CREATE INDEX idx_products_enabled ON products (is_enabled) WHERE is_enabled = TRUE; +CREATE INDEX idx_products_category ON products (category_id); +CREATE INDEX idx_products_type ON products (product_type); CREATE INDEX idx_products_price ON products (price); CREATE INDEX idx_products_created_at ON products (created_at DESC); CREATE INDEX idx_products_updated_at ON products (updated_at DESC); CREATE INDEX idx_products_enabled_price ON products (is_enabled, price) WHERE is_enabled = TRUE; +CREATE INDEX idx_products_customizable ON products (is_customizable) WHERE is_customizable = TRUE; +CREATE INDEX idx_products_preparation_time ON products (preparation_time_hours); +CREATE INDEX idx_products_enabled_type ON products (is_enabled, product_type) WHERE is_enabled = TRUE; -- Product variants indexes CREATE INDEX idx_product_variants_product_id ON product_variants (product_id); CREATE INDEX idx_product_variants_non_test ON product_variants (product_id) WHERE is_test = FALSE; CREATE INDEX idx_product_variants_size ON product_variants (size); +CREATE INDEX idx_product_variants_display_order ON product_variants (product_id, display_order); +CREATE INDEX idx_product_variants_quantity ON product_variants (quantity); -- Product images indexes CREATE INDEX idx_product_images_product_id ON product_images (product_id); CREATE INDEX idx_product_images_variant_id ON product_images (variant_id) WHERE variant_id IS NOT NULL; CREATE INDEX idx_product_images_primary ON product_images (product_id, is_primary) WHERE is_primary = TRUE; --- Product categories indexes (for future category filtering) -CREATE INDEX idx_product_categories_name ON product_categories (product_category_name); +-- Product attributes indexes +CREATE INDEX idx_product_attributes_product_id ON product_attributes (product_id); +CREATE INDEX idx_product_attributes_name ON product_attributes (attribute_name); +CREATE INDEX idx_product_attributes_value ON product_attributes (attribute_value); +CREATE INDEX idx_product_attributes_name_value ON product_attributes (attribute_name, attribute_value); + +-- Product tags indexes +CREATE INDEX idx_product_tags_enabled ON product_tags (is_enabled, display_order) WHERE is_enabled = TRUE; +CREATE INDEX idx_product_tags_color ON product_tags (tag_color); + +-- Junction table indexes +CREATE INDEX idx_product_tag_assignments_tag ON product_tag_assignments (product_tag_id); + +-- Customization indexes +CREATE INDEX idx_customization_options_enabled ON customization_options (is_enabled, display_order) WHERE is_enabled = TRUE; +CREATE INDEX idx_customization_option_values_option ON customization_option_values (customization_option_id, display_order); +CREATE INDEX idx_customization_option_values_enabled ON customization_option_values (is_enabled) WHERE is_enabled = TRUE; +CREATE INDEX idx_product_customization_options_product ON product_customization_options (product_id, display_order); -- Internationalization indexes CREATE INDEX idx_translations_key_lang ON translations (translation_key, language_id); @@ -136,6 +289,11 @@ CREATE INDEX idx_product_translations_language ON product_translations (language CREATE INDEX idx_category_translations_category_lang ON category_translations (category_id, language_id); CREATE INDEX idx_category_translations_language ON category_translations (language_id); +CREATE INDEX idx_product_tag_translations_tag_lang ON product_tag_translations (product_tag_id, language_id); + +CREATE INDEX idx_customization_option_translations_option_lang ON customization_option_translations (customization_option_id, language_id); +CREATE INDEX idx_customization_option_value_translations_value_lang ON customization_option_value_translations (option_value_id, language_id); + -- Languages index for i18n lookups CREATE INDEX idx_languages_iso_code ON languages (iso_code); @@ -146,3 +304,4 @@ CREATE INDEX idx_product_translations_search ON product_translations USING gin ( -- Composite indexes for common query patterns CREATE INDEX idx_products_enabled_created ON products (is_enabled, created_at DESC) WHERE is_enabled = TRUE; CREATE INDEX idx_products_enabled_price_created ON products (is_enabled, price, created_at DESC) WHERE is_enabled = TRUE; +CREATE INDEX idx_products_category_enabled ON products (category_id, is_enabled) WHERE is_enabled = TRUE; diff --git a/database/seed_data.sql b/database/seed_data.sql index 97d4c68..072322b 100644 --- a/database/seed_data.sql +++ b/database/seed_data.sql @@ -1,2 +1,302 @@ +-- ===================================== +-- SAMPLE DATA FOR CATEGORIES, ATTRIBUTES & TAGS SYSTEM +-- Replace/extend your seed_data.sql file with this data +-- ===================================== + +-- Insert languages first INSERT INTO languages (iso_code, english_name, native_name) VALUES ('fr', 'French', 'Français'), ('en', 'English', 'English'), ('es', 'Spanish', 'Español'); + +-- 1. INSERT PRODUCT CATEGORIES (main grouping) +INSERT INTO product_categories (category_name, category_color, category_description, display_order) VALUES +('Gâteaux', '#FF6B6B', 'Birthday cakes and celebration treats', 1), +('Viennoiseries', '#96CEB4', 'French pastries and croissants', 2), +('Chocolat', '#4ECDC4', 'Chocolate products and spreads', 3), +('Bouchées', '#45B7D1', 'Small bite-sized treats', 4), +('Pain', '#DDA0DD', 'Breads and traditional baked goods', 5); + +-- 2. INSERT PRODUCT TAGS (marketing/seasonal) +INSERT INTO product_tags (tag_name, tag_color, tag_description, display_order) VALUES +('Bestseller', '#FFD700', 'Most popular products', 1), +('Seasonal', '#FF7675', 'Limited time seasonal items', 2), +('Valentine''s Day', '#E84393', 'Perfect for Valentine''s Day', 3), +('Birthday', '#6C5CE7', 'Perfect for birthdays', 4), +('Wedding', '#A29BFE', 'Elegant wedding treats', 5), +('Christmas', '#00B894', 'Holiday specialties', 6), +('New', '#00CEC9', 'Recently added products', 7); + +-- 3. INSERT SAMPLE PRODUCTS WITH NEW STRUCTURE + +-- Variant-based product (Macarons - quantity pricing) +INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + price, + preparation_time_hours, + serving_info, + is_customizable +) VALUES ( + 4, -- Bouchées category + 'Macarons Assortis', + 'Assortment of colorful French macarons in various flavors', + 'variant_based', + NULL, + 24, + 'per piece', + FALSE +); + +-- Product variants for macarons +INSERT INTO product_variants (product_id, variant_name, quantity, price_override, is_default, display_order) VALUES +(1, '6 pièces', 6, 12.00, TRUE, 1), +(1, '12 pièces', 12, 20.00, FALSE, 2), +(1, '24 pièces', 24, 35.00, FALSE, 3); + +-- Configurable product (Custom Chocolate Cake) +INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + base_price, + preparation_time_hours, + min_order_hours, + serving_info, + is_customizable +) VALUES ( + 1, -- Gâteaux category + 'Gâteau Chocolat Personnalisé', + 'Custom chocolate cake with your choice of cream and decorations', + 'configurable', + 25.00, + 48, + 48, + '4-8 persons', + TRUE +); + +-- Standard product (Chocolate Paste) +INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + price, + preparation_time_hours, + serving_info, + is_customizable +) VALUES ( + 3, -- Chocolat category + 'Pâte à Tartiner Chocolat', + 'Rich and indulgent chocolate spread made from the finest ingredients', + 'standard', + 8.50, + 24, + '250g jar', + FALSE +); + +-- Standard product (Croissant) +INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + price, + preparation_time_hours, + serving_info, + is_customizable +) VALUES ( + 2, -- Viennoiseries category + 'Croissant Beurre', + 'Traditional French butter croissant, flaky and buttery', + 'standard', + 3.50, + 12, + '1 piece', + FALSE +); + +-- 4. INSERT PRODUCT ATTRIBUTES (structured data) + +-- Allergen attributes +INSERT INTO product_attributes (product_id, attribute_name, attribute_value, attribute_color) VALUES +-- Macarons allergens +(1, 'allergen', 'gluten', '#FF6B6B'), +(1, 'allergen', 'dairy', '#4ECDC4'), +(1, 'allergen', 'eggs', '#FFEAA7'), +(1, 'allergen', 'nuts', '#DDA0DD'), + +-- Custom cake allergens +(2, 'allergen', 'gluten', '#FF6B6B'), +(2, 'allergen', 'dairy', '#4ECDC4'), +(2, 'allergen', 'eggs', '#FFEAA7'), + +-- Chocolate spread allergens +(3, 'allergen', 'dairy', '#4ECDC4'), +(3, 'allergen', 'nuts', '#DDA0DD'), + +-- Croissant allergens +(4, 'allergen', 'gluten', '#FF6B6B'), +(4, 'allergen', 'dairy', '#4ECDC4'); + +-- Flavor attributes +INSERT INTO product_attributes (product_id, attribute_name, attribute_value, attribute_color) VALUES +(1, 'flavor', 'vanilla', '#F8F8F8'), +(1, 'flavor', 'chocolate', '#8B4513'), +(1, 'flavor', 'strawberry', '#FF69B4'), +(1, 'flavor', 'pistachio', '#9ACD32'), +(2, 'flavor', 'chocolate', '#8B4513'), +(3, 'flavor', 'chocolate', '#8B4513'), +(4, 'flavor', 'butter', '#FFE4B5'); + +-- Dietary attributes +INSERT INTO product_attributes (product_id, attribute_name, attribute_value, attribute_color) VALUES +(3, 'dietary', 'vegetarian', '#32CD32'); + +-- Portion attributes +INSERT INTO product_attributes (product_id, attribute_name, attribute_value, attribute_color) VALUES +(1, 'portion', 'individual', '#87CEEB'), +(2, 'portion', 'sharing', '#FF7F50'), +(3, 'portion', 'family', '#DDA0DD'), +(4, 'portion', 'individual', '#87CEEB'); + +-- 5. ASSIGN TAGS TO PRODUCTS +INSERT INTO product_tag_assignments (product_id, product_tag_id) VALUES +-- Macarons +(1, 1), -- Bestseller +(1, 4), -- Birthday + +-- Custom cake +(2, 4), -- Birthday +(2, 5), -- Wedding +(2, 7), -- New + +-- Chocolate spread +(3, 1), -- Bestseller + +-- Croissant +(4, 1), -- Bestseller +(4, 7); -- New + +-- 6. INSERT CUSTOMIZATION OPTIONS FOR CONFIGURABLE PRODUCTS + +INSERT INTO customization_options (option_name, option_type, is_required, display_order) VALUES +('Type de crème', 'single_select', TRUE, 1), +('Décoration', 'single_select', FALSE, 2), +('Taille', 'single_select', TRUE, 3), +('Message personnalisé', 'text_input', FALSE, 4); + +-- Cream type options +INSERT INTO customization_option_values (customization_option_id, value_name, price_modifier, is_default, display_order) VALUES +(1, 'Crème au chocolat', 0.00, TRUE, 1), +(1, 'Crème vanille', 2.00, FALSE, 2), +(1, 'Crème caramel', 3.00, FALSE, 3), +(1, 'Crème fruits rouges', 4.00, FALSE, 4); + +-- Decoration options +INSERT INTO customization_option_values (customization_option_id, value_name, price_modifier, is_default, display_order) VALUES +(2, 'Décoration simple', 0.00, TRUE, 1), +(2, 'Fleurs en sucre', 8.00, FALSE, 2), +(2, 'Figurines chocolat', 12.00, FALSE, 3), +(2, 'Décoration premium', 15.00, FALSE, 4); + +-- Size options +INSERT INTO customization_option_values (customization_option_id, value_name, price_modifier, is_default, display_order) VALUES +(3, '4 personnes', 0.00, TRUE, 1), +(3, '6 personnes', 8.00, FALSE, 2), +(3, '8 personnes', 15.00, FALSE, 3), +(3, '10 personnes', 22.00, FALSE, 4); + +-- Link customization options to the configurable cake product +INSERT INTO product_customization_options (product_id, customization_option_id, is_required, display_order) VALUES +(2, 1, TRUE, 1), -- Cream type is required +(2, 2, FALSE, 2), -- Decoration is optional +(2, 3, TRUE, 3), -- Size is required +(2, 4, FALSE, 4); -- Message is optional + +-- 7. ADD TRANSLATIONS FOR NEW CONTENT + +-- Category translations +INSERT INTO category_translations (category_id, language_id, category_name, category_description) VALUES +-- French translations (language_id = 1) +(1, 1, 'Gâteaux', 'Gâteaux d''anniversaire et de célébration'), +(2, 1, 'Viennoiseries', 'Pâtisseries françaises et croissants'), +(3, 1, 'Chocolat', 'Produits chocolatés et pâtes à tartiner'), +(4, 1, 'Bouchées', 'Petites gourmandises à déguster'), +(5, 1, 'Pain', 'Pains et produits de boulangerie traditionnels'), + +-- English translations (language_id = 2) +(1, 2, 'Cakes', 'Birthday cakes and celebration treats'), +(2, 2, 'Pastries', 'French pastries and croissants'), +(3, 2, 'Chocolate', 'Chocolate products and spreads'), +(4, 2, 'Bite-sized Treats', 'Small bite-sized treats'), +(5, 2, 'Bread', 'Breads and traditional baked goods'), + +-- Spanish translations (language_id = 3) +(1, 3, 'Pasteles', 'Pasteles de cumpleaños y celebración'), +(2, 3, 'Bollería', 'Bollería francesa y croissants'), +(3, 3, 'Chocolate', 'Productos de chocolate y cremas'), +(4, 3, 'Bocados', 'Pequeños bocados deliciosos'), +(5, 3, 'Pan', 'Panes y productos de panadería tradicionales'); + +-- Tag translations +INSERT INTO product_tag_translations (product_tag_id, language_id, tag_name, tag_description) VALUES +-- French translations +(1, 1, 'Bestseller', 'Produits les plus populaires'), +(2, 1, 'Saisonnier', 'Articles saisonniers à durée limitée'), +(3, 1, 'Saint-Valentin', 'Parfait pour la Saint-Valentin'), +(4, 1, 'Anniversaire', 'Parfait pour les anniversaires'), +(5, 1, 'Mariage', 'Friandises élégantes pour mariage'), +(6, 1, 'Noël', 'Spécialités des fêtes'), +(7, 1, 'Nouveau', 'Produits récemment ajoutés'), + +-- English translations +(1, 2, 'Bestseller', 'Most popular products'), +(2, 2, 'Seasonal', 'Limited time seasonal items'), +(3, 2, 'Valentine''s Day', 'Perfect for Valentine''s Day'), +(4, 2, 'Birthday', 'Perfect for birthdays'), +(5, 2, 'Wedding', 'Elegant wedding treats'), +(6, 2, 'Christmas', 'Holiday specialties'), +(7, 2, 'New', 'Recently added products'); + +-- Product translations +INSERT INTO product_translations (product_id, language_id, product_name, product_description) VALUES +-- English translations +(1, 2, 'Assorted Macarons', 'Assortment of colorful French macarons in various flavors'), +(2, 2, 'Custom Chocolate Cake', 'Custom chocolate cake with your choice of cream and decorations'), +(3, 2, 'Chocolate Spread', 'Rich and indulgent chocolate spread made from the finest ingredients'), +(4, 2, 'Butter Croissant', 'Traditional French butter croissant, flaky and buttery'), + +-- Spanish translations +(1, 3, 'Macarons Surtidos', 'Surtido de macarons franceses coloridos en varios sabores'), +(2, 3, 'Pastel de Chocolate Personalizado', 'Pastel de chocolate personalizado con tu elección de crema y decoraciones'), +(3, 3, 'Crema de Chocolate', 'Rica y deliciosa crema de chocolate hecha con los mejores ingredientes'), +(4, 3, 'Croissant de Mantequilla', 'Croissant francés tradicional de mantequilla, hojaldrado y mantecoso'); + +-- Customization option translations +INSERT INTO customization_option_translations (customization_option_id, language_id, option_name) VALUES +-- French (already in base data) +-- English +(1, 2, 'Cream Type'), (2, 2, 'Decoration'), (3, 2, 'Size'), (4, 2, 'Personal Message'), +-- Spanish +(1, 3, 'Tipo de Crema'), (2, 3, 'Decoración'), (3, 3, 'Tamaño'), (4, 3, 'Mensaje Personalizado'); + +-- Customization option value translations +INSERT INTO customization_option_value_translations (option_value_id, language_id, value_name) VALUES +-- Cream types - English +(1, 2, 'Chocolate Cream'), (2, 2, 'Vanilla Cream'), (3, 2, 'Caramel Cream'), (4, 2, 'Berry Cream'), +-- Cream types - Spanish +(1, 3, 'Crema de Chocolate'), (2, 3, 'Crema de Vainilla'), (3, 3, 'Crema de Caramelo'), (4, 3, 'Crema de Frutos Rojos'), + +-- Decorations - English +(5, 2, 'Simple Decoration'), (6, 2, 'Sugar Flowers'), (7, 2, 'Chocolate Figurines'), (8, 2, 'Premium Decoration'), +-- Decorations - Spanish +(5, 3, 'Decoración Simple'), (6, 3, 'Flores de Azúcar'), (7, 3, 'Figuras de Chocolate'), (8, 3, 'Decoración Premium'), + +-- Sizes - English +(9, 2, '4 people'), (10, 2, '6 people'), (11, 2, '8 people'), (12, 2, '10 people'), +-- Sizes - Spanish +(9, 3, '4 personas'), (10, 3, '6 personas'), (11, 3, '8 personas'), (12, 3, '10 personas'); diff --git a/database/tests/functions.test.sql b/database/tests/functions.test.sql new file mode 100644 index 0000000..6a29d07 --- /dev/null +++ b/database/tests/functions.test.sql @@ -0,0 +1,68 @@ +-- ===================================== +-- FIXED functions.test.sql +-- Replace: database/tests/functions.test.sql +-- ===================================== + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; + +SELECT plan(15); + +-- ============================================================================= +-- FUNCTION EXISTENCE TESTS +-- ============================================================================= + +SELECT has_function('get_pagination_info', 'get_pagination_info function exists'); +SELECT has_function('get_product_translation', 'get_product_translation function exists'); +SELECT has_function('get_products_paginated', 'get_products_paginated function exists'); +SELECT has_function('get_product_details', 'get_product_details function exists'); +SELECT has_function('get_product_categories', 'get_product_categories function exists'); +SELECT has_function('get_product_tags', 'get_product_tags function exists'); +SELECT has_function('get_product_attributes_for_filter', 'get_product_attributes_for_filter function exists'); +SELECT has_function('search_products', 'search_products function exists'); +SELECT has_function('calculate_product_price', 'calculate_product_price function exists'); + +-- ============================================================================= +-- FUNCTION FUNCTIONALITY TESTS +-- ============================================================================= + +-- Test get_product_categories function +SELECT ok( + (SELECT count(*) FROM get_product_categories('en')) > 0, + 'get_product_categories returns categories' +); + +-- Test get_product_tags function +SELECT ok( + (SELECT count(*) FROM get_product_tags('en')) > 0, + 'get_product_tags returns tags' +); + +-- Test get_products_paginated with basic parameters +SELECT ok( + (SELECT count(*) FROM get_products_paginated(1, 12, 'en')) > 0, + 'get_products_paginated returns products' +); + +-- Test get_products_paginated with category filter +SELECT ok( + (SELECT count(*) FROM get_products_paginated(1, 12, 'en', 'created_at', 'DESC', 1)) >= 0, + 'get_products_paginated works with category filter' +); + +-- Test search_products function +SELECT ok( + (SELECT count(*) FROM search_products('chocolate', 1, 12, 'en')) >= 0, + 'search_products function works' +); + +-- Test calculate_product_price for standard product +SELECT ok( + (SELECT calculate_product_price(3)) > 0, -- Chocolate spread product + 'calculate_product_price works for standard products' +); + +SELECT finish(); + +ROLLBACK; diff --git a/database/tests/schema.test.sql b/database/tests/schema.test.sql index 6f3fd70..b0f3ebd 100644 --- a/database/tests/schema.test.sql +++ b/database/tests/schema.test.sql @@ -1,21 +1,38 @@ +-- ===================================== +-- UPDATED TEST FILES FOR NEW SCHEMA +-- Replace your existing test files with these updated versions +-- ===================================== + +-- 1. UPDATED schema.test.sql +-- Replace: database/tests/schema.test.sql + CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; -SELECT plan(109); +SELECT plan(167); -- ============================================================================= -- TABLE EXISTENCE TESTS -- ============================================================================= SELECT has_table('languages', 'Languages table exists'); -SELECT has_table('products', 'Products table exists'); SELECT has_table('product_categories', 'Product categories table exists'); +SELECT has_table('products', 'Products table exists'); SELECT has_table('product_variants', 'Product variants table exists'); SELECT has_table('product_images', 'Product images table exists'); +SELECT has_table('product_attributes', 'Product attributes table exists'); +SELECT has_table('product_tags', 'Product tags table exists'); +SELECT has_table('product_tag_assignments', 'Product tag assignments table exists'); +SELECT has_table('customization_options', 'Customization options table exists'); +SELECT has_table('customization_option_values', 'Customization option values table exists'); +SELECT has_table('product_customization_options', 'Product customization options table exists'); SELECT has_table('translations', 'Translations table exists'); SELECT has_table('product_translations', 'Product translations table exists'); SELECT has_table('category_translations', 'Category translations table exists'); +SELECT has_table('product_tag_translations', 'Product tag translations table exists'); +SELECT has_table('customization_option_translations', 'Customization option translations table exists'); +SELECT has_table('customization_option_value_translations', 'Customization option value translations table exists'); SELECT has_table('contact_messages', 'Contact messages table exists'); -- ============================================================================= @@ -23,6 +40,9 @@ SELECT has_table('contact_messages', 'Contact messages table exists'); -- ============================================================================= SELECT has_type('delivery_type', 'delivery_type enum exists'); +SELECT has_type('product_type', 'product_type enum exists'); +SELECT has_type('pagination_info', 'pagination_info type exists'); +SELECT has_domain('hex_color', 'hex_color domain exists'); -- ============================================================================= -- COLUMN EXISTENCE AND TYPE TESTS @@ -36,76 +56,121 @@ SELECT has_column('languages', 'native_name', 'languages.native_name exists'); SELECT col_type_is('languages', 'language_id', 'integer', 'languages.language_id is integer'); SELECT col_type_is('languages', 'iso_code', 'character(2)', 'languages.iso_code is char(2)'); -SELECT col_type_is('languages', 'english_name', 'text', 'languages.english_name is text'); -SELECT col_type_is('languages', 'native_name', 'text', 'languages.native_name is text'); + +-- Product categories table columns +SELECT has_column('product_categories', 'category_id', 'product_categories.category_id exists'); +SELECT has_column('product_categories', 'category_name', 'product_categories.category_name exists'); +SELECT has_column('product_categories', 'category_color', 'product_categories.category_color exists'); +SELECT has_column('product_categories', 'category_description', 'product_categories.category_description exists'); +SELECT has_column('product_categories', 'display_order', 'product_categories.display_order exists'); +SELECT has_column('product_categories', 'is_enabled', 'product_categories.is_enabled exists'); + +SELECT col_type_is('product_categories', 'category_color', 'hex_color', 'product_categories.category_color is hex_color'); -- Products table columns SELECT has_column('products', 'product_id', 'products.product_id exists'); +SELECT has_column('products', 'category_id', 'products.category_id exists'); SELECT has_column('products', 'product_name', 'products.product_name exists'); SELECT has_column('products', 'product_description', 'products.product_description exists'); +SELECT has_column('products', 'product_type', 'products.product_type exists'); SELECT has_column('products', 'price', 'products.price exists'); +SELECT has_column('products', 'base_price', 'products.base_price exists'); SELECT has_column('products', 'image_url', 'products.image_url exists'); +SELECT has_column('products', 'preparation_time_hours', 'products.preparation_time_hours exists'); +SELECT has_column('products', 'min_order_hours', 'products.min_order_hours exists'); +SELECT has_column('products', 'serving_info', 'products.serving_info exists'); +SELECT has_column('products', 'is_customizable', 'products.is_customizable exists'); SELECT has_column('products', 'is_enabled', 'products.is_enabled exists'); SELECT has_column('products', 'created_at', 'products.created_at exists'); SELECT has_column('products', 'updated_at', 'products.updated_at exists'); SELECT col_type_is('products', 'product_id', 'integer', 'products.product_id is integer'); -SELECT col_type_is('products', 'product_name', 'text', 'products.product_name is text'); +SELECT col_type_is('products', 'product_type', 'product_type', 'products.product_type is product_type enum'); SELECT col_type_is('products', 'price', 'numeric(10,2)', 'products.price is numeric(10,2)'); -SELECT col_type_is('products', 'is_enabled', 'boolean', 'products.is_enabled is boolean'); -SELECT col_type_is('products', 'created_at', 'timestamp with time zone', 'products.created_at is timestamptz'); - --- Product categories table columns -SELECT has_column('product_categories', 'product_category_id', 'product_categories.product_category_id exists'); -SELECT has_column('product_categories', 'product_category_name', 'product_categories.product_category_name exists'); -SELECT has_column('product_categories', 'product_category_description', 'product_categories.product_category_description exists'); -SELECT has_column('product_categories', 'created_at', 'product_categories.created_at exists'); -SELECT has_column('product_categories', 'updated_at', 'product_categories.updated_at exists'); +SELECT col_type_is('products', 'base_price', 'numeric(10,2)', 'products.base_price is numeric(10,2)'); -- Product variants table columns SELECT has_column('product_variants', 'product_variant_id', 'product_variants.product_variant_id exists'); SELECT has_column('product_variants', 'product_id', 'product_variants.product_id exists'); +SELECT has_column('product_variants', 'variant_name', 'product_variants.variant_name exists'); SELECT has_column('product_variants', 'size', 'product_variants.size exists'); +SELECT has_column('product_variants', 'quantity', 'product_variants.quantity exists'); +SELECT has_column('product_variants', 'serving_size', 'product_variants.serving_size exists'); SELECT has_column('product_variants', 'is_test', 'product_variants.is_test exists'); SELECT has_column('product_variants', 'price_override', 'product_variants.price_override exists'); - -SELECT col_type_is('product_variants', 'is_test', 'boolean', 'product_variants.is_test is boolean'); -SELECT col_type_is('product_variants', 'price_override', 'numeric(10,2)', 'product_variants.price_override is numeric(10,2)'); +SELECT has_column('product_variants', 'is_default', 'product_variants.is_default exists'); +SELECT has_column('product_variants', 'display_order', 'product_variants.display_order exists'); + +-- Product attributes table columns +SELECT has_column('product_attributes', 'product_attribute_id', 'product_attributes.product_attribute_id exists'); +SELECT has_column('product_attributes', 'product_id', 'product_attributes.product_id exists'); +SELECT has_column('product_attributes', 'attribute_name', 'product_attributes.attribute_name exists'); +SELECT has_column('product_attributes', 'attribute_value', 'product_attributes.attribute_value exists'); +SELECT has_column('product_attributes', 'attribute_color', 'product_attributes.attribute_color exists'); + +SELECT col_type_is('product_attributes', 'attribute_color', 'hex_color', 'product_attributes.attribute_color is hex_color'); + +-- Product tags table columns +SELECT has_column('product_tags', 'product_tag_id', 'product_tags.product_tag_id exists'); +SELECT has_column('product_tags', 'tag_name', 'product_tags.tag_name exists'); +SELECT has_column('product_tags', 'tag_color', 'product_tags.tag_color exists'); +SELECT has_column('product_tags', 'tag_description', 'product_tags.tag_description exists'); +SELECT has_column('product_tags', 'display_order', 'product_tags.display_order exists'); +SELECT has_column('product_tags', 'is_enabled', 'product_tags.is_enabled exists'); + +SELECT col_type_is('product_tags', 'tag_color', 'hex_color', 'product_tags.tag_color is hex_color'); + +-- Customization options table columns +SELECT has_column('customization_options', 'customization_option_id', 'customization_options.customization_option_id exists'); +SELECT has_column('customization_options', 'option_name', 'customization_options.option_name exists'); +SELECT has_column('customization_options', 'option_type', 'customization_options.option_type exists'); +SELECT has_column('customization_options', 'is_required', 'customization_options.is_required exists'); -- ============================================================================= -- PRIMARY KEY TESTS -- ============================================================================= SELECT has_pk('languages', 'languages has primary key'); -SELECT has_pk('products', 'products has primary key'); SELECT has_pk('product_categories', 'product_categories has primary key'); +SELECT has_pk('products', 'products has primary key'); SELECT has_pk('product_variants', 'product_variants has primary key'); SELECT has_pk('product_images', 'product_images has primary key'); +SELECT has_pk('product_attributes', 'product_attributes has primary key'); +SELECT has_pk('product_tags', 'product_tags has primary key'); +SELECT has_pk('customization_options', 'customization_options has primary key'); +SELECT has_pk('customization_option_values', 'customization_option_values has primary key'); SELECT has_pk('translations', 'translations has primary key'); SELECT has_pk('product_translations', 'product_translations has primary key'); SELECT has_pk('category_translations', 'category_translations has primary key'); +SELECT has_pk('product_tag_translations', 'product_tag_translations has primary key'); SELECT has_pk('contact_messages', 'contact_messages has primary key'); SELECT col_is_pk('languages', 'language_id', 'languages.language_id is primary key'); SELECT col_is_pk('products', 'product_id', 'products.product_id is primary key'); -SELECT col_is_pk('product_categories', 'product_category_id', 'product_categories.product_category_id is primary key'); +SELECT col_is_pk('product_categories', 'category_id', 'product_categories.category_id is primary key'); -- ============================================================================= -- FOREIGN KEY TESTS -- ============================================================================= +SELECT has_fk('products', 'products has foreign key'); SELECT has_fk('product_variants', 'product_variants has foreign key'); SELECT has_fk('product_images', 'product_images has foreign key'); +SELECT has_fk('product_attributes', 'product_attributes has foreign key'); +SELECT has_fk('product_tag_assignments', 'product_tag_assignments has foreign key'); +SELECT has_fk('customization_option_values', 'customization_option_values has foreign key'); +SELECT has_fk('product_customization_options', 'product_customization_options has foreign key'); SELECT has_fk('translations', 'translations has foreign key'); SELECT has_fk('product_translations', 'product_translations has foreign key'); SELECT has_fk('category_translations', 'category_translations has foreign key'); -- Specific foreign key relationships +SELECT col_is_fk('products', 'category_id', 'products.category_id is foreign key'); SELECT col_is_fk('product_variants', 'product_id', 'product_variants.product_id is foreign key'); SELECT col_is_fk('product_images', 'product_id', 'product_images.product_id is foreign key'); -SELECT col_is_fk('translations', 'language_id', 'translations.language_id is foreign key'); -SELECT col_is_fk('product_translations', 'product_id', 'product_translations.product_id is foreign key'); -SELECT col_is_fk('product_translations', 'language_id', 'product_translations.language_id is foreign key'); +SELECT col_is_fk('product_attributes', 'product_id', 'product_attributes.product_id is foreign key'); +SELECT col_is_fk('product_tag_assignments', 'product_id', 'product_tag_assignments.product_id is foreign key'); +SELECT col_is_fk('product_tag_assignments', 'product_tag_id', 'product_tag_assignments.product_tag_id is foreign key'); -- ============================================================================= -- UNIQUE CONSTRAINT TESTS @@ -114,80 +179,83 @@ SELECT col_is_fk('product_translations', 'language_id', 'product_translations.la SELECT col_is_unique('languages', ARRAY['iso_code'], 'languages.iso_code is unique'); SELECT col_is_unique('languages', ARRAY['english_name'], 'languages.english_name is unique'); SELECT col_is_unique('languages', ARRAY['native_name'], 'languages.native_name is unique'); -SELECT col_is_unique('product_categories', ARRAY['product_category_name'], 'product_categories.product_category_name is unique'); +SELECT col_is_unique('product_categories', ARRAY['category_name'], 'product_categories.category_name is unique'); +SELECT col_is_unique('product_tags', ARRAY['tag_name'], 'product_tags.tag_name is unique'); SELECT col_is_unique('translations', ARRAY['translation_key'], 'translations.translation_key is unique'); SELECT col_is_unique('translations', ARRAY['translation_key', 'language_id'], 'translations (translation_key, language_id) is unique'); SELECT col_is_unique('product_translations', ARRAY['product_id', 'language_id'], 'product_translations (product_id, language_id) is unique'); SELECT col_is_unique('category_translations', ARRAY['category_id', 'language_id'], 'category_translations (category_id, language_id) is unique'); +SELECT col_is_unique('product_attributes', ARRAY['product_id', 'attribute_name', 'attribute_value'], 'product_attributes (product_id, attribute_name, attribute_value) is unique'); -- ============================================================================= -- NOT NULL CONSTRAINT TESTS -- ============================================================================= SELECT col_not_null('products', 'product_name', 'products.product_name is not null'); -SELECT col_not_null('products', 'price', 'products.price is not null'); -SELECT col_not_null('product_categories', 'product_category_name', 'product_categories.product_category_name is not null'); +SELECT col_not_null('product_categories', 'category_name', 'product_categories.category_name is not null'); SELECT col_not_null('product_variants', 'product_id', 'product_variants.product_id is not null'); SELECT col_not_null('product_images', 'product_id', 'product_images.product_id is not null'); SELECT col_not_null('product_images', 'image_url', 'product_images.image_url is not null'); -SELECT col_not_null('translations', 'translation_key', 'translations.translation_key is not null'); -SELECT col_not_null('translations', 'language_id', 'translations.language_id is not null'); -SELECT col_not_null('translations', 'translation_value', 'translations.translation_value is not null'); +SELECT col_not_null('product_attributes', 'product_id', 'product_attributes.product_id is not null'); +SELECT col_not_null('product_attributes', 'attribute_name', 'product_attributes.attribute_name is not null'); +SELECT col_not_null('product_attributes', 'attribute_value', 'product_attributes.attribute_value is not null'); +SELECT col_not_null('product_tags', 'tag_name', 'product_tags.tag_name is not null'); -- ============================================================================= -- DEFAULT VALUE TESTS -- ============================================================================= +SELECT col_has_default('products', 'product_type', 'products.product_type has default'); +SELECT col_has_default('products', 'is_customizable', 'products.is_customizable has default'); SELECT col_has_default('products', 'is_enabled', 'products.is_enabled has default'); SELECT col_has_default('products', 'created_at', 'products.created_at has default'); +SELECT col_has_default('products', 'preparation_time_hours', 'products.preparation_time_hours has default'); +SELECT col_has_default('products', 'min_order_hours', 'products.min_order_hours has default'); +SELECT col_has_default('product_categories', 'display_order', 'product_categories.display_order has default'); +SELECT col_has_default('product_categories', 'is_enabled', 'product_categories.is_enabled has default'); SELECT col_has_default('product_variants', 'is_test', 'product_variants.is_test has default'); +SELECT col_has_default('product_variants', 'is_default', 'product_variants.is_default has default'); +SELECT col_has_default('product_variants', 'display_order', 'product_variants.display_order has default'); SELECT col_has_default('product_images', 'is_primary', 'product_images.is_primary has default'); +SELECT col_has_default('product_tags', 'display_order', 'product_tags.display_order has default'); +SELECT col_has_default('product_tags', 'is_enabled', 'product_tags.is_enabled has default'); -- ============================================================================= -- INDEX TESTS -- ============================================================================= +-- Categories indexes +SELECT has_index('product_categories', 'idx_product_categories_enabled', 'Index idx_product_categories_enabled exists'); +SELECT has_index('product_categories', 'idx_product_categories_name', 'Index idx_product_categories_name exists'); + -- Products table indexes SELECT has_index('products', 'idx_products_enabled', 'Index idx_products_enabled exists'); +SELECT has_index('products', 'idx_products_category', 'Index idx_products_category exists'); +SELECT has_index('products', 'idx_products_type', 'Index idx_products_type exists'); SELECT has_index('products', 'idx_products_price', 'Index idx_products_price exists'); SELECT has_index('products', 'idx_products_created_at', 'Index idx_products_created_at exists'); -SELECT has_index('products', 'idx_products_updated_at', 'Index idx_products_updated_at exists'); -SELECT has_index('products', 'idx_products_enabled_price', 'Index idx_products_enabled_price exists'); +SELECT has_index('products', 'idx_products_customizable', 'Index idx_products_customizable exists'); -- Product variants indexes SELECT has_index('product_variants', 'idx_product_variants_product_id', 'Index idx_product_variants_product_id exists'); SELECT has_index('product_variants', 'idx_product_variants_non_test', 'Index idx_product_variants_non_test exists'); -SELECT has_index('product_variants', 'idx_product_variants_size', 'Index idx_product_variants_size exists'); - --- Product images indexes -SELECT has_index('product_images', 'idx_product_images_product_id', 'Index idx_product_images_product_id exists'); -SELECT has_index('product_images', 'idx_product_images_variant_id', 'Index idx_product_images_variant_id exists'); -SELECT has_index('product_images', 'idx_product_images_primary', 'Index idx_product_images_primary exists'); +SELECT has_index('product_variants', 'idx_product_variants_default', 'Index idx_product_variants_default exists'); +SELECT has_index('product_variants', 'idx_product_variants_display_order', 'Index idx_product_variants_display_order exists'); --- Product categories indexes -SELECT has_index('product_categories', 'idx_product_categories_name', 'Index idx_product_categories_name exists'); - --- Internationalization indexes -SELECT has_index('translations', 'idx_translations_key_lang', 'Index idx_translations_key_lang exists'); -SELECT has_index('translations', 'idx_translations_language', 'Index idx_translations_language exists'); - -SELECT has_index('product_translations', 'idx_product_translations_product_lang', 'Index idx_product_translations_product_lang exists'); -SELECT has_index('product_translations', 'idx_product_translations_language', 'Index idx_product_translations_language exists'); +-- Product attributes indexes +SELECT has_index('product_attributes', 'idx_product_attributes_product_id', 'Index idx_product_attributes_product_id exists'); +SELECT has_index('product_attributes', 'idx_product_attributes_name', 'Index idx_product_attributes_name exists'); +SELECT has_index('product_attributes', 'idx_product_attributes_value', 'Index idx_product_attributes_value exists'); +SELECT has_index('product_attributes', 'idx_product_attributes_name_value', 'Index idx_product_attributes_name_value exists'); -SELECT has_index('category_translations', 'idx_category_translations_category_lang', 'Index idx_category_translations_category_lang exists'); -SELECT has_index('category_translations', 'idx_category_translations_language', 'Index idx_category_translations_language exists'); - --- Languages index for i18n lookups -SELECT has_index('languages', 'idx_languages_iso_code', 'Index idx_languages_iso_code exists'); +-- Product tags indexes +SELECT has_index('product_tags', 'idx_product_tags_enabled', 'Index idx_product_tags_enabled exists'); +SELECT has_index('product_tag_assignments', 'idx_product_tag_assignments_tag', 'Index idx_product_tag_assignments_tag exists'); -- Full-text search indexes (GIN indexes) SELECT has_index('products', 'idx_products_search', 'Index idx_products_search exists'); SELECT has_index('product_translations', 'idx_product_translations_search', 'Index idx_product_translations_search exists'); --- Composite indexes for common query patterns -SELECT has_index('products', 'idx_products_enabled_created', 'Index idx_products_enabled_created exists'); -SELECT has_index('products', 'idx_products_enabled_price_created', 'Index idx_products_enabled_price_created exists'); - SELECT finish(); ROLLBACK; diff --git a/database/tests/seed_data.test.sql b/database/tests/seed_data.test.sql index 9f744d9..9443cd9 100644 --- a/database/tests/seed_data.test.sql +++ b/database/tests/seed_data.test.sql @@ -2,41 +2,85 @@ CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; -SELECT plan(3); +SELECT plan(12); +-- Test languages SELECT is( ( SELECT count(*)::INT FROM languages - WHERE - iso_code = 'fr' - AND english_name = 'French' - AND native_name = 'Français' + WHERE iso_code = 'fr' ), - 1, 'Language with ISO code "fr" exists' + 1, 'French language exists' ); SELECT is( ( SELECT count(*)::INT FROM languages - WHERE - iso_code = 'en' - AND english_name = 'English' - AND native_name = 'English' + WHERE iso_code = 'en' ), - 1, 'Language with ISO code "en" exists' + 1, 'English language exists' ); SELECT is( ( SELECT count(*)::INT FROM languages - WHERE - iso_code = 'es' - AND english_name = 'Spanish' - AND native_name = 'Español' + WHERE iso_code = 'es' ), - 1, 'Language with ISO code "es" exists' + 1, 'Spanish language exists' ); -SELECT finish(TRUE); +-- Test categories +SELECT isnt( + (SELECT count(*)::INT FROM product_categories), + 0, 'Product categories exist' +); + +-- Test products +SELECT isnt( + (SELECT count(*)::INT FROM products), + 0, 'Products exist' +); + +-- Test product attributes +SELECT isnt( + (SELECT count(*)::INT FROM product_attributes), + 0, 'Product attributes exist' +); + +-- Test product tags +SELECT isnt( + (SELECT count(*)::INT FROM product_tags), + 0, 'Product tags exist' +); + +-- Test product tag assignments +SELECT isnt( + (SELECT count(*)::INT FROM product_tag_assignments), + 0, 'Product tag assignments exist' +); + +-- Test customization options +SELECT isnt( + (SELECT count(*)::INT FROM customization_options), + 0, 'Customization options exist' +); + +-- Test customization option values +SELECT isnt( + (SELECT count(*)::INT FROM customization_option_values), + 0, 'Customization option values exist' +); + +-- Test translations +SELECT isnt( + (SELECT count(*)::INT FROM category_translations), + 0, 'Category translations exist' +); + +-- Test product variants +SELECT isnt( + (SELECT count(*)::INT FROM product_variants), + 0, 'Product variants exist' +); -ROLLBACK; +SELECT finish(); From 514b37a29490df4d7301f44c2bddc9a3d1ebf6b5 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 23 Aug 2025 18:15:44 +0000 Subject: [PATCH 03/10] Remove useless comments from schema test file --- database/tests/schema.test.sql | 8 -------- 1 file changed, 8 deletions(-) diff --git a/database/tests/schema.test.sql b/database/tests/schema.test.sql index b0f3ebd..dd221a3 100644 --- a/database/tests/schema.test.sql +++ b/database/tests/schema.test.sql @@ -1,11 +1,3 @@ --- ===================================== --- UPDATED TEST FILES FOR NEW SCHEMA --- Replace your existing test files with these updated versions --- ===================================== - --- 1. UPDATED schema.test.sql --- Replace: database/tests/schema.test.sql - CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; From d0450a50f02a0e020faa56760d5651fa316c87b1 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 23 Aug 2025 18:16:46 +0000 Subject: [PATCH 04/10] Clean up seed data and test files by removing unnecessary comments --- database/seed_data.sql | 5 ----- database/tests/functions.test.sql | 5 ----- 2 files changed, 10 deletions(-) diff --git a/database/seed_data.sql b/database/seed_data.sql index 072322b..892ba6e 100644 --- a/database/seed_data.sql +++ b/database/seed_data.sql @@ -1,8 +1,3 @@ --- ===================================== --- SAMPLE DATA FOR CATEGORIES, ATTRIBUTES & TAGS SYSTEM --- Replace/extend your seed_data.sql file with this data --- ===================================== - -- Insert languages first INSERT INTO languages (iso_code, english_name, native_name) VALUES ('fr', 'French', 'Français'), ('en', 'English', 'English'), ('es', 'Spanish', 'Español'); diff --git a/database/tests/functions.test.sql b/database/tests/functions.test.sql index 6a29d07..0cf6e28 100644 --- a/database/tests/functions.test.sql +++ b/database/tests/functions.test.sql @@ -1,8 +1,3 @@ --- ===================================== --- FIXED functions.test.sql --- Replace: database/tests/functions.test.sql --- ===================================== - CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; From 8a3caf47b9cb2d6f8e9be5845b207d74be16fee7 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Sat, 23 Aug 2025 18:35:23 +0000 Subject: [PATCH 05/10] Improve readability of seed data SQL by standardizing comment formatting --- database/seed_data.sql | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/database/seed_data.sql b/database/seed_data.sql index 892ba6e..dea3807 100644 --- a/database/seed_data.sql +++ b/database/seed_data.sql @@ -2,7 +2,10 @@ INSERT INTO languages (iso_code, english_name, native_name) VALUES ('fr', 'French', 'Français'), ('en', 'English', 'English'), ('es', 'Spanish', 'Español'); --- 1. INSERT PRODUCT CATEGORIES (main grouping) +-- ============================================================================= +-- INSERT PRODUCT CATEGORIES (main grouping) +-- ============================================================================= + INSERT INTO product_categories (category_name, category_color, category_description, display_order) VALUES ('Gâteaux', '#FF6B6B', 'Birthday cakes and celebration treats', 1), ('Viennoiseries', '#96CEB4', 'French pastries and croissants', 2), @@ -10,7 +13,10 @@ INSERT INTO product_categories (category_name, category_color, category_descript ('Bouchées', '#45B7D1', 'Small bite-sized treats', 4), ('Pain', '#DDA0DD', 'Breads and traditional baked goods', 5); --- 2. INSERT PRODUCT TAGS (marketing/seasonal) +-- ============================================================================= +-- INSERT PRODUCT TAGS (marketing/seasonal) +-- ============================================================================= + INSERT INTO product_tags (tag_name, tag_color, tag_description, display_order) VALUES ('Bestseller', '#FFD700', 'Most popular products', 1), ('Seasonal', '#FF7675', 'Limited time seasonal items', 2), @@ -20,7 +26,9 @@ INSERT INTO product_tags (tag_name, tag_color, tag_description, display_order) V ('Christmas', '#00B894', 'Holiday specialties', 6), ('New', '#00CEC9', 'Recently added products', 7); --- 3. INSERT SAMPLE PRODUCTS WITH NEW STRUCTURE +-- ============================================================================= +-- INSERT SAMPLE PRODUCTS WITH NEW STRUCTURE +-- ============================================================================= -- Variant-based product (Macarons - quantity pricing) INSERT INTO products ( @@ -114,7 +122,9 @@ INSERT INTO products ( FALSE ); --- 4. INSERT PRODUCT ATTRIBUTES (structured data) +-- ============================================================================= +-- INSERT PRODUCT ATTRIBUTES (structured data) +-- ============================================================================= -- Allergen attributes INSERT INTO product_attributes (product_id, attribute_name, attribute_value, attribute_color) VALUES @@ -176,7 +186,9 @@ INSERT INTO product_tag_assignments (product_id, product_tag_id) VALUES (4, 1), -- Bestseller (4, 7); -- New --- 6. INSERT CUSTOMIZATION OPTIONS FOR CONFIGURABLE PRODUCTS +-- ============================================================================= +-- INSERT CUSTOMIZATION OPTIONS FOR CONFIGURABLE PRODUCTS +-- ============================================================================= INSERT INTO customization_options (option_name, option_type, is_required, display_order) VALUES ('Type de crème', 'single_select', TRUE, 1), From 6fb977c45cd963a7d0d132711ca63d528349a31e Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Mon, 25 Aug 2025 02:32:08 +0000 Subject: [PATCH 06/10] Add functions for product management including adding new products, customization options, variants, and updating product status --- .../functions/products/add_new_product.sql | 302 ++++++++++++++++++ .../add_product_customization_option.sql | 64 ++++ .../products/add_product_variant.sql | 100 ++++++ .../products/update_product_status.sql | 44 +++ 4 files changed, 510 insertions(+) create mode 100644 database/functions/products/add_new_product.sql create mode 100644 database/functions/products/add_product_customization_option.sql create mode 100644 database/functions/products/add_product_variant.sql create mode 100644 database/functions/products/update_product_status.sql diff --git a/database/functions/products/add_new_product.sql b/database/functions/products/add_new_product.sql new file mode 100644 index 0000000..b690ba0 --- /dev/null +++ b/database/functions/products/add_new_product.sql @@ -0,0 +1,302 @@ +-- Custom exception types for better error handling +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'raise_product_exception') THEN + NULL; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION add_new_product( + -- Required product information + p_product_name TEXT, + p_product_description TEXT, + p_product_type PRODUCT_TYPE, + p_category_id INTEGER, + + -- Pricing (one must be provided based on product type) + p_price NUMERIC(10, 2) DEFAULT NULL, + p_base_price NUMERIC(10, 2) DEFAULT NULL, + + -- Optional product details + p_image_url TEXT DEFAULT NULL, + p_preparation_time_hours INTEGER DEFAULT 48, + p_min_order_hours INTEGER DEFAULT 48, + p_serving_info TEXT DEFAULT NULL, + p_is_customizable BOOLEAN DEFAULT FALSE, + + -- Optional arrays for related data + p_tag_ids INTEGER [] DEFAULT NULL, + p_attributes JSONB DEFAULT NULL, -- Format: [{"name": "allergen", "value": "gluten", "color": "#FF6B6B"}] + p_translations JSONB DEFAULT NULL, -- Format: [{"language_iso": "en", "name": "...", "description": "..."}] + + -- Audit information + p_created_by TEXT DEFAULT 'system' +) RETURNS INTEGER AS $$ +DECLARE + v_product_id INTEGER; + v_category_exists BOOLEAN; + v_tag_record RECORD; + v_attr_record RECORD; + v_trans_record RECORD; + v_language_id INTEGER; +BEGIN + -- ============================================================================= + -- INPUT VALIDATION AND SECURITY CHECKS + -- ============================================================================= + + -- Validate required fields + IF p_product_name IS NULL OR TRIM(p_product_name) = '' THEN + RAISE EXCEPTION 'Product name cannot be empty' + USING ERRCODE = 'P0001', + HINT = 'Please provide a valid product name'; + END IF; + + IF p_product_description IS NULL OR TRIM(p_product_description) = '' THEN + RAISE EXCEPTION 'Product description cannot be empty' + USING ERRCODE = 'P0002', + HINT = 'Please provide a product description'; + END IF; + + -- Validate product name length and format + IF LENGTH(TRIM(p_product_name)) < 3 THEN + RAISE EXCEPTION 'Product name must be at least 3 characters long' + USING ERRCODE = 'P0003'; + END IF; + + IF LENGTH(TRIM(p_product_name)) > 200 THEN + RAISE EXCEPTION 'Product name cannot exceed 200 characters' + USING ERRCODE = 'P0004'; + END IF; + + -- Sanitize and validate product name (prevent XSS) + IF p_product_name ~ '[<>''"]' THEN + RAISE EXCEPTION 'Product name contains invalid characters' + USING ERRCODE = 'P0005', + HINT = 'Product name cannot contain <, >, quotes, or other special characters'; + END IF; + + -- Validate description length + IF LENGTH(p_product_description) > 2000 THEN + RAISE EXCEPTION 'Product description cannot exceed 2000 characters' + USING ERRCODE = 'P0006'; + END IF; + + -- Validate pricing based on product type + IF p_product_type = 'configurable' THEN + IF p_base_price IS NULL OR p_base_price < 0 THEN + RAISE EXCEPTION 'Configurable products require a valid base_price >= 0' + USING ERRCODE = 'P0007', + HINT = 'Set base_price for configurable products'; + END IF; + IF p_price IS NOT NULL THEN + RAISE WARNING 'Price field ignored for configurable products, using base_price'; + END IF; + ELSE + IF p_price IS NULL OR p_price < 0 THEN + RAISE EXCEPTION 'Standard and variant-based products require a valid price >= 0' + USING ERRCODE = 'P0008', + HINT = 'Set price for non-configurable products'; + END IF; + IF p_base_price IS NOT NULL THEN + RAISE WARNING 'Base price field ignored for non-configurable products'; + END IF; + END IF; + + -- Validate time constraints + IF p_preparation_time_hours < 0 OR p_preparation_time_hours > 8760 THEN -- Max 1 year + RAISE EXCEPTION 'Preparation time must be between 0 and 8760 hours' + USING ERRCODE = 'P0009'; + END IF; + + IF p_min_order_hours < 0 OR p_min_order_hours > 8760 THEN + RAISE EXCEPTION 'Minimum order time must be between 0 and 8760 hours' + USING ERRCODE = 'P0010'; + END IF; + + -- Validate category exists and is enabled + SELECT EXISTS ( + SELECT 1 FROM product_categories + WHERE category_id = p_category_id + AND is_enabled = TRUE + ) INTO v_category_exists; + + IF NOT v_category_exists THEN + RAISE EXCEPTION 'Invalid or disabled category ID: %', p_category_id + USING ERRCODE = 'P0011', + HINT = 'Please select a valid, enabled category'; + END IF; + + -- Validate image URL format if provided + IF p_image_url IS NOT NULL AND p_image_url !~ '^https?://[^\s]+\.(jpg|jpeg|png|webp)(\?[^\s]*)?$' THEN + RAISE EXCEPTION 'Invalid image URL format' + USING ERRCODE = 'P0012', + HINT = 'Image URL must be a valid HTTP/HTTPS URL pointing to jpg, jpeg, png, or webp file'; + END IF; + + -- Check for duplicate product names (case-insensitive) + IF EXISTS ( + SELECT 1 FROM products + WHERE LOWER(product_name) = LOWER(TRIM(p_product_name)) + AND is_enabled = TRUE + ) THEN + RAISE EXCEPTION 'A product with this name already exists' + USING ERRCODE = 'P0013', + HINT = 'Please choose a different product name'; + END IF; + + -- Validate tag IDs if provided + IF p_tag_ids IS NOT NULL THEN + FOR i IN 1..array_length(p_tag_ids, 1) LOOP + IF NOT EXISTS ( + SELECT 1 FROM product_tags + WHERE product_tag_id = p_tag_ids[i] + AND is_enabled = TRUE + ) THEN + RAISE EXCEPTION 'Invalid or disabled tag ID: %', p_tag_ids[i] + USING ERRCODE = 'P0014', + HINT = 'All tag IDs must reference existing, enabled tags'; + END IF; + END LOOP; + END IF; + + -- ============================================================================= + -- INSERT MAIN PRODUCT RECORD + -- ============================================================================= + + INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + price, + base_price, + image_url, + preparation_time_hours, + min_order_hours, + serving_info, + is_customizable + ) VALUES ( + p_category_id, + TRIM(p_product_name), + TRIM(p_product_description), + p_product_type, + CASE WHEN p_product_type != 'configurable' THEN p_price ELSE NULL END, + CASE WHEN p_product_type = 'configurable' THEN p_base_price ELSE NULL END, + p_image_url, + p_preparation_time_hours, + p_min_order_hours, + p_serving_info, + p_is_customizable + ) RETURNING product_id INTO v_product_id; + + -- ============================================================================= + -- ADD RELATED DATA + -- ============================================================================= + + -- Add tag associations + IF p_tag_ids IS NOT NULL AND array_length(p_tag_ids, 1) > 0 THEN + FOR i IN 1..array_length(p_tag_ids, 1) LOOP + INSERT INTO product_tag_assignments (product_id, product_tag_id) + VALUES (v_product_id, p_tag_ids[i]) + ON CONFLICT (product_id, product_tag_id) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Added % tag associations', array_length(p_tag_ids, 1); + END IF; + + -- Add product attributes + IF p_attributes IS NOT NULL THEN + FOR v_attr_record IN SELECT * FROM jsonb_array_elements(p_attributes) LOOP + -- Validate attribute structure + IF NOT (v_attr_record.value ? 'name' AND v_attr_record.value ? 'value') THEN + RAISE EXCEPTION 'Each attribute must have "name" and "value" fields' + USING ERRCODE = 'P0015'; + END IF; + + -- Validate attribute name and value + IF TRIM(v_attr_record.value->>'name') = '' OR TRIM(v_attr_record.value->>'value') = '' THEN + RAISE EXCEPTION 'Attribute name and value cannot be empty' + USING ERRCODE = 'P0016'; + END IF; + + -- Validate hex color if provided + IF v_attr_record.value ? 'color' THEN + IF NOT (v_attr_record.value->>'color' ~ '^#[0-9A-Fa-f]{6}$') THEN + RAISE EXCEPTION 'Invalid hex color format: %', v_attr_record.value->>'color' + USING ERRCODE = 'P0017', + HINT = 'Color must be in format #RRGGBB'; + END IF; + END IF; + + INSERT INTO product_attributes ( + product_id, + attribute_name, + attribute_value, + attribute_color + ) VALUES ( + v_product_id, + TRIM(v_attr_record.value->>'name'), + TRIM(v_attr_record.value->>'value'), + COALESCE((v_attr_record.value->>'color')::HEX_COLOR, '#32cd32') + ) ON CONFLICT (product_id, attribute_name, attribute_value) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Added product attributes'; + END IF; + + -- Add translations + IF p_translations IS NOT NULL THEN + FOR v_trans_record IN SELECT * FROM jsonb_array_elements(p_translations) LOOP + -- Validate translation structure + IF NOT (v_trans_record.value ? 'language_iso' AND v_trans_record.value ? 'name') THEN + RAISE EXCEPTION 'Each translation must have "language_iso" and "name" fields' + USING ERRCODE = 'P0018'; + END IF; + + -- Get language ID + SELECT language_id INTO v_language_id + FROM languages + WHERE iso_code = v_trans_record.value->>'language_iso'; + + IF v_language_id IS NULL THEN + RAISE EXCEPTION 'Invalid language ISO code: %', v_trans_record.value->>'language_iso' + USING ERRCODE = 'P0019', + HINT = 'Language must exist in languages table'; + END IF; + + -- Validate translation data + IF TRIM(v_trans_record.value->>'name') = '' THEN + RAISE EXCEPTION 'Translation name cannot be empty' + USING ERRCODE = 'P0020'; + END IF; + + INSERT INTO product_translations ( + product_id, + language_id, + product_name, + product_description + ) VALUES ( + v_product_id, + v_language_id, + TRIM(v_trans_record.value->>'name'), + TRIM(COALESCE(v_trans_record.value->>'description', '')) + ) ON CONFLICT (product_id, language_id) DO UPDATE SET + product_name = EXCLUDED.product_name, + product_description = EXCLUDED.product_description; + END LOOP; + + RAISE NOTICE 'Added product translations'; + END IF; + + -- Log successful creation + RAISE NOTICE 'Successfully created product ID % with name "%"', v_product_id, p_product_name; + + RETURN v_product_id; + +EXCEPTION + WHEN OTHERS THEN + -- Log the error for debugging + RAISE NOTICE 'Error creating product "%": % - %', p_product_name, SQLSTATE, SQLERRM; + RAISE; -- Re-raise the exception +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/add_product_customization_option.sql b/database/functions/products/add_product_customization_option.sql new file mode 100644 index 0000000..68633c5 --- /dev/null +++ b/database/functions/products/add_product_customization_option.sql @@ -0,0 +1,64 @@ +CREATE OR REPLACE FUNCTION add_product_customization_option( + p_product_id INTEGER, + p_customization_option_id INTEGER, + p_is_required BOOLEAN DEFAULT FALSE, + p_display_order INTEGER DEFAULT 0 +) RETURNS BOOLEAN AS $$ +DECLARE + v_product_type PRODUCT_TYPE; + v_option_exists BOOLEAN; +BEGIN + -- Validate product exists and is configurable + SELECT product_type INTO v_product_type + FROM products + WHERE product_id = p_product_id + AND is_enabled = TRUE; + + IF v_product_type IS NULL THEN + RAISE EXCEPTION 'Product ID % not found or is disabled', p_product_id + USING ERRCODE = 'C0001'; + END IF; + + IF v_product_type != 'configurable' THEN + RAISE EXCEPTION 'Can only add customization options to configurable products' + USING ERRCODE = 'C0002', + HINT = 'Product type is: ' || v_product_type; + END IF; + + -- Validate customization option exists + SELECT EXISTS ( + SELECT 1 FROM customization_options + WHERE customization_option_id = p_customization_option_id + AND is_enabled = TRUE + ) INTO v_option_exists; + + IF NOT v_option_exists THEN + RAISE EXCEPTION 'Customization option ID % not found or is disabled', p_customization_option_id + USING ERRCODE = 'C0003'; + END IF; + + -- Insert the association + INSERT INTO product_customization_options ( + product_id, + customization_option_id, + is_required, + display_order + ) VALUES ( + p_product_id, + p_customization_option_id, + p_is_required, + p_display_order + ) ON CONFLICT (product_id, customization_option_id) DO UPDATE SET + is_required = EXCLUDED.is_required, + display_order = EXCLUDED.display_order; + + RAISE NOTICE 'Added customization option % to product %', p_customization_option_id, p_product_id; + + RETURN TRUE; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error adding customization option: % - %', SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/add_product_variant.sql b/database/functions/products/add_product_variant.sql new file mode 100644 index 0000000..0debd3a --- /dev/null +++ b/database/functions/products/add_product_variant.sql @@ -0,0 +1,100 @@ +CREATE OR REPLACE FUNCTION add_product_variant( + p_product_id INTEGER, + p_variant_name TEXT, + p_size TEXT DEFAULT NULL, + p_quantity INTEGER DEFAULT NULL, + p_serving_size TEXT DEFAULT NULL, + p_price_override NUMERIC(10, 2) DEFAULT NULL, + p_is_default BOOLEAN DEFAULT FALSE, + p_display_order INTEGER DEFAULT 0 +) RETURNS INTEGER AS $$ +DECLARE + v_variant_id INTEGER; + v_product_type PRODUCT_TYPE; + v_product_exists BOOLEAN; +BEGIN + -- Validate product exists and is variant-based + SELECT product_type INTO v_product_type + FROM products + WHERE product_id = p_product_id + AND is_enabled = TRUE; + + IF v_product_type IS NULL THEN + RAISE EXCEPTION 'Product ID % not found or is disabled', p_product_id + USING ERRCODE = 'V0001'; + END IF; + + IF v_product_type != 'variant_based' THEN + RAISE EXCEPTION 'Can only add variants to variant_based products' + USING ERRCODE = 'V0002', + HINT = 'Product type is: ' || v_product_type; + END IF; + + -- Validate required fields + IF p_variant_name IS NULL OR TRIM(p_variant_name) = '' THEN + RAISE EXCEPTION 'Variant name cannot be empty' + USING ERRCODE = 'V0003'; + END IF; + + -- Validate quantity if provided + IF p_quantity IS NOT NULL AND p_quantity <= 0 THEN + RAISE EXCEPTION 'Quantity must be positive' + USING ERRCODE = 'V0004'; + END IF; + + -- Validate price override if provided + IF p_price_override IS NOT NULL AND p_price_override < 0 THEN + RAISE EXCEPTION 'Price override cannot be negative' + USING ERRCODE = 'V0005'; + END IF; + + -- Check for duplicate variant name within product + IF EXISTS ( + SELECT 1 FROM product_variants + WHERE product_id = p_product_id + AND LOWER(variant_name) = LOWER(TRIM(p_variant_name)) + ) THEN + RAISE EXCEPTION 'A variant with this name already exists for this product' + USING ERRCODE = 'V0006'; + END IF; + + -- If setting as default, unset other defaults + IF p_is_default THEN + UPDATE product_variants + SET is_default = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE product_id = p_product_id AND is_default = TRUE; + END IF; + + -- Insert variant + INSERT INTO product_variants ( + product_id, + variant_name, + size, + quantity, + serving_size, + price_override, + is_default, + display_order, + is_test + ) VALUES ( + p_product_id, + TRIM(p_variant_name), + p_size, + p_quantity, + p_serving_size, + p_price_override, + p_is_default, + p_display_order, + FALSE -- Not a test variant + ) RETURNING product_variant_id INTO v_variant_id; + + RAISE NOTICE 'Added variant ID % to product ID %', v_variant_id, p_product_id; + + RETURN v_variant_id; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error adding variant to product %: % - %', p_product_id, SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/update_product_status.sql b/database/functions/products/update_product_status.sql new file mode 100644 index 0000000..dd96c1f --- /dev/null +++ b/database/functions/products/update_product_status.sql @@ -0,0 +1,44 @@ +CREATE OR REPLACE FUNCTION update_product_status( + p_product_id INTEGER, + p_is_enabled BOOLEAN, + p_updated_by TEXT DEFAULT 'system' +) RETURNS BOOLEAN AS $$ +DECLARE + v_current_status BOOLEAN; + v_product_name TEXT; +BEGIN + -- Check if product exists and get current status + SELECT is_enabled, product_name + INTO v_current_status, v_product_name + FROM products + WHERE product_id = p_product_id; + + IF v_current_status IS NULL THEN + RAISE EXCEPTION 'Product with ID % not found', p_product_id + USING ERRCODE = 'P0021'; + END IF; + + -- Check if status change is needed + IF v_current_status = p_is_enabled THEN + RAISE NOTICE 'Product "%" already has status: %', v_product_name, + CASE WHEN p_is_enabled THEN 'enabled' ELSE 'disabled' END; + RETURN TRUE; + END IF; + + -- Update product status + UPDATE products + SET is_enabled = p_is_enabled, + updated_at = CURRENT_TIMESTAMP + WHERE product_id = p_product_id; + + RAISE NOTICE 'Product "%" status changed to: %', v_product_name, + CASE WHEN p_is_enabled THEN 'enabled' ELSE 'disabled' END; + + RETURN TRUE; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error updating product status: % - %', SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; From 79c5463bc8bcd1b7be758477eb8bf27eba19a9c5 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 26 Aug 2025 02:23:35 +0000 Subject: [PATCH 07/10] Enhance product-related functions with detailed data retrieval and pagination support --- .../get_customization_option_values.sql | 31 ++++ .../products/get_product_assigned_tags.sql | 33 ++++ .../products/get_product_attributes.sql | 26 +++ .../get_product_customization_options.sql | 34 ++++ .../products/get_product_details.sql | 160 +++++------------- .../functions/products/get_product_images.sql | 28 +++ .../products/get_product_variants.sql | 35 ++++ .../products/get_products_paginated.sql | 116 +++++++------ .../functions/products/search_products.sql | 138 +++++++++------ 9 files changed, 375 insertions(+), 226 deletions(-) create mode 100644 database/functions/products/get_customization_option_values.sql create mode 100644 database/functions/products/get_product_assigned_tags.sql create mode 100644 database/functions/products/get_product_attributes.sql create mode 100644 database/functions/products/get_product_customization_options.sql create mode 100644 database/functions/products/get_product_images.sql create mode 100644 database/functions/products/get_product_variants.sql diff --git a/database/functions/products/get_customization_option_values.sql b/database/functions/products/get_customization_option_values.sql new file mode 100644 index 0000000..35632fc --- /dev/null +++ b/database/functions/products/get_customization_option_values.sql @@ -0,0 +1,31 @@ +CREATE OR REPLACE FUNCTION get_customization_option_values( + p_customization_option_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + value_id INTEGER, + customization_option_id INTEGER, + value_name TEXT, + price_modifier NUMERIC(10, 2), + is_default BOOLEAN, + display_order INTEGER, + is_enabled BOOLEAN +) AS $$ +BEGIN + RETURN QUERY + SELECT + cov.option_value_id as value_id, + cov.customization_option_id, + COALESCE(covt.value_name, cov.value_name) as value_name, + cov.price_modifier, + cov.is_default, + cov.display_order, + cov.is_enabled + FROM customization_option_values cov + LEFT JOIN customization_option_value_translations covt + ON cov.option_value_id = covt.option_value_id + AND covt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE cov.customization_option_id = p_customization_option_id + AND cov.is_enabled = TRUE + ORDER BY cov.display_order, cov.value_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_product_assigned_tags.sql b/database/functions/products/get_product_assigned_tags.sql new file mode 100644 index 0000000..49cad3c --- /dev/null +++ b/database/functions/products/get_product_assigned_tags.sql @@ -0,0 +1,33 @@ +CREATE OR REPLACE FUNCTION get_product_assigned_tags( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + tag_id INTEGER, + product_id INTEGER, + tag_name TEXT, + tag_color TEXT, + tag_description TEXT, + display_order INTEGER, + assigned_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pt.product_tag_id as tag_id, + pta.product_id, + COALESCE(ptt.tag_name, pt.tag_name) as tag_name, + pt.tag_color::TEXT, + COALESCE(ptt.tag_description, pt.tag_description) as tag_description, + pt.display_order, + pta.created_at as assigned_at + FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + JOIN products p ON pta.product_id = p.product_id + WHERE pta.product_id = p_product_id + AND pt.is_enabled = TRUE + AND p.is_enabled = TRUE + ORDER BY pt.display_order, pt.tag_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_product_attributes.sql b/database/functions/products/get_product_attributes.sql new file mode 100644 index 0000000..fc8499a --- /dev/null +++ b/database/functions/products/get_product_attributes.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION get_product_attributes( + p_product_id INTEGER +) RETURNS TABLE ( + attribute_id INTEGER, + product_id INTEGER, + attribute_name TEXT, + attribute_value TEXT, + attribute_color TEXT, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pa.product_attribute_id as attribute_id, + pa.product_id, + pa.attribute_name, + pa.attribute_value, + pa.attribute_color::TEXT, + pa.created_at + FROM product_attributes pa + JOIN products p ON pa.product_id = p.product_id + WHERE pa.product_id = p_product_id + AND p.is_enabled = TRUE + ORDER BY pa.attribute_name, pa.attribute_value; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_product_customization_options.sql b/database/functions/products/get_product_customization_options.sql new file mode 100644 index 0000000..eac023e --- /dev/null +++ b/database/functions/products/get_product_customization_options.sql @@ -0,0 +1,34 @@ +CREATE OR REPLACE FUNCTION get_product_customization_options( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + option_id INTEGER, + product_id INTEGER, + option_name TEXT, + option_type TEXT, + is_required BOOLEAN, + display_order INTEGER, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + co.customization_option_id as option_id, + pco.product_id, + COALESCE(cot.option_name, co.option_name) as option_name, + co.option_type, + COALESCE(pco.is_required, co.is_required) as is_required, + COALESCE(pco.display_order, co.display_order) as display_order, + pco.created_at + FROM product_customization_options pco + JOIN customization_options co ON pco.customization_option_id = co.customization_option_id + LEFT JOIN customization_option_translations cot + ON co.customization_option_id = cot.customization_option_id + AND cot.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + JOIN products p ON pco.product_id = p.product_id + WHERE pco.product_id = p_product_id + AND co.is_enabled = TRUE + AND p.is_enabled = TRUE + ORDER BY COALESCE(pco.display_order, co.display_order), co.option_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_product_details.sql b/database/functions/products/get_product_details.sql index df36676..b80f91a 100644 --- a/database/functions/products/get_product_details.sql +++ b/database/functions/products/get_product_details.sql @@ -2,7 +2,7 @@ CREATE OR REPLACE FUNCTION get_product_details( p_product_id INTEGER, p_language_iso CHAR(2) DEFAULT 'en' ) RETURNS TABLE ( - -- Product data + -- Core product data product_id INTEGER, product_name TEXT, product_description TEXT, @@ -15,13 +15,24 @@ CREATE OR REPLACE FUNCTION get_product_details( serving_info TEXT, is_customizable BOOLEAN, created_at TIMESTAMPTZ, - -- Related data - category JSONB, - variants JSONB, - customization_options JSONB, - attributes JSONB, - tags JSONB, - images JSONB + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + category_description TEXT, + + -- Variant data + has_variants BOOLEAN, + default_variant_id INTEGER, + + -- Customization data + has_customization_options BOOLEAN, + + -- Counts for related data + attribute_count INTEGER, + tag_count INTEGER, + image_count INTEGER ) AS $$ BEGIN RETURN QUERY @@ -38,116 +49,29 @@ BEGIN p.serving_info, p.is_customizable, p.created_at, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT, - 'description', COALESCE(ct.category_description, pc.category_description) - ) - ELSE NULL END as category, - -- Variants - CASE - WHEN p.product_type = 'variant_based' THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'variant_id', pv.product_variant_id, - 'name', pv.variant_name, - 'quantity', pv.quantity, - 'price', COALESCE(pv.price_override, p.price), - 'serving_size', pv.serving_size, - 'is_default', pv.is_default, - 'size', pv.size - ) ORDER BY pv.display_order - ) FROM product_variants pv - WHERE pv.product_id = p.product_id AND pv.is_test = FALSE) - ELSE NULL - END as variants, - -- Customization options for configurable products - CASE - WHEN p.product_type = 'configurable' THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'option_id', co.customization_option_id, - 'name', COALESCE(cot.option_name, co.option_name), - 'type', co.option_type, - 'is_required', COALESCE(pco.is_required, co.is_required), - 'values', CASE - WHEN co.option_type IN ('single_select', 'multi_select') THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'value_id', cov.option_value_id, - 'name', COALESCE(covt.value_name, cov.value_name), - 'price_modifier', cov.price_modifier, - 'is_default', cov.is_default - ) ORDER BY cov.display_order - ) FROM customization_option_values cov - LEFT JOIN customization_option_value_translations covt - ON cov.option_value_id = covt.option_value_id - AND covt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE cov.customization_option_id = co.customization_option_id - AND cov.is_enabled = TRUE) - ELSE NULL - END - ) ORDER BY COALESCE(pco.display_order, co.display_order) - ) FROM product_customization_options pco - JOIN customization_options co ON pco.customization_option_id = co.customization_option_id - LEFT JOIN customization_option_translations cot - ON co.customization_option_id = cot.customization_option_id - AND cot.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pco.product_id = p.product_id AND co.is_enabled = TRUE) - ELSE NULL - END as customization_options, - -- Product attributes (fixed - no nested aggregates) - (SELECT - CASE - WHEN COUNT(*) > 0 THEN - jsonb_object_agg( - attr_grouped.attribute_name, - attr_grouped.values - ) - ELSE NULL - END - FROM ( - SELECT - pa.attribute_name, - jsonb_agg( - jsonb_build_object( - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) as values - FROM product_attributes pa - WHERE pa.product_id = p.product_id - GROUP BY pa.attribute_name - ) attr_grouped - ) as attributes, - -- Product tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt.tag_name), - 'color', pt.tag_color::TEXT, - 'description', COALESCE(ptt.tag_description, pt.tag_description) - ) ORDER BY pt.display_order - ) FROM product_tag_assignments pta - JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE - ) as tags, - -- Product images - (SELECT jsonb_agg( - jsonb_build_object( - 'image_id', pi.product_image_id, - 'url', pi.image_url, - 'is_primary', pi.is_primary, - 'variant_id', pi.variant_id - ) ORDER BY pi.is_primary DESC, pi.created_at - ) FROM product_images pi - WHERE pi.product_id = p.product_id - ) as images + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + COALESCE(ct.category_description, pc.category_description) as category_description, + + -- Variant indicators + (p.product_type = 'variant_based') as has_variants, + (SELECT product_variant_id FROM product_variants + WHERE product_id = p.product_id AND is_default = TRUE AND is_test = FALSE + LIMIT 1) as default_variant_id, + + -- Customization indicator + (p.product_type = 'configurable') as has_customization_options, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + (SELECT COUNT(*)::INTEGER FROM product_images pi WHERE pi.product_id = p.product_id) as image_count + FROM products p LEFT JOIN product_categories pc ON p.category_id = pc.category_id LEFT JOIN category_translations ct ON pc.category_id = ct.category_id diff --git a/database/functions/products/get_product_images.sql b/database/functions/products/get_product_images.sql new file mode 100644 index 0000000..207e828 --- /dev/null +++ b/database/functions/products/get_product_images.sql @@ -0,0 +1,28 @@ +CREATE OR REPLACE FUNCTION get_product_images( + p_product_id INTEGER, + p_variant_id INTEGER DEFAULT NULL +) RETURNS TABLE ( + image_id INTEGER, + product_id INTEGER, + variant_id INTEGER, + image_url TEXT, + is_primary BOOLEAN, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pi.product_image_id as image_id, + pi.product_id, + pi.variant_id, + pi.image_url, + pi.is_primary, + pi.created_at + FROM product_images pi + JOIN products p ON pi.product_id = p.product_id + WHERE pi.product_id = p_product_id + AND (p_variant_id IS NULL OR pi.variant_id = p_variant_id OR pi.variant_id IS NULL) + AND p.is_enabled = TRUE + ORDER BY pi.is_primary DESC, pi.created_at; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_product_variants.sql b/database/functions/products/get_product_variants.sql new file mode 100644 index 0000000..ff53fd7 --- /dev/null +++ b/database/functions/products/get_product_variants.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE FUNCTION get_product_variants( + p_product_id INTEGER +) RETURNS TABLE ( + variant_id INTEGER, + product_id INTEGER, + variant_name TEXT, + size TEXT, + quantity INTEGER, + serving_size TEXT, + price_override NUMERIC(10, 2), + final_price NUMERIC(10, 2), + is_default BOOLEAN, + display_order INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + pv.product_variant_id as variant_id, + pv.product_id, + pv.variant_name, + pv.size, + pv.quantity, + pv.serving_size, + pv.price_override, + COALESCE(pv.price_override, p.price) as final_price, + pv.is_default, + pv.display_order + FROM product_variants pv + JOIN products p ON pv.product_id = p.product_id + WHERE pv.product_id = p_product_id + AND pv.is_test = FALSE + AND p.is_enabled = TRUE + ORDER BY pv.display_order, pv.product_variant_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_products_paginated.sql b/database/functions/products/get_products_paginated.sql index 2b21ecd..d688de0 100644 --- a/database/functions/products/get_products_paginated.sql +++ b/database/functions/products/get_products_paginated.sql @@ -8,6 +8,7 @@ CREATE OR REPLACE FUNCTION get_products_paginated( p_tag_filter INTEGER [] DEFAULT NULL, p_attribute_filter JSONB DEFAULT NULL ) RETURNS TABLE ( + -- Core product data product_id INTEGER, product_name TEXT, product_description TEXT, @@ -20,14 +21,36 @@ CREATE OR REPLACE FUNCTION get_products_paginated( serving_info TEXT, is_customizable BOOLEAN, created_at TIMESTAMPTZ, - category JSONB, - variants JSONB, - attributes JSONB, - tags JSONB, + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + category_description TEXT, + + -- Variant indicators + has_variants BOOLEAN, + default_variant_id INTEGER, + variant_count INTEGER, + + -- Related data counts + attribute_count INTEGER, + tag_count INTEGER, + image_count INTEGER, + + -- Pagination info pagination PAGINATION_INFO ) AS $$ +DECLARE + v_total_count BIGINT; + v_offset INTEGER := (p_page - 1) * p_size; BEGIN - -- No complex filtering for now + -- Get total count for pagination + SELECT COUNT(*) INTO v_total_count + FROM products p + WHERE p.is_enabled = TRUE + AND (p_category_filter IS NULL OR p.category_id = p_category_filter); + RETURN QUERY SELECT p.product_id, @@ -42,59 +65,46 @@ BEGIN p.serving_info, p.is_customizable, p.created_at, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT, - 'description', COALESCE(ct.category_description, pc.category_description) - ) - ELSE NULL END as category, - -- Variants - (SELECT jsonb_agg( - jsonb_build_object( - 'variant_id', pv.product_variant_id, - 'name', pv.variant_name, - 'quantity', pv.quantity, - 'price', COALESCE(pv.price_override, p.price), - 'serving_size', pv.serving_size, - 'is_default', pv.is_default - ) ORDER BY pv.display_order - ) FROM product_variants pv - WHERE pv.product_id = p.product_id AND pv.is_test = FALSE - ) as variants, - -- Attributes (simplified - no nested aggregates) - (SELECT jsonb_agg( - jsonb_build_object( - 'name', pa.attribute_name, - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) FROM product_attributes pa WHERE pa.product_id = p.product_id - ) as attributes, - -- Tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt.tag_name), - 'color', pt.tag_color::TEXT, - 'description', COALESCE(ptt.tag_description, pt.tag_description) - ) ORDER BY pt.display_order - ) FROM product_tag_assignments pta - JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE - ) as tags, - get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM products WHERE is_enabled = TRUE)::BIGINT) as pagination + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + COALESCE(ct.category_description, pc.category_description) as category_description, + + -- Variant indicators + (p.product_type = 'variant_based') as has_variants, + (SELECT product_variant_id FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_default = TRUE AND pv.is_test = FALSE + LIMIT 1) as default_variant_id, + (SELECT COUNT(*)::INTEGER FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_test = FALSE) as variant_count, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + (SELECT COUNT(*)::INTEGER FROM product_images pi WHERE pi.product_id = p.product_id) as image_count, + + -- Pagination info + get_pagination_info(p_page, p_size, v_total_count) as pagination + FROM products p LEFT JOIN product_categories pc ON p.category_id = pc.category_id LEFT JOIN category_translations ct ON pc.category_id = ct.category_id AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true WHERE p.is_enabled = TRUE - ORDER BY p.created_at DESC - LIMIT p_size OFFSET (p_page - 1) * p_size; + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + ORDER BY + CASE WHEN p_sort_by = 'created_at' AND p_sort_order = 'DESC' THEN p.created_at END DESC, + CASE WHEN p_sort_by = 'created_at' AND p_sort_order = 'ASC' THEN p.created_at END ASC, + CASE WHEN p_sort_by = 'price' AND p_sort_order = 'DESC' THEN COALESCE(p.price, p.base_price) END DESC, + CASE WHEN p_sort_by = 'price' AND p_sort_order = 'ASC' THEN COALESCE(p.price, p.base_price) END ASC, + CASE WHEN p_sort_by = 'name' AND p_sort_order = 'DESC' THEN COALESCE(tr.product_name, p.product_name) END DESC, + CASE WHEN p_sort_by = 'name' AND p_sort_order = 'ASC' THEN COALESCE(tr.product_name, p.product_name) END ASC, + p.product_id + LIMIT p_size OFFSET v_offset; END; $$ LANGUAGE plpgsql; diff --git a/database/functions/products/search_products.sql b/database/functions/products/search_products.sql index 47e8746..72f77da 100644 --- a/database/functions/products/search_products.sql +++ b/database/functions/products/search_products.sql @@ -7,6 +7,7 @@ CREATE OR REPLACE FUNCTION search_products( p_tag_filter INTEGER [] DEFAULT NULL, p_attribute_filter JSONB DEFAULT NULL ) RETURNS TABLE ( + -- Core product data product_id INTEGER, product_name TEXT, product_description TEXT, @@ -14,27 +15,70 @@ CREATE OR REPLACE FUNCTION search_products( price NUMERIC(10, 2), base_price NUMERIC(10, 2), image_url TEXT, - category JSONB, - attributes JSONB, - tags JSONB, - rank REAL, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, + created_at TIMESTAMPTZ, + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + + -- Search relevance + search_rank REAL, + + -- Related data counts + attribute_count INTEGER, + tag_count INTEGER, + + -- Pagination info pagination PAGINATION_INFO ) AS $$ +DECLARE + v_total_count BIGINT; + v_offset INTEGER := (p_page - 1) * p_size; + v_query tsquery; BEGIN - RETURN QUERY - WITH search_query AS ( - SELECT websearch_to_tsquery('english', p_search_term) as query - ), - filtered_products AS ( + -- Prepare search query + v_query := websearch_to_tsquery('english', p_search_term); + + -- Get total count for pagination + WITH search_results AS ( SELECT DISTINCT p.product_id FROM products p LEFT JOIN product_translations pt ON p.product_id = pt.product_id AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - CROSS JOIN search_query sq WHERE p.is_enabled = TRUE AND ( - to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ sq.query - OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ sq.query + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ v_query + OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ v_query + ) + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + AND (p_tag_filter IS NULL OR EXISTS ( + SELECT 1 FROM product_tag_assignments pta + WHERE pta.product_id = p.product_id + AND pta.product_tag_id = ANY(p_tag_filter) + )) + ) + SELECT COUNT(*) INTO v_total_count FROM search_results; + + RETURN QUERY + WITH search_results AS ( + SELECT + p.product_id, + ts_rank_cd( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')), + v_query + ) as rank + FROM products p + LEFT JOIN product_translations pt ON p.product_id = pt.product_id + AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE p.is_enabled = TRUE + AND ( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ v_query + OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ v_query ) AND (p_category_filter IS NULL OR p.category_id = p_category_filter) AND (p_tag_filter IS NULL OR EXISTS ( @@ -42,13 +86,6 @@ BEGIN WHERE pta.product_id = p.product_id AND pta.product_tag_id = ANY(p_tag_filter) )) - ), - paginated_results AS ( - SELECT fp.product_id, - ROW_NUMBER() OVER (ORDER BY p.created_at DESC) as rn - FROM filtered_products fp - JOIN products p ON fp.product_id = p.product_id - LIMIT p_size OFFSET (p_page - 1) * p_size ) SELECT p.product_id, @@ -58,45 +95,36 @@ BEGIN p.price, p.base_price, p.image_url, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT - ) - ELSE NULL END as category, - -- Attributes (simplified approach - no nested aggregates) - (SELECT jsonb_agg( - jsonb_build_object( - 'name', pa.attribute_name, - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) FROM product_attributes pa WHERE pa.product_id = p.product_id - ) as attributes, - -- Tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt2.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt2.tag_name), - 'color', pt2.tag_color::TEXT - ) - ) FROM product_tag_assignments pta - JOIN product_tags pt2 ON pta.product_tag_id = pt2.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt2.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt2.is_enabled = TRUE - ) as tags, - -- Search ranking - 0.5::REAL as rank, -- Simplified ranking - get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM filtered_products)::BIGINT) as pagination - FROM paginated_results pr - JOIN products p ON pr.product_id = p.product_id + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + + -- Search relevance + sr.rank as search_rank, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + + -- Pagination info + get_pagination_info(p_page, p_size, v_total_count) as pagination + + FROM search_results sr + JOIN products p ON sr.product_id = p.product_id LEFT JOIN product_categories pc ON p.category_id = pc.category_id LEFT JOIN category_translations ct ON pc.category_id = ct.category_id AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true - ORDER BY pr.rn; + ORDER BY sr.rank DESC, p.created_at DESC + LIMIT p_size OFFSET v_offset; END; $$ LANGUAGE plpgsql; From 9182a57ca583f3a609d7255c7de3508f7c2d5a7f Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 26 Aug 2025 02:30:45 +0000 Subject: [PATCH 08/10] Implement functions for adding and retrieving product customization options --- .../get_customization_option_values.sql | 0 .../add_product_customization_option.sql | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename database/functions/{products => customization_options}/get_customization_option_values.sql (100%) rename database/functions/{products => product_customization}/add_product_customization_option.sql (100%) diff --git a/database/functions/products/get_customization_option_values.sql b/database/functions/customization_options/get_customization_option_values.sql similarity index 100% rename from database/functions/products/get_customization_option_values.sql rename to database/functions/customization_options/get_customization_option_values.sql diff --git a/database/functions/products/add_product_customization_option.sql b/database/functions/product_customization/add_product_customization_option.sql similarity index 100% rename from database/functions/products/add_product_customization_option.sql rename to database/functions/product_customization/add_product_customization_option.sql From 58c1cb0054afc6d2d8ec155d37ed1b3db012153a Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Tue, 26 Aug 2025 02:35:42 +0000 Subject: [PATCH 09/10] Rename product management functions files --- .../{get_customization_option_values.sql => get_values.sql} | 0 .../pagination/{get_pagination_info.sql => get_info.sql} | 0 .../add_option.sql} | 0 .../get_options.sql} | 0 .../get_product_variants.sql => product_variants/get_all.sql} | 0 database/functions/products/{add_new_product.sql => add_new.sql} | 0 .../products/{add_product_variant.sql => add_variant.sql} | 0 .../products/{calculate_product_price.sql => calculate_price.sql} | 0 .../{get_product_assigned_tags.sql => get_assigned_tags.sql} | 0 .../products/{get_product_attributes.sql => get_attributes.sql} | 0 ...ct_attributes_for_filter.sql => get_attributes_for_filter.sql} | 0 .../products/{get_product_categories.sql => get_categories.sql} | 0 .../products/{get_product_details.sql => get_details.sql} | 0 .../functions/products/{get_product_images.sql => get_images.sql} | 0 .../products/{get_products_paginated.sql => get_paginated.sql} | 0 .../functions/products/{get_product_tags.sql => get_tags.sql} | 0 database/functions/products/{search_products.sql => search.sql} | 0 .../products/{update_product_status.sql => update_status.sql} | 0 .../{get_product_translation.sql => get_translation.sql} | 0 19 files changed, 0 insertions(+), 0 deletions(-) rename database/functions/customization_options/{get_customization_option_values.sql => get_values.sql} (100%) rename database/functions/pagination/{get_pagination_info.sql => get_info.sql} (100%) rename database/functions/{product_customization/add_product_customization_option.sql => product_customizations/add_option.sql} (100%) rename database/functions/{products/get_product_customization_options.sql => product_customizations/get_options.sql} (100%) rename database/functions/{products/get_product_variants.sql => product_variants/get_all.sql} (100%) rename database/functions/products/{add_new_product.sql => add_new.sql} (100%) rename database/functions/products/{add_product_variant.sql => add_variant.sql} (100%) rename database/functions/products/{calculate_product_price.sql => calculate_price.sql} (100%) rename database/functions/products/{get_product_assigned_tags.sql => get_assigned_tags.sql} (100%) rename database/functions/products/{get_product_attributes.sql => get_attributes.sql} (100%) rename database/functions/products/{get_product_attributes_for_filter.sql => get_attributes_for_filter.sql} (100%) rename database/functions/products/{get_product_categories.sql => get_categories.sql} (100%) rename database/functions/products/{get_product_details.sql => get_details.sql} (100%) rename database/functions/products/{get_product_images.sql => get_images.sql} (100%) rename database/functions/products/{get_products_paginated.sql => get_paginated.sql} (100%) rename database/functions/products/{get_product_tags.sql => get_tags.sql} (100%) rename database/functions/products/{search_products.sql => search.sql} (100%) rename database/functions/products/{update_product_status.sql => update_status.sql} (100%) rename database/functions/translations/{get_product_translation.sql => get_translation.sql} (100%) diff --git a/database/functions/customization_options/get_customization_option_values.sql b/database/functions/customization_options/get_values.sql similarity index 100% rename from database/functions/customization_options/get_customization_option_values.sql rename to database/functions/customization_options/get_values.sql diff --git a/database/functions/pagination/get_pagination_info.sql b/database/functions/pagination/get_info.sql similarity index 100% rename from database/functions/pagination/get_pagination_info.sql rename to database/functions/pagination/get_info.sql diff --git a/database/functions/product_customization/add_product_customization_option.sql b/database/functions/product_customizations/add_option.sql similarity index 100% rename from database/functions/product_customization/add_product_customization_option.sql rename to database/functions/product_customizations/add_option.sql diff --git a/database/functions/products/get_product_customization_options.sql b/database/functions/product_customizations/get_options.sql similarity index 100% rename from database/functions/products/get_product_customization_options.sql rename to database/functions/product_customizations/get_options.sql diff --git a/database/functions/products/get_product_variants.sql b/database/functions/product_variants/get_all.sql similarity index 100% rename from database/functions/products/get_product_variants.sql rename to database/functions/product_variants/get_all.sql diff --git a/database/functions/products/add_new_product.sql b/database/functions/products/add_new.sql similarity index 100% rename from database/functions/products/add_new_product.sql rename to database/functions/products/add_new.sql diff --git a/database/functions/products/add_product_variant.sql b/database/functions/products/add_variant.sql similarity index 100% rename from database/functions/products/add_product_variant.sql rename to database/functions/products/add_variant.sql diff --git a/database/functions/products/calculate_product_price.sql b/database/functions/products/calculate_price.sql similarity index 100% rename from database/functions/products/calculate_product_price.sql rename to database/functions/products/calculate_price.sql diff --git a/database/functions/products/get_product_assigned_tags.sql b/database/functions/products/get_assigned_tags.sql similarity index 100% rename from database/functions/products/get_product_assigned_tags.sql rename to database/functions/products/get_assigned_tags.sql diff --git a/database/functions/products/get_product_attributes.sql b/database/functions/products/get_attributes.sql similarity index 100% rename from database/functions/products/get_product_attributes.sql rename to database/functions/products/get_attributes.sql diff --git a/database/functions/products/get_product_attributes_for_filter.sql b/database/functions/products/get_attributes_for_filter.sql similarity index 100% rename from database/functions/products/get_product_attributes_for_filter.sql rename to database/functions/products/get_attributes_for_filter.sql diff --git a/database/functions/products/get_product_categories.sql b/database/functions/products/get_categories.sql similarity index 100% rename from database/functions/products/get_product_categories.sql rename to database/functions/products/get_categories.sql diff --git a/database/functions/products/get_product_details.sql b/database/functions/products/get_details.sql similarity index 100% rename from database/functions/products/get_product_details.sql rename to database/functions/products/get_details.sql diff --git a/database/functions/products/get_product_images.sql b/database/functions/products/get_images.sql similarity index 100% rename from database/functions/products/get_product_images.sql rename to database/functions/products/get_images.sql diff --git a/database/functions/products/get_products_paginated.sql b/database/functions/products/get_paginated.sql similarity index 100% rename from database/functions/products/get_products_paginated.sql rename to database/functions/products/get_paginated.sql diff --git a/database/functions/products/get_product_tags.sql b/database/functions/products/get_tags.sql similarity index 100% rename from database/functions/products/get_product_tags.sql rename to database/functions/products/get_tags.sql diff --git a/database/functions/products/search_products.sql b/database/functions/products/search.sql similarity index 100% rename from database/functions/products/search_products.sql rename to database/functions/products/search.sql diff --git a/database/functions/products/update_product_status.sql b/database/functions/products/update_status.sql similarity index 100% rename from database/functions/products/update_product_status.sql rename to database/functions/products/update_status.sql diff --git a/database/functions/translations/get_product_translation.sql b/database/functions/translations/get_translation.sql similarity index 100% rename from database/functions/translations/get_product_translation.sql rename to database/functions/translations/get_translation.sql From 0a5f7283b4f2f20d7754249894a445211e783610 Mon Sep 17 00:00:00 2001 From: Vianpyro Date: Thu, 28 Aug 2025 00:27:41 +0000 Subject: [PATCH 10/10] Refactor product attribute retrieval function to return individual attribute values and counts, enhancing data structure for filtering. --- .../products/get_attributes_for_filter.sql | 26 ++++++------------- database/functions/products/get_details.sql | 6 ++--- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/database/functions/products/get_attributes_for_filter.sql b/database/functions/products/get_attributes_for_filter.sql index dca1718..5332e68 100644 --- a/database/functions/products/get_attributes_for_filter.sql +++ b/database/functions/products/get_attributes_for_filter.sql @@ -2,32 +2,22 @@ CREATE OR REPLACE FUNCTION get_product_attributes_for_filter( p_category_id INTEGER DEFAULT NULL ) RETURNS TABLE ( attribute_name TEXT, - attribute_values JSONB + attribute_value TEXT, + attribute_color TEXT, + product_count INTEGER ) AS $$ BEGIN RETURN QUERY SELECT pa.attribute_name, - jsonb_agg( - DISTINCT jsonb_build_object( - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT, - 'count', ( - SELECT COUNT(DISTINCT p2.product_id) - FROM product_attributes pa2 - JOIN products p2 ON pa2.product_id = p2.product_id - WHERE pa2.attribute_name = pa.attribute_name - AND pa2.attribute_value = pa.attribute_value - AND p2.is_enabled = TRUE - AND (p_category_id IS NULL OR p2.category_id = p_category_id) - ) - ) - ) as attribute_values + pa.attribute_value, + pa.attribute_color::TEXT, + COUNT(DISTINCT p.product_id)::INTEGER as product_count FROM product_attributes pa JOIN products p ON pa.product_id = p.product_id WHERE p.is_enabled = TRUE AND (p_category_id IS NULL OR p.category_id = p_category_id) - GROUP BY pa.attribute_name - ORDER BY pa.attribute_name; + GROUP BY pa.attribute_name, pa.attribute_value, pa.attribute_color + ORDER BY pa.attribute_name, pa.attribute_value; END; $$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_details.sql b/database/functions/products/get_details.sql index b80f91a..9cd1ed6 100644 --- a/database/functions/products/get_details.sql +++ b/database/functions/products/get_details.sql @@ -58,9 +58,9 @@ BEGIN -- Variant indicators (p.product_type = 'variant_based') as has_variants, - (SELECT product_variant_id FROM product_variants - WHERE product_id = p.product_id AND is_default = TRUE AND is_test = FALSE - LIMIT 1) as default_variant_id, + (SELECT pv.product_variant_id FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_default = TRUE AND pv.is_test = FALSE + LIMIT 1) as default_variant_id, -- Customization indicator (p.product_type = 'configurable') as has_customization_options,