feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,27 @@
/**
* Categories module table. Module-owned naming: categories_<table>.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE categories_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id uuid REFERENCES categories_categories(id) ON DELETE RESTRICT,
name text NOT NULL,
slug text NOT NULL UNIQUE,
seo_title text,
seo_description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_categories_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id)
)
`);
pgm.sql('CREATE INDEX categories_categories_parent_id_idx ON categories_categories (parent_id)');
pgm.sql('CREATE INDEX categories_categories_slug_idx ON categories_categories (slug)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS categories_categories');
};

View File

@@ -0,0 +1,41 @@
/**
* Catalog core product tables. Module-owned naming: catalog_<table>.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE catalog_products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
description text,
state text NOT NULL DEFAULT 'draft',
seo_title text,
seo_description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT catalog_products_state_check CHECK (state IN ('draft', 'active', 'archived'))
)
`);
pgm.sql('CREATE INDEX catalog_products_state_idx ON catalog_products (state)');
pgm.sql('CREATE INDEX catalog_products_slug_idx ON catalog_products (slug)');
pgm.sql(`
CREATE TABLE catalog_product_categories (
product_id uuid NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
category_id uuid NOT NULL REFERENCES categories_categories(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (product_id, category_id)
)
`);
pgm.sql(
'CREATE INDEX catalog_product_categories_category_id_idx ON catalog_product_categories (category_id)',
);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS catalog_product_categories');
pgm.sql('DROP TABLE IF EXISTS catalog_products');
};

View File

@@ -0,0 +1,32 @@
/**
* Brands module table and catalog product-brand assignment.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE brands_brands (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
seo_title text,
seo_description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql('CREATE INDEX brands_brands_slug_idx ON brands_brands (slug)');
pgm.sql(`
ALTER TABLE catalog_products
ADD COLUMN brand_id uuid REFERENCES brands_brands(id) ON DELETE SET NULL
`);
pgm.sql('CREATE INDEX catalog_products_brand_id_idx ON catalog_products (brand_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP INDEX IF EXISTS catalog_products_brand_id_idx');
pgm.sql('ALTER TABLE catalog_products DROP COLUMN IF EXISTS brand_id');
pgm.sql('DROP TABLE IF EXISTS brands_brands');
};

View File

@@ -0,0 +1,45 @@
/**
* Catalog product variants and rich product data.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE catalog_product_variants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
sku text NOT NULL UNIQUE,
ean text UNIQUE,
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(
'CREATE INDEX catalog_product_variants_product_id_idx ON catalog_product_variants (product_id)',
);
pgm.sql(`
CREATE TABLE catalog_product_rich_data (
product_id uuid PRIMARY KEY REFERENCES catalog_products(id) ON DELETE CASCADE,
ingredients text,
allergens text[] NOT NULL DEFAULT ARRAY[]::text[],
nutrition jsonb,
nutrition_source text,
is_organic boolean NOT NULL DEFAULT false,
organic_certification text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT catalog_product_rich_data_nutrition_source_check
CHECK (nutrition_source IS NULL OR nutrition_source IN ('manual', 'manufacturer', 'openfoodfacts')),
CONSTRAINT catalog_product_rich_data_nutrition_source_required_check
CHECK (nutrition IS NULL OR nutrition_source IS NOT NULL)
)
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS catalog_product_rich_data');
pgm.sql('DROP TABLE IF EXISTS catalog_product_variants');
};

View File

@@ -0,0 +1,63 @@
/**
* Catalog product and variant images.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE catalog_product_images (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
variant_id uuid REFERENCES catalog_product_variants(id) ON DELETE CASCADE,
url text NOT NULL,
alt_text text NOT NULL,
position integer NOT NULL DEFAULT 0,
role text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT catalog_product_images_position_check CHECK (position >= 0),
CONSTRAINT catalog_product_images_role_check CHECK (role IN ('main', 'gallery'))
)
`);
pgm.sql(
'CREATE INDEX catalog_product_images_product_scope_idx ON catalog_product_images (product_id, variant_id, position)',
);
pgm.sql(`
CREATE UNIQUE INDEX catalog_product_images_product_main_unique
ON catalog_product_images (product_id)
WHERE variant_id IS NULL AND role = 'main'
`);
pgm.sql(`
CREATE UNIQUE INDEX catalog_product_images_variant_main_unique
ON catalog_product_images (variant_id)
WHERE variant_id IS NOT NULL AND role = 'main'
`);
pgm.sql(`
CREATE OR REPLACE FUNCTION catalog_product_image_variant_scope_check()
RETURNS trigger AS $$
BEGIN
IF NEW.variant_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM catalog_product_variants v
WHERE v.id = NEW.variant_id AND v.product_id = NEW.product_id
) THEN
RAISE EXCEPTION 'catalog product image variant must belong to product';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
pgm.sql(`
CREATE TRIGGER catalog_product_images_variant_scope_check
BEFORE INSERT OR UPDATE OF product_id, variant_id ON catalog_product_images
FOR EACH ROW EXECUTE FUNCTION catalog_product_image_variant_scope_check()
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql(
'DROP TRIGGER IF EXISTS catalog_product_images_variant_scope_check ON catalog_product_images',
);
pgm.sql('DROP FUNCTION IF EXISTS catalog_product_image_variant_scope_check');
pgm.sql('DROP TABLE IF EXISTS catalog_product_images');
};

View File

@@ -0,0 +1,47 @@
/**
* PostgreSQL full-text search support for catalog products.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE INDEX catalog_products_fts_idx
ON catalog_products USING gin (
(
setweight(to_tsvector('spanish', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(description, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_description, '')), 'C')
)
)
`);
pgm.sql(`
CREATE INDEX brands_brands_fts_idx
ON brands_brands USING gin (
(
setweight(to_tsvector('spanish', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(slug, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_description, '')), 'C')
)
)
`);
pgm.sql(`
CREATE INDEX categories_categories_fts_idx
ON categories_categories USING gin (
(
setweight(to_tsvector('spanish', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(slug, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(seo_description, '')), 'C')
)
)
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP INDEX IF EXISTS categories_categories_fts_idx');
pgm.sql('DROP INDEX IF EXISTS brands_brands_fts_idx');
pgm.sql('DROP INDEX IF EXISTS catalog_products_fts_idx');
};

View File

@@ -0,0 +1,44 @@
/**
* Inventory stock counters and movement audit.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE inventory_stock (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL UNIQUE,
available integer NOT NULL DEFAULT 0,
reserved integer NOT NULL DEFAULT 0,
sold integer NOT NULL DEFAULT 0,
incoming integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT inventory_stock_available_non_negative CHECK (available >= 0),
CONSTRAINT inventory_stock_reserved_non_negative CHECK (reserved >= 0),
CONSTRAINT inventory_stock_sold_non_negative CHECK (sold >= 0),
CONSTRAINT inventory_stock_incoming_non_negative CHECK (incoming >= 0)
)
`);
pgm.sql('CREATE INDEX inventory_stock_variant_id_idx ON inventory_stock (variant_id)');
pgm.sql(`
CREATE TABLE inventory_movements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL,
operation text NOT NULL,
quantity integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT inventory_movements_quantity_non_negative CHECK (quantity >= 0),
CONSTRAINT inventory_movements_operation_check
CHECK (operation IN ('reserve', 'release', 'confirm', 'set_available'))
)
`);
pgm.sql('CREATE INDEX inventory_movements_variant_id_idx ON inventory_movements (variant_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS inventory_movements');
pgm.sql('DROP TABLE IF EXISTS inventory_stock');
};

View File

@@ -0,0 +1,52 @@
/**
* Pricing variant prices and append-only price history.
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE pricing_variant_prices (
variant_id uuid PRIMARY KEY,
net_unit_amount_cents integer NOT NULL,
vat_rate text NOT NULL,
currency text NOT NULL DEFAULT 'EUR',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pricing_variant_prices_amount_non_negative CHECK (net_unit_amount_cents >= 0),
CONSTRAINT pricing_variant_prices_vat_rate_check CHECK (vat_rate IN ('general', 'reduced')),
CONSTRAINT pricing_variant_prices_currency_check CHECK (currency = 'EUR')
)
`);
pgm.sql(`
CREATE TABLE pricing_price_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL,
previous_net_unit_amount_cents integer,
previous_vat_rate text,
previous_currency text,
new_net_unit_amount_cents integer NOT NULL,
new_vat_rate text NOT NULL,
new_currency text NOT NULL DEFAULT 'EUR',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pricing_price_history_previous_amount_non_negative
CHECK (previous_net_unit_amount_cents IS NULL OR previous_net_unit_amount_cents >= 0),
CONSTRAINT pricing_price_history_new_amount_non_negative CHECK (new_net_unit_amount_cents >= 0),
CONSTRAINT pricing_price_history_previous_vat_rate_check
CHECK (previous_vat_rate IS NULL OR previous_vat_rate IN ('general', 'reduced')),
CONSTRAINT pricing_price_history_new_vat_rate_check CHECK (new_vat_rate IN ('general', 'reduced')),
CONSTRAINT pricing_price_history_previous_currency_check
CHECK (previous_currency IS NULL OR previous_currency = 'EUR'),
CONSTRAINT pricing_price_history_new_currency_check CHECK (new_currency = 'EUR')
)
`);
pgm.sql(
'CREATE INDEX pricing_price_history_variant_id_idx ON pricing_price_history (variant_id)',
);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS pricing_price_history');
pgm.sql('DROP TABLE IF EXISTS pricing_variant_prices');
};

View File

@@ -0,0 +1,34 @@
/** Cart persistence: product, variant and quantity only. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE cart_carts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(`
CREATE TABLE cart_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
cart_id uuid NOT NULL REFERENCES cart_carts(id) ON DELETE CASCADE,
product_id uuid NOT NULL,
variant_id uuid NOT NULL,
quantity integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT cart_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT cart_items_unique_variant_per_cart UNIQUE (cart_id, variant_id)
)
`);
pgm.sql('CREATE INDEX cart_items_cart_id_idx ON cart_items (cart_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS cart_items');
pgm.sql('DROP TABLE IF EXISTS cart_carts');
};

View File

@@ -0,0 +1,31 @@
/** Promotions and selected cart promo code. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE promotions_promotions (
code text PRIMARY KEY,
type text NOT NULL,
value integer NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
usage_limit integer,
usage_count integer NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT promotions_type_check CHECK (type IN ('percent', 'fixed_amount')),
CONSTRAINT promotions_value_positive CHECK (value > 0),
CONSTRAINT promotions_usage_limit_positive CHECK (usage_limit IS NULL OR usage_limit > 0),
CONSTRAINT promotions_usage_count_non_negative CHECK (usage_count >= 0),
CONSTRAINT promotions_window_check CHECK (ends_at > starts_at)
)
`);
pgm.sql('ALTER TABLE cart_carts ADD COLUMN promo_code text');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('ALTER TABLE cart_carts DROP COLUMN IF EXISTS promo_code');
pgm.sql('DROP TABLE IF EXISTS promotions_promotions');
};

View File

@@ -0,0 +1,39 @@
/** Shipping zones and methods. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE shipping_zones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
country text NOT NULL,
postal_code_prefix text,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql('CREATE INDEX shipping_zones_country_idx ON shipping_zones (country, active)');
pgm.sql(`
CREATE TABLE shipping_methods (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
zone_id uuid NOT NULL REFERENCES shipping_zones(id) ON DELETE CASCADE,
name text NOT NULL,
base_cost_cents integer NOT NULL,
free_shipping_threshold_cents integer,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT shipping_methods_base_cost_non_negative CHECK (base_cost_cents >= 0),
CONSTRAINT shipping_methods_threshold_non_negative CHECK (free_shipping_threshold_cents IS NULL OR free_shipping_threshold_cents >= 0)
)
`);
pgm.sql('CREATE INDEX shipping_methods_zone_id_idx ON shipping_methods (zone_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS shipping_methods');
pgm.sql('DROP TABLE IF EXISTS shipping_zones');
};

View File

@@ -0,0 +1,58 @@
/** Orders with item snapshots and explicit state machine. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE orders_orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
idempotency_key text UNIQUE,
state text NOT NULL DEFAULT 'PENDING',
currency text NOT NULL DEFAULT 'EUR',
subtotal_cents integer NOT NULL DEFAULT 0,
discount_cents integer NOT NULL DEFAULT 0,
tax_cents integer NOT NULL DEFAULT 0,
total_cents integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT orders_orders_state_check CHECK (state IN (
'PENDING','AWAITING_PAYMENT','PAID','PROCESSING','SHIPPED','DELIVERED',
'CANCELLED','REFUNDED','PARTIALLY_REFUNDED'
)),
CONSTRAINT orders_orders_currency_check CHECK (currency = 'EUR'),
CONSTRAINT orders_orders_subtotal_non_negative CHECK (subtotal_cents >= 0),
CONSTRAINT orders_orders_discount_non_negative CHECK (discount_cents >= 0),
CONSTRAINT orders_orders_tax_non_negative CHECK (tax_cents >= 0),
CONSTRAINT orders_orders_total_non_negative CHECK (total_cents >= 0)
)
`);
pgm.sql('CREATE INDEX orders_orders_user_id_idx ON orders_orders (user_id)');
pgm.sql(`
CREATE TABLE orders_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders_orders(id) ON DELETE CASCADE,
product_id uuid NOT NULL,
variant_id uuid NOT NULL,
sku text NOT NULL,
ean text,
name text NOT NULL,
unit_price_cents integer NOT NULL,
discount_cents integer NOT NULL DEFAULT 0,
tax_cents integer NOT NULL DEFAULT 0,
quantity integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT orders_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT orders_items_unit_price_non_negative CHECK (unit_price_cents >= 0),
CONSTRAINT orders_items_discount_non_negative CHECK (discount_cents >= 0),
CONSTRAINT orders_items_tax_non_negative CHECK (tax_cents >= 0)
)
`);
pgm.sql('CREATE INDEX orders_items_order_id_idx ON orders_items (order_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS orders_items');
pgm.sql('DROP TABLE IF EXISTS orders_orders');
};

View File

@@ -0,0 +1,29 @@
/** Payment transactions and idempotency. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE payments_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider text NOT NULL,
provider_event_id text NOT NULL,
provider_payment_id text,
order_id uuid,
amount_cents integer NOT NULL,
currency text NOT NULL DEFAULT 'EUR',
status text NOT NULL,
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT payments_transactions_amount_non_negative CHECK (amount_cents >= 0),
CONSTRAINT payments_transactions_status_check CHECK (status IN ('requires_payment','succeeded','failed','refunded','chargeback')),
CONSTRAINT payments_transactions_currency_check CHECK (currency = 'EUR'),
CONSTRAINT payments_transactions_provider_event_unique UNIQUE (provider, provider_event_id)
)
`);
pgm.sql('CREATE INDEX payments_transactions_order_id_idx ON payments_transactions (order_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS payments_transactions');
};

View File

@@ -0,0 +1,26 @@
/** Notification messages with idempotent dispatch. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE notifications_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text NOT NULL UNIQUE,
channel text NOT NULL DEFAULT 'email',
template text NOT NULL,
recipient text NOT NULL,
subject text NOT NULL,
body text NOT NULL,
status text NOT NULL DEFAULT 'sent',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notifications_messages_channel_check CHECK (channel = 'email'),
CONSTRAINT notifications_messages_status_check CHECK (status IN ('sent', 'failed', 'queued'))
)
`);
pgm.sql('CREATE INDEX notifications_messages_template_idx ON notifications_messages (template)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS notifications_messages');
};

View File

@@ -0,0 +1,31 @@
/** Verified-purchase reviews. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE reviews_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
product_id uuid NOT NULL,
order_id uuid NOT NULL,
order_item_id uuid NOT NULL UNIQUE,
rating integer NOT NULL,
title text NOT NULL,
body text NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT reviews_reviews_rating_check CHECK (rating BETWEEN 1 AND 5),
CONSTRAINT reviews_reviews_status_check CHECK (status IN ('pending','published','rejected'))
)
`);
pgm.sql(
'CREATE INDEX reviews_reviews_product_status_idx ON reviews_reviews (product_id, status)',
);
pgm.sql('CREATE INDEX reviews_reviews_user_id_idx ON reviews_reviews (user_id)');
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS reviews_reviews');
};

View File

@@ -0,0 +1,22 @@
/** CMS pages with slug routing and draft/publish states. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE cms_pages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
body text NOT NULL,
status text NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT cms_pages_status_check CHECK (status IN ('draft','published'))
)
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS cms_pages');
};

View File

@@ -0,0 +1,28 @@
/** Security audit log. */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE security_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id uuid,
action text NOT NULL,
target text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql('CREATE INDEX security_audit_log_actor_id_idx ON security_audit_log (actor_id)');
pgm.sql('CREATE INDEX security_audit_log_action_idx ON security_audit_log (action)');
pgm.sql(`
ALTER TABLE identity_users
ADD COLUMN mfa_enrolled boolean NOT NULL DEFAULT false
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('ALTER TABLE identity_users DROP COLUMN IF EXISTS mfa_enrolled');
pgm.sql('DROP TABLE IF EXISTS security_audit_log');
};

View File

@@ -0,0 +1,30 @@
/**
* Migration 022: Store settings key-value table.
* Stores dynamic store configuration editable from admin panel.
*/
exports.up = async (pgm) => {
await pgm.sql(`
CREATE TABLE IF NOT EXISTS store_settings (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES identity_users(id) ON DELETE SET NULL
);
-- Seed default settings
INSERT INTO store_settings (key, value) VALUES
('store_name', 'Mercado de Vida'),
('store_tagline', 'Productos naturales y ecológicos'),
('contact_email', 'info@mercadodevida.es'),
('contact_phone', ''),
('contact_address', ''),
('footer_text', '© 2026 Mercado de Vida. Todos los derechos reservados.'),
('facebook_url', ''),
('instagram_url', '')
ON CONFLICT (key) DO NOTHING;
`);
};
exports.down = async (pgm) => {
await pgm.sql(`DROP TABLE IF EXISTS store_settings;`);
};

View File

@@ -0,0 +1,27 @@
/**
* Migration 023: Tax rates configuration.
* Stores configurable tax rates per category.
*/
exports.up = async (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS tax_rates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(40) NOT NULL,
rate_percent NUMERIC(5,2) NOT NULL,
country VARCHAR(2) NOT NULL DEFAULT 'ES',
applies_to VARCHAR(20) NOT NULL DEFAULT 'general',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO tax_rates (name, rate_percent, country, applies_to, active) VALUES
('IVA General', 21.0, 'ES', 'general', true),
('IVA Reducido', 10.0, 'ES', 'reduced', true),
('IVA Superreducido', 4.0, 'ES', 'super-reduced', true)
ON CONFLICT DO NOTHING;
`);
};
exports.down = async (pgm) => {
pgm.sql('DROP TABLE IF EXISTS tax_rates');
};

View File

@@ -0,0 +1,46 @@
// migration: 024_product_channels_attributes
// Adds sales channels, featured flag, and attribute tags to the catalog_products table
/** @param {import('pg').Pool} pool */
exports.up = async (pool) => {
// Add channels column
await pool.query(`
ALTER TABLE catalog_products
ADD COLUMN IF NOT EXISTS channels TEXT NOT NULL DEFAULT 'all'
CHECK (channels IN ('online', 'offline', 'all'))
`);
// Add featured column
await pool.query(`
ALTER TABLE catalog_products
ADD COLUMN IF NOT EXISTS featured BOOLEAN NOT NULL DEFAULT FALSE
`);
// Add attributes column (stored as JSONB array of strings)
await pool.query(`
ALTER TABLE catalog_products
ADD COLUMN IF NOT EXISTS attributes JSONB NOT NULL DEFAULT '[]'::jsonb
`);
// Add offer_cents and cost_cents to pricing_variant_prices
await pool.query(`
ALTER TABLE pricing_variant_prices
ADD COLUMN IF NOT EXISTS offer_cents INTEGER,
ADD COLUMN IF NOT EXISTS cost_cents INTEGER
`);
// Create index for featured products (common query)
await pool.query(`
CREATE INDEX IF NOT EXISTS catalog_products_featured_idx
ON catalog_products (featured) WHERE featured = TRUE
`);
};
exports.down = async (pool) => {
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS channels`);
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS featured`);
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS attributes`);
await pool.query(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS offer_cents`);
await pool.query(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS cost_cents`);
await pool.query(`DROP INDEX IF EXISTS catalog_products_featured_idx`);
};

View File

@@ -0,0 +1,57 @@
// migration: 025_cms_footer_pages
// Seeds initial CMS pages for footer links if they don't exist yet
/** @param {import('pg').Pool} pool */
exports.up = async (pool) => {
const pages = [
{
slug: 'about',
title: 'Quiénes somos',
body: '<h2>Nuestra historia</h2><p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado.</p><h2>Nuestra misión</h2><p>Facilitar el acceso a productos naturales y orgánicos de alta calidad.</p>',
status: 'published',
},
{
slug: 'contact',
title: 'Contacto',
body: '<h2>Email</h2><p><a href="mailto:hola@mercadodevida.es">hola@mercadodevida.es</a></p><p>Intentamos responder en 24-48h laborables.</p>',
status: 'published',
},
{
slug: 'shipping',
title: 'Envíos y entregas',
body: '<h2>Zonas de envío</h2><p>España peninsular. Canarias, Ceuta y Melilla: contacta con nosotros.</p><h2>Envío estándar (3-5 días)</h2><p>Coste según peso. Gratis a partir de un umbral.</p><h2>Envío express 24h</h2><p>Disponible para pedidos antes de las 13:00h.</p>',
status: 'published',
},
{
slug: 'privacy',
title: 'Política de privacidad',
body: '<h2>Responsable del tratamiento</h2><p>MercadoDeVida. hola@mercadodevida.es</p><h2>Finalidad</h2><p>Gestión de pedidos, atención al cliente y envío de comunicaciones comerciales.</p><h2>Tus derechos</h2><p>Acceso, rectificación, supresión y portabilidad de tus datos. Contacta: hola@mercadodevida.es</p>',
status: 'published',
},
{
slug: 'terms',
title: 'Términos y condiciones',
body: '<h2>Contratación</h2><p>Al realizar un pedido aceptas estos términos. Los precios incluyen IVA.</p><h2>Envíos</h2><p>Ver sección de envíos.</p><h2>Devoluciones</h2><p>Tienes 14 días para devolver productos no perecederos en su estado original.</p>',
status: 'published',
},
{
slug: 'cookies',
title: 'Política de cookies',
body: '<h2>¿Qué son las cookies?</h2><p>Pequños archivos que se almacenan en tu navegador para mejorar tu experiencia.</p><h2>Cookies que usamos</h2><p>Cookies de sesión (carrito, login), cookies analíticas (opcionales) y cookies de preferencias.</p><h2>Tu consentimiento</h2><p>Al navegar aceptas el uso de cookies. Puedes desactivarlas en tu navegador.</p>',
status: 'published',
},
];
for (const p of pages) {
await pool.query(
`INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (slug) DO NOTHING`,
[p.slug, p.title, p.body, p.status],
);
}
};
exports.down = async (pool) => {
await pool.query(`DELETE FROM cms_pages WHERE slug IN ('about','contact','shipping','privacy','terms','cookies')`);
};

View File

@@ -0,0 +1,83 @@
/**
* Migration 026: Fuzzy search with trigram similarity (pg_trgm)
*
* - Enables pg_trgm extension for Levenshtein-like fuzzy matching
* - Creates GIN indexes on product name/description for fast trigram similarity
* - Adds similarity index on brands and categories for term suggestions
*
* Run: node migrations/026_fuzzy_search.js
*/
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida',
});
async function migrate() {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Enable pg_trgm extension
await client.query(`
CREATE EXTENSION IF NOT EXISTS pg_trgm;
`);
console.log('✓ pg_trgm extension enabled');
// 2. GIN index on product name for trigram similarity
await client.query(`
CREATE INDEX IF NOT EXISTS catalog_products_name_trgm_idx
ON catalog_products
USING GIN (name gin_trgm_ops);
`);
console.log('✓ catalog_products name trigram index');
// 3. GIN index on product description
await client.query(`
CREATE INDEX IF NOT EXISTS catalog_products_description_trgm_idx
ON catalog_products
USING GIN (description gin_trgm_ops);
`);
console.log('✓ catalog_products description trigram index');
// 4. GIN index on brand names (for suggestions)
await client.query(`
CREATE INDEX IF NOT EXISTS brands_brands_name_trgm_idx
ON brands_brands
USING GIN (name gin_trgm_ops);
`);
console.log('✓ brands_brands name trigram index');
// 5. GIN index on category names (for suggestions)
await client.query(`
CREATE INDEX IF NOT EXISTS categories_categories_name_trgm_idx
ON categories_categories
USING GIN (name gin_trgm_ops);
`);
console.log('✓ categories_categories name trigram index');
// 6. Record migration
await client.query(`
INSERT INTO pgmigrations (name, run_on)
VALUES ('026_fuzzy_search', NOW())
ON CONFLICT (name) DO NOTHING;
`);
console.log('✓ Migration recorded');
await client.query('COMMIT');
console.log('\n✅ Migration 026 complete: fuzzy search indexes created');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
await pool.end();
}
}
migrate().catch((err) => {
console.error('❌ Migration failed:', err.message);
process.exit(1);
});