feat(F-048): completed feature
This commit is contained in:
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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')`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
23
project/migrations/027_category_is_parent.js
Normal file
23
project/migrations/027_category_is_parent.js
Normal 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');
|
||||
};
|
||||
71
project/migrations/028_backoffice_users.js
Normal file
71
project/migrations/028_backoffice_users.js
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user