feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -1,46 +1,47 @@
// 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) => {
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
// Add channels column
await pool.query(`
pgm.sql(`
ALTER TABLE catalog_products
ADD COLUMN IF NOT EXISTS channels TEXT NOT NULL DEFAULT 'all'
CHECK (channels IN ('online', 'offline', 'all'))
CHECK (channels IN ('online', 'offline', 'all'))
`);
// Add featured column
await pool.query(`
pgm.sql(`
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(`
pgm.sql(`
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(`
pgm.sql(`
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(`
pgm.sql(`
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`);
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS channels`);
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS featured`);
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS attributes`);
pgm.sql(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS offer_cents`);
pgm.sql(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS cost_cents`);
pgm.sql(`DROP INDEX IF EXISTS catalog_products_featured_idx`);
};

View File

@@ -1,57 +1,96 @@
// 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',
},
];
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
// About page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'about',
'Quiénes somos',
'<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>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
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],
);
}
// Contact page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'contact',
'Contacto',
'<h2>Email</h2><p><a href="mailto:hola@mercadodevida.es">hola@mercadodevida.es</a></p><p>Intentamos responder en 24-48h laborables.</p>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
// Shipping page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'shipping',
'Envíos y entregas',
'<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>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
// Privacy page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'privacy',
'Política de privacidad',
'<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>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
// Terms page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'terms',
'Términos y condiciones',
'<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>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
// Cookies page
pgm.sql(`
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
VALUES (
'cookies',
'Política de cookies',
'<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>',
'published',
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
`);
};
exports.down = async (pool) => {
await pool.query(`DELETE FROM cms_pages WHERE slug IN ('about','contact','shipping','privacy','terms','cookies')`);
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.sql(
`DELETE FROM cms_pages WHERE slug IN ('about','contact','shipping','privacy','terms','cookies')`,
);
};

View File

@@ -1,83 +1,44 @@
/**
* Migration 026: Fuzzy search with trigram similarity (pg_trgm)
* 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
* This is an ordered node-pg-migrate migration. It must not open its own
* connection or execute work while the migration module is being loaded.
*/
const { Pool } = require('pg');
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.sql('CREATE EXTENSION IF NOT EXISTS pg_trgm');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida',
});
pgm.sql(`
CREATE INDEX IF NOT EXISTS catalog_products_name_trgm_idx
ON catalog_products
USING GIN (name gin_trgm_ops)
`);
async function migrate() {
const client = await pool.connect();
pgm.sql(`
CREATE INDEX IF NOT EXISTS catalog_products_description_trgm_idx
ON catalog_products
USING GIN (description gin_trgm_ops)
`);
try {
await client.query('BEGIN');
pgm.sql(`
CREATE INDEX IF NOT EXISTS brands_brands_name_trgm_idx
ON brands_brands
USING GIN (name gin_trgm_ops)
`);
// 1. Enable pg_trgm extension
await client.query(`
CREATE EXTENSION IF NOT EXISTS pg_trgm;
`);
console.log('✓ pg_trgm extension enabled');
pgm.sql(`
CREATE INDEX IF NOT EXISTS categories_categories_name_trgm_idx
ON categories_categories
USING GIN (name gin_trgm_ops)
`);
};
// 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);
});
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.sql('DROP INDEX IF EXISTS categories_categories_name_trgm_idx');
pgm.sql('DROP INDEX IF EXISTS brands_brands_name_trgm_idx');
pgm.sql('DROP INDEX IF EXISTS catalog_products_description_trgm_idx');
pgm.sql('DROP INDEX IF EXISTS catalog_products_name_trgm_idx');
pgm.sql('DROP EXTENSION IF EXISTS pg_trgm');
};

View File

@@ -0,0 +1,23 @@
/**
* FIX-19: categories parent/child type.
* Adds is_parent flag (container category). A CHILD is a leaf and cannot
* contain children; only PARENT categories can contain other categories.
*/
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`ALTER TABLE categories_categories ADD COLUMN is_parent boolean NOT NULL DEFAULT false`);
// Migration rule: categories that already have children become parents.
pgm.sql(`
UPDATE categories_categories c
SET is_parent = true
WHERE EXISTS (
SELECT 1 FROM categories_categories ch WHERE ch.parent_id = c.id
)
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('ALTER TABLE categories_categories DROP COLUMN IF EXISTS is_parent');
};

View File

@@ -0,0 +1,71 @@
/**
* FIX-14: Physical separation of backoffice users (admin/editor) from
* storefront customers (identity_users). Backoffice users get their own
* table, their own sessions, and their own auth cookie/session mechanism.
*
* Customers (role = 'customer') remain in identity_users.
* Admin/editor users are COPIED into backoffice_users (preserving password
* hash and mfa_enrolled flag) and then REMOVED from identity_users so the
* tables do not overlap at all.
*/
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
export const up = (pgm) => {
// 1) Create the new backoffice schema namespace (table names: backoffice_*)
pgm.sql(`
CREATE TABLE backoffice_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'editor')),
mfa_enrolled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(`
CREATE TABLE backoffice_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
)
`);
pgm.sql('CREATE INDEX backoffice_sessions_user_id_idx ON backoffice_sessions (user_id)');
// 2) Migrate admin/editor rows from identity_users → backoffice_users.
// Preserve id and password_hash so existing sessions/hashes survive.
pgm.sql(`
INSERT INTO backoffice_users (id, email, password_hash, role, mfa_enrolled, created_at, updated_at)
SELECT id, email, password_hash,
CASE WHEN role = 'editor' THEN 'editor' ELSE 'admin' END,
COALESCE(mfa_enrolled, false),
created_at, updated_at
FROM identity_users
WHERE role IN ('admin', 'editor')
ON CONFLICT (id) DO NOTHING
`);
// 3) Remove admin/editor rows from identity_users so the tables are disjoint.
pgm.sql(`DELETE FROM identity_users WHERE role IN ('admin', 'editor')`);
};
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
export const down = (pgm) => {
// Recreate the dropped rows in identity_users so the schema is reversible
// for the dev environment (best-effort — email uniqueness may collide if
// a customer signed up with the same email after the migration).
pgm.sql(`
INSERT INTO identity_users (id, email, password_hash, role, created_at, updated_at)
SELECT id, email, password_hash,
CASE WHEN role = 'editor' THEN 'admin' ELSE role END,
created_at, updated_at
FROM backoffice_users
ON CONFLICT (id) DO NOTHING
`);
pgm.sql('DROP TABLE IF EXISTS backoffice_sessions');
pgm.sql('DROP TABLE IF EXISTS backoffice_users');
};