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,220 @@
# FEATURE GAP MATRIX
**Project**: MercadoDeVida vNext
**Inspected**: 2026-08-17
**Backend**: project/src/ | **Storefront**: project/frontend/src/ | **Admin**: project/apps/admin/src/
---
## FEATURE A — Expiration Tracking
| Aspect | Status | Details |
|--------|--------|---------|
| Product model | ⚠️ PARTIAL | Has `ProductState`, no `expiration_tracking_enabled` |
| Inventory model | ⚠️ PARTIAL | `inventory_stock` at variant level only; no lots |
| Inventory repository | ✅ EXISTS | `PgInventoryRepository` (variant-level) |
| Lot model | ❌ MISSING | `inventory_lots` table does not exist |
| Lot repository | ❌ MISSING | No lot-level persistence |
| LotService | ❌ MISSING | Domain service for FEFO + expiry validation |
| InventoryService extended | ❌ MISSING | Delegates to LotService for expiry products |
| Checkout integration | ✅ EXISTS | Uses `InventoryServicePort` — boundary correct |
| GET /inventory/lots | ❌ MISSING | Need this route |
| POST /inventory/lots | ❌ MISSING | Need this route |
| PATCH /inventory/lots/:id | ❌ MISSING | Need this route |
| DELETE /inventory/lots/:id | ❌ MISSING | Need this route |
| GET /inventory/lots with filter | ❌ MISSING | Need ?filter=expiring\|expired\|all |
| Admin product editor | ⚠️ PARTIAL | Has General/Pricing/Inventory/Images/SEO/Publish tabs; no expiry toggle |
| Admin inventory view | ⚠️ PARTIAL | Shows variant stock; no lots |
| Admin expiry filters | ❌ MISSING | Need filter tabs in /admin/inventory |
| Audit for lot mutations | ⚠️ PARTIAL | `AuditLogger` exists; new operation types needed |
| Feature flag | ✅ EXISTS | `FLAG_EXPIRY_TRACKING` can be used |
| DB migration | ❌ MISSING | inventory_lots table, column on catalog_products |
**Backend blockers**: None — can implement in isolation
**DB blocker**: EXP-DB-001 must run before EXP-BE-005 (InventoryService extension)
---
## FEATURE B — Brand Migration
| Aspect | Status | Details |
|--------|--------|---------|
| Tailwind @theme tokens | ✅ EXISTS | `--color-primary: #2D6A4F`, `--color-accent: #E76F51`, fonts set |
| Google Fonts (Inter + Playfair) | ✅ EXISTS | Loaded in globals.css |
| businessConfig | ❌ MISSING | No single source of truth for company info |
| Official logo | ❌ MISSING | Not downloaded; no file in /public/images |
| Header component | ✅ EXISTS | Has logo slot; needs actual logo + businessConfig |
| Footer component | ✅ EXISTS | Needs businessConfig + social links |
| /about page | ✅ EXISTS | Has placeholder content; needs legacy migration |
| /contact page | ✅ EXISTS | Has placeholder content; needs real contact info |
| /shipping page | ✅ EXISTS | Needs enhancement with real policy |
| /terms page | ✅ EXISTS | Needs legacy migration + legal review |
| /privacy page | ✅ EXISTS | Needs creation from scratch |
| /cookies page | ✅ EXISTS | Needs creation from scratch |
| /legal page | ⚠️ PARTIAL | May not exist |
| Legacy redirect map | ⚠️ PARTIAL | Partially mapped; needs full sitemap extraction |
| next.config.ts redirects | ⚠️ PARTIAL | Some routes exist; needs completion + product slugs |
| sitemap.ts | ✅ EXISTS | Needs updating to include migrated routes |
| robots.ts | ✅ EXISTS | Needs updating for new site structure |
| Admin sidebar logo | ⚠️ PARTIAL | Has inline SVG; needs real logo |
| Admin branding | ✅ EXISTS | Same tokens as storefront |
**Backend blockers**: None
**⚠️ Warning**: Legal pages require human review before publishing
---
## FEATURE C — Bulk Update
| Aspect | Status | Details |
|--------|--------|---------|
| BulkOperationHandler port | ❌ MISSING | Interface for pluggable handlers |
| Price handlers (5 ops) | ❌ MISSING | SET, INCREASE/DECREASE (%, fixed) |
| Category handlers (3 ops) | ❌ MISSING | ADD, REMOVE, REPLACE |
| BulkService orchestrator | ❌ MISSING | preview() + execute() + audit |
| BulkProductRepository | ❌ MISSING | Batch read with pricing join |
| Bulk API routes | ❌ MISSING | POST /admin/bulk/preview, POST /admin/bulk/execute |
| PricingService | ✅ EXISTS | `netUnitAmountCents`, VAT calculation |
| ProductRepository | ✅ EXISTS | Has `update(id, patch)` |
| Category API | ✅ EXISTS | Categories managed via product.categoryIds |
| AuditLogger | ✅ EXISTS | `security_audit_log` table exists |
| RBAC | ✅ EXISTS | `permissions.ts` with `products.bulk_update` can be added |
| Feature flag | ✅ EXISTS | `FLAG_ADMIN_BULK_UPDATE` can be used |
| Admin product list | ✅ EXISTS | Checkbox selection is feasible |
| Admin bulk update page | ❌ MISSING | New /admin/bulk-update route |
| Navigation entry | ⚠️ PARTIAL | Need to add to NAV_ITEMS |
**Backend blockers**: None — all components can be built in isolation
**⚠️ Critical**: No bulk update should be exposed without confirmation UI
---
## API Changes Required
### New Endpoints (Backend)
| Endpoint | Method | Auth | Purpose |
|----------|--------|------|---------|
| `/inventory/lots` | GET | admin | List lots with filter |
| `/inventory/lots` | POST | admin | Create lot |
| `/inventory/lots/:id` | PATCH | admin | Update lot |
| `/inventory/lots/:id` | DELETE | admin | Delete lot |
| `/admin/bulk/preview` | POST | admin | Dry-run bulk operation |
| `/admin/bulk/execute` | POST | admin | Execute bulk operation |
### Modified Endpoints
| Endpoint | Change | Reason |
|---------|--------|--------|
| `GET /products/:id` | + `expiration_tracking_enabled` field | Product expiry policy |
| `PATCH /products/:id` | + accepts `expiration_tracking_enabled` | Set product expiry policy |
| `PUT /inventory/:variantId/stock` | Consider deprecating in favor of lots | For expiry products |
### No Changes Required
- `POST /cart`, `POST /checkout` — use InventoryServicePort unchanged
- `GET /products/search` — unchanged
- `GET /categories/tree` — unchanged
- `GET /brands` — unchanged
---
## Database Changes Required
### New Tables
| Table | Purpose |
|-------|---------|
| `inventory_lots` | Per-arrival stock with expiration date |
| `inventory_lot_movements` | Optional: movement tracking per lot |
### Modified Tables
| Table | Change | Default |
|-------|--------|---------|
| `catalog_products` | + `expiration_tracking_enabled boolean` | `false` |
| `inventory_movements` | + `lot_id uuid` (nullable) | `NULL` |
| `inventory_movements` | + new operation types | — |
### Indexes
| Index | Table | Columns |
|-------|-------|---------|
| `inventory_lots_variant_id_idx` | `inventory_lots` | `variant_id` |
| `inventory_lots_expiration_idx` | `inventory_lots` | `expiration_date` (partial) |
---
## STOREFRONT Changes
### Expected: NONE
**Expiration**: Checkout uses `InventoryServicePort` unchanged. No lot querying from frontend. No expiration display requirement from product team.
**Branding**: Storefront touches every visual component (Header, Footer, pages). Changes scoped to: tokens, logo, static pages, redirects.
**Bulk Update**: ZERO storefront changes. Bulk update is an Admin-only feature. Storefront automatically reflects updated prices/categories because it reads from the same backend.
---
## Cross-Feature Impact
```
FEATURE A (Expiration)
┌─ EXP-BE-005 (InventoryService extension)
│ └─ Checkout uses InventoryServicePort → NO CHANGE to checkout code
│ └─ Unit test regression required
└─ EXP-ADM-002/003/004 (Admin lot UI)
└─ Uses new lot API routes
FEATURE B (Branding)
┌─ Token updates may touch frontend + admin globals.css
│ └─ Build verification required on both apps
└─ Redirects in next.config.ts
└─ May conflict with existing routes → verify no overlap
FEATURE C (Bulk Update)
┌─ Bulk write to catalog_products (prices, categories)
│ └─ Storefront reads same DB → no code change needed
└─ New admin route /admin/bulk-update
└─ No conflict with existing routes
```
---
## Implementation Order
```
1. EXPIRATION
├─ BE: EXP-BE-001 (product column) ──┐
├─ BE: EXP-BE-002 (lot model) ──────────┼── parallel
└─ DB: EXP-DB-001 (run migrations) ────┘
2. EXPIRATION
├─ BE: EXP-BE-003 (LotService) ────────▶ EXP-BE-004 (lot routes)
│ │
└─ BE: EXP-BE-005 (InventoryService) ◀──────┘
3. BRAND + BULK (parallel)
├─ BRAND: BRAND-FE-001..003 (assets, config) ──▶ BRAND-FE-004..009 (pages, redirects)
└─ BULK: BULK-BE-001..008 (all backend) ──────────────────▶ BULK-ADM-001..005
4. ALL FEATURES
├─ QA: EXP-QA-001 + BULK-QA-001 (E2E regression)
├─ BRAND: Legal pages human review
└─ FLAGS: Flip feature flags when ready
```
---
## Feature Flags Needed
| Flag | Feature | Default |
|------|---------|---------|
| `expiration_tracking` | Expiration tracking (lot model + FEFO) | `false` |
| `admin_bulk_update` | Bulk update module in admin | `false` |
Branding has no feature flag — purely additive changes that cannot break existing functionality.

View File

@@ -0,0 +1,183 @@
# BRAND INVENTORY — MercadoDeVida
**Source**: https://mercadodevida.es
**Inspected**: 2026-08-17
**Status**: INCOMPLETE — CSS not yet extracted from legacy site
---
## To Extract Before Implementation
### 1. CSS Stylesheet
Fetch from the legacy site source. Common paths to try:
- `/catalog/view/theme/journal3/stylesheet/stylesheet.css`
- `/catalog/view/theme/default/stylesheet/stylesheet.css`
- `/styles.css`
- Inline `<style>` tags in page source
Extract all CSS custom properties (variables) or color values:
- Primary color (likely green/teal)
- Accent color (likely terracotta/coral)
- Background colors
- Text colors
- Link colors
- Border colors
### 2. Logo
Search for logo image sources in page HTML:
```bash
curl -s https://mercadodevida.es | grep -i "logo"
```
The logo file name is unknown. Common paths to try:
- `/image/logo.png`
- `/image/Logo-Mdv.png`
- `/image/data/logo.png`
- `/image/catalog/logo.png`
Download the highest quality version available.
### 3. Favicon
Check for:
- `/favicon.ico`
- `/image/favicon.ico`
- `<link rel="icon">` in HTML head
---
## Known Information
### Company
- **Full name**: Natural - Mercado de Vida
- **Short name**: MercadoDeVida
- **Type**: Supermercado ecológico online
- **Location**: Estepona, Costa del Sol, España
- **Founders**: Ivana & Ricardo
- **Since**: Not specified in public content
### Contact
| Field | Value |
|-------|-------|
| Address | Av. Litoral - Edif. Vistamar II - Local 18, 29680 Estepona - Málaga |
| Phone | +34 951 338 132 |
| WhatsApp | +34 676 014 074 |
| Email | info@mercadodevida.es |
### Hours
```
Lunes a Viernes: 10:00 18:00
Sábado: 10:00 14:00
```
### Social (URLs not confirmed)
- Instagram link (in footer)
- Facebook link (in footer)
- Twitter link (in footer)
### Legacy Site Technology
OpenCart-based (evidenced by `?route=` URL patterns in robots.txt disallows).
---
## Extracted Page Content
### /quienes-somos (About Us)
**Source**: https://mercadodevida.es/quienes-somos
**Content**:
> Hola! Somos Ivana & Ricardo y te invitamos a conocer Natural - Mercado de Vida
>
> Nuestra empresa se creo con la idea de un mundo mas sostenible y concientizada en un consumo responsable. En Natural - Mercado de Vida, investigamos el mercado para ofrecer en nuestra establecimiento los mejores y mas novedosos productos ecológicos, y de medicina natural, con los mejores precios y la mayor calidad. Pero también intentamos acercar nuestra selección a todos los rincones de España... y en un futuro no muy lejano a Europa.
>
> Creemos en un mundo mas coherente con el medio ambiente y confiamos que esto será posible de la mano de un consumo mas responsable y acorde a las necesidades, primando el bienestar común al individual, eligiendo calidad y buen hacer.
>
> En Natural - Mercado de Vida, Estepona, te ofrecemos un mundo de productos ecológicos y naturales a la distancia de un click.
>
> Somos el primer Supermercado Ecológico online en la Costa del Sol, desde Estepona para Sotogrande, Marbella, Málaga y toda España, poniendo el corazón y las ganas de mejorar el mundo.
>
> Invitamos a nuestros clientes a compartir nuestras experiencias y recetas para mejorar nuestra relación con el medio ambiente.
**Note**: Legacy text contains spelling/grammar issues (e.g., "concientizada", "distancía", "mas"). Migration preserves text faithfully. Flag for human review.
### /contacto (Contact)
**Source**: https://mercadodevida.es/contacto
**Content**:
> Contact us: +34 951 338 132
> Envíanos un Whatsapp: +34 676 014 074
> info@mercadodevida.es
**Form**: Legacy OpenCart contact form. New platform needs contact form or mailto link.
### /terminos-y-condiciones (Terms)
**Source**: https://mercadodevida.es/terminos-y-condiciones
**Content**: Full legal text covering:
- Ley 7/1998 Condiciones Generales de Contratación
- Ley 26/1984 Defensa Consumidores y Usuarios
- Real Decreto 1906/1999 Contratación Electrónica
- Ley Orgánica 15/1999 Protección de Datos (LOPD — LEGACY, needs updating to RGPD/LOPDGDD)
- Ley 7/1996 Ordenación Comercio Minorista
- Ley 34/2002 Servicios Sociedad Información y Comercio Electrónico (LSSI)
**⚠️ Legal flag**: LOPD (1999) referenced — Spain's current data protection law is RGPD/LOPDGDD 2018. Legal review required before publishing as-is.
### /politica-de-cookies (Cookie Policy)
**Source**: 404 on legacy. Create new policy based on RGPD requirements.
### /politica-de-privacidad (Privacy)
**Source**: 404 on legacy. Needs creation.
### /envios-y-formas-de-pago (Shipping & Payment)
**Source**: 404 on legacy. Content needs creation. Use existing new platform's /shipping page as baseline, add payment methods.
---
## Existing Design Tokens (from new platform)
These are the CURRENT tokens. They may or may not match legacy colors.
```css
/* Frontend */
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-primary-light: #40916C;
--color-secondary: #F5F0E8;
--color-accent: #E76F51;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--font-sans: "Inter", system-ui, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
```
```css
/* Admin */
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-primary-light: #40916C;
--color-accent: #E76F51;
--color-text: #111827;
--color-muted: #6B7280;
--color-bg: #F9FAFB;
--color-surface: #FFFFFF;
```
---
## Implementation Notes
1. **Extract legacy CSS** before choosing final token values
2. **Download logo** at highest resolution available
3. **Check favicon** and generate appropriate formats (ICO, PNG, SVG if available)
4. **Business config**: define `BUSINESS_INFO` constant once
5. **Legal pages**: do NOT publish legal text without human/legal review
6. **Spellchecking**: legacy text has errors — preserve them during migration, flag for review

View File

@@ -0,0 +1,242 @@
# BRAND MIGRATION — DESIGN.md
## 1. Design Token Strategy
### 1.1 Current State
Both `frontend/src/app/globals.css` and `apps/admin/src/app/globals.css` use Tailwind `@theme` block:
```css
@theme {
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-primary-light: #40916C;
--color-secondary: #F5F0E8;
--color-accent: #E76F51;
--color-text: #111827;
--color-muted: #6B7280;
--font-sans: "Inter", system-ui, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
}
```
### 1.2 Decision Required
**Extract legacy CSS first** (see BRAND_INVENTORY.md). If legacy colors differ from current tokens, update the `@theme` block. If they match (green primary, terracotta accent), no change needed.
### 1.3 Token Architecture
```
globals.css @theme
├── Tailwind utility classes auto-generate from @theme
│ text-primary, bg-primary, border-primary, etc.
└── All components reference tokens, never raw hex values
✅ text-[var(--color-primary)] / bg-[var(--color-accent)]
❌ text-[#2D6A4F] scattered in JSX
```
If any component uses a raw hex value matching a brand color, refactor to use the token.
## 2. Business Configuration
### 2.1 Single Source of Truth
Create `frontend/src/lib/businessConfig.ts`:
```typescript
export const BUSINESS_CONFIG = {
name: 'Natural - Mercado de Vida',
shortName: 'MercadoDeVida',
founders: 'Ivana & Ricardo',
address: {
street: 'Av. Litoral - Edif. Vistamar II - Local 18',
postalCode: '29680',
city: 'Estepona',
province: 'Málaga',
country: 'España',
},
phone: '+34 951 338 132',
whatsapp: '+34 676 014 074',
email: 'info@mercadodevida.es',
hours: {
weekdays: '10:00 18:00',
saturday: '10:00 14:00',
},
social: {
instagram: null, // TODO: extract from legacy site
facebook: null, // TODO: extract from legacy site
},
website: 'https://mercadodevida.es',
} as const;
export type BusinessConfig = typeof BUSINESS_CONFIG;
```
### 2.2 Usage Points
```typescript
import { BUSINESS_CONFIG } from '@/lib/businessConfig';
// Footer
<span>{BUSINESS_CONFIG.name}</span>
<span>{BUSINESS_CONFIG.address.city}</span>
// Contact page
<a href={`tel:${BUSINESS_CONFIG.phone}`}>{BUSINESS_CONFIG.phone}</a>
<a href={`mailto:${BUSINESS_CONFIG.email}`}>{BUSINESS_CONFIG.email}</a>
// About page
<p>Somos {BUSINESS_CONFIG.founders}...</p>
// Checkout confirmation
<p>{BUSINESS_CONFIG.address.street}, {BUSINESS_CONFIG.address.postalCode} {BUSINESS_CONFIG.address.city}</p>
```
Do NOT duplicate any of these values anywhere else.
## 3. Logo
### 3.1 Source Asset
Download from legacy site. See BRAND_INVENTORY.md.
### 3.2 Placement
```
Storefront Header
└── Logo → /public/images/logo.svg (or .png)
└── Also used in Admin sidebar
Storefront Footer
└── Logo (same asset)
Favicon
└── /public/favicon.ico
└── Also /public/apple-touch-icon.png
```
### 3.3 Next.js Metadata
In `frontend/src/app/layout.tsx`:
```typescript
import { BUSINESS_CONFIG } from '@/lib/businessConfig';
export const metadata: Metadata = {
title: { default: BUSINESS_CONFIG.name, template: `%s | ${BUSINESS_CONFIG.name}` },
metadataBase: new URL(BUSINESS_CONFIG.website),
};
```
## 4. Static Pages Content
### 4.1 Architecture Decision
For 57 informational pages, typed static React components are acceptable.
The existing `ContentPage` component (`frontend/src/components/content/ContentPage.tsx`) should be evaluated:
- Does it support all needed HTML elements (tables, lists, headings, blockquotes)?
- Does it support linking to business config data?
If rich layout is needed for complex pages (e.g., terms with sections), consider:
- Markdown/MDX files processed at build time
- Single `ContentPage` component that renders parsed markdown
### 4.2 Page Content Plan
| Page | Route | Source | Status |
|------|-------|--------|--------|
| About | /about | /quienes-somos (migrated) | See BRAND_INVENTORY.md |
| Contact | /contact | /contacto (partial) | Needs form + hours |
| Shipping | /shipping | New + legacy /envios-y-formas-de-pago (404) | Create from scratch |
| Terms | /terms | /terminos-y-condiciones (migrated) | Needs legal review |
| Privacy | /privacy | New (legacy 404) | Create from scratch |
| Cookies | /cookies | New (legacy 404) | Create from scratch |
| Legal | /legal | New (no legacy) | Create if needed |
### 4.3 Legal Content Flag
All legal pages (Terms, Privacy, Cookies) must be flagged:
```
⚠️ MIGRATED LEGAL CONTENT — REQUIRES HUMAN REVIEW BEFORE PUBLISHING
```
Do not mark migrated legal text as newly legally approved.
## 5. SEO Redirects
### 5.1 Implementation
In `frontend/next.config.ts`:
```typescript
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async redirects() {
return [
// Static pages
{ source: '/quienes-somos', destination: '/about', permanent: true },
{ source: '/contacto', destination: '/contact', permanent: true },
{ source: '/terminos-y-condiciones', destination: '/terms', permanent: true },
{ source: '/politica-de-cookies', destination: '/cookies', permanent: true },
{ source: '/politica-de-privacidad', destination: '/privacy', permanent: true },
{ source: '/donde-estamos', destination: '/contact', permanent: true },
{ source: '/aviso-legal', destination: '/legal', permanent: true },
// OpenCart internals — block
{ source: '/admin/:path*', destination: '/404', permanent: false },
// Catch-all for OpenCart query strings — these should not reach the app
{ source: '/:path*', has: [{ type: 'query', key: 'route' }], destination: '/404', permanent: false },
];
},
};
```
### 5.2 Category Redirects
Complete the redirect map from LEGACY_REDIRECT_MAP.md before implementing.
### 5.3 Product Redirects
Evaluate Option A vs Option B from LEGACY_REDIRECT_MAP.md before implementing.
## 6. Admin Branding
### 6.1 Logo
Use the same logo file as storefront. Simplified/smaller version acceptable.
In `apps/admin/src/app/(dashboard)/layout.tsx`, the sidebar currently shows an inline SVG. Replace with the actual logo image.
### 6.2 Token Alignment
Admin should use the same `--color-primary` token as storefront. Verify the admin `globals.css` tokens match storefront. Currently they do (both use `#2D6A4F`).
### 6.3 NOT Changed
- Admin table styling (operational, not brand-focused)
- Admin form components
- Admin typography (keep Inter for data-dense UIs)
## 7. Header Component
Review `frontend/src/components/layout/Header.tsx`:
- Logo: use `BUSINESS_CONFIG.shortName` for text fallback
- Nav links: About, Contact, Categories, etc.
- Ensure no hardcoded company name/address
## 8. Footer Component
Review `frontend/src/components/layout/Footer.tsx`:
- Use `BUSINESS_CONFIG.name` for brand name
- Use `BUSINESS_CONFIG.address` for address
- Use `BUSINESS_CONFIG.phone`, `BUSINESS_CONFIG.email`
- Use `BUSINESS_CONFIG.hours`
- Social links from `BUSINESS_CONFIG.social`
- Legal links: Terms, Privacy, Cookies
- Copyright year dynamic: `{new Date().getFullYear()}`

View File

@@ -0,0 +1,162 @@
# LEGACY REDIRECT MAP — mercadodevida.es → new-mercadodevida
**Legacy site**: OpenCart-based (evidenced by `?route=` URL patterns)
**New site**: Next.js App Router
**Status**: PARTIAL — requires completion after legacy site full audit
---
## How to Complete This Map
1. Fetch the full sitemap: `https://mercadodevida.es/sitemap.xml`
2. Fetch the homepage and parse all footer/navigation links
3. Check robots.txt disallows for route patterns
4. Map each discovered URL to its new equivalent
---
## Confirmed Redirects
| Legacy URL | New URL | Type | Status |
|-----------|---------|------|--------|
| `/quienes-somos` | `/about` | 308 | Confirmed from page content |
| `/quienes-somos` | `/about` | 308 | Confirmed from sitemap/links |
| `/contacto` | `/contact` | 308 | Confirmed from links |
| `/terminos-y-condiciones` | `/terms` | 308 | Confirmed from sitemap |
| `/politica-de-cookies` | `/cookies` | 308 | New route; no legacy page found (404) |
| `/politica-de-privacidad` | `/privacy` | 308 | New route; no legacy page found (404) |
---
## Product Redirects (from sitemap)
All product pages use slug format: `/[product-name-slug]`
From sitemap, product slugs look like:
```
/alga-dulse-hojas-100-gr-algamar
/arroz-lentejas-algas-eco-500-gr-algamar
/crema-de-cacahuetes-330gr-bio-monki
/semillas-de-calabaza-bio-250gr-el-granero-integral
/tahini-crema-de-sesamo-bio-340-gr-granovita
```
**Strategy**: 308 redirect ALL legacy product slugs to `/products/[slug]`
The new platform's product slugs may differ. Two approaches:
### Option A: Keep legacy slugs as canonical
- New platform generates slugs from product names
- If a legacy slug matches, it becomes the new canonical slug
- Redirect from legacy slug to new slug
### Option B: 308 everything to /products
- All legacy product URLs → `/products`
- User searches from there
- Simpler but loses SEO equity
**Recommended**: Option A if new product slugs can be configured. Otherwise Option B.
---
## Category Redirects
Legacy OpenCart category URLs use `?route=product/category&path=N` pattern.
From homepage navigation (extracted from page HTML):
```
/alimentacion
/aceites-y-vinagres
/azucares-y-endulzantes
/bebidas
/cereales-y-harinas
/chocolates-y-dulces
/especias-y-condimentos
/frutos-secos-y-semillas
/infantil
/infusiones-y-te
/legumbres
/macbiotica
/panaderia-y-galletas
/pastas-y-arrozes
/snacks
/cosmetica
/aseo-personal
/bebes-y-ninos
/corporal
/facial
/herbolario
/hogar
/mascotas
/libros
/superfood
```
**Strategy**: 308 redirect each legacy category slug to `/categories/[slug]`
---
## Static Pages Not Yet Confirmed
| Legacy URL | New URL | Type | Status |
|-----------|---------|------|--------|
| `/aviso-legal` | `/legal` | 308? | Not confirmed |
| `/envios-y-formas-de-pago` | `/shipping` | 308? | Legacy page 404 |
| `/donde-estamos` | `/contact` | 308? | Fragment of /contact |
---
## OpenCart Route Patterns (from robots.txt)
```
Disallow: /*?sort
Disallow: /*&sort
Disallow: /*?limit
Disallow: /*&limit
Disallow: /*?route=checkout
Disallow: /*?route=account
Disallow: /*?route=product/search
Disallow: /*&keyword
```
These patterns should return 410 Gone (OpenCart internals, not migrated).
---
## Implementation
In Next.js, redirects are configured in `next.config.ts`:
```typescript
// frontend/next.config.ts
{
async redirects() {
return [
// Products
{ source: '/:slug', has: [{ type: 'query', key: 'route', value: 'product/product' }], destination: '/products/:slug', permanent: true },
// Categories
{ source: '/:slug', has: [{ type: 'query', key: 'route', value: 'product/category' }], destination: '/categories/:slug', permanent: true },
// Static pages
{ source: '/quienes-somos', destination: '/about', permanent: true },
{ source: '/contacto', destination: '/contact', permanent: true },
{ source: '/terminos-y-condiciones', destination: '/terms', permanent: true },
{ source: '/politica-de-cookies', destination: '/cookies', permanent: true },
{ source: '/politica-de-privacidad', destination: '/privacy', permanent: true },
// Block OpenCart internals
{ source: '/admin/:path*', destination: '/404', permanent: false },
];
}
}
```
---
## SEO Equity Notes
- 308 (Permanent Redirect) preserves most SEO equity
- All redirects must be in place before legacy site goes offline
- Submit updated sitemap to Google Search Console after migration
- Monitor 404 errors in Search Console for 30 days post-migration

View File

@@ -0,0 +1,161 @@
# BRAND MIGRATION — SPEC.md
## 1. Concept & Vision
MercadoDeVida ("Natural - Mercado de Vida") is a Costa del Sol-based online organic supermarket founded by Ivana & Ricardo, serving all of Spain. The brand identity comes from the legacy site (mercadodevida.es). The new platform must faithfully adopt the established brand identity — colors, typography, company information, and approved content — without inventing or approximating.
**Rule: INSPECT, DO NOT INVENT. MIGRATE, DO NOT REWRITE.**
## 2. Source of Truth
**Legacy site**: https://mercadodevida.es
**Legacy sitemap**: https://mercadodevida.es/sitemap.xml
All visual assets, colors, and content come from the legacy site. No AI approximation.
## 3. Company Identity
### 3.1 Official Business Name
```
Primary: Natural - Mercado de Vida
Abbreviated: MercadoDeVida
```
### 3.2 Founders
```
Ivana & Ricardo
```
### 3.3 Contact Information
| Field | Value |
|-------|-------|
| Address | Av. Litoral - Edif. Vistamar II - Local 18, 29680 Estepona - Málaga |
| Phone | +34 951 338 132 |
| WhatsApp | +34 676 014 074 |
| Email | info@mercadodevida.es |
### 3.4 Business Hours
```
MondayFriday: 10:00 18:00
Saturday: 10:00 14:00
```
### 3.5 Geographic Scope
Serving all of Spain (peninsular). First Costa del Sol: Estepona, Sotogrande, Marbella, Málaga.
### 3.6 Social Links
| Platform | Handle/Lookup |
|---------|---------------|
| Instagram | Siguenos en Instagram (URL to be confirmed) |
| Facebook | Siguenos en Facebook (URL to be confirmed) |
URLs must be extracted from legacy site HTML. Do not guess.
## 4. Brand Inventory
See `BRAND_INVENTORY.md` for complete asset catalog.
## 5. Color Palette
Colors extracted from legacy site CSS. Do not eyeball from screenshots.
See `BRAND_INVENTORY.md` for source URLs and extracted values.
### Initial Observations
The legacy site appears to use:
- A green/teal primary (matching the existing `#2D6A4F` in the new platform's design tokens)
- A terracotta/coral accent
- Warm cream/beige backgrounds
- Earthy tones consistent with an organic/natural products brand
Exact CSS values must be extracted from the legacy site's stylesheet. See `BRAND_INVENTORY.md` for instructions.
## 6. Typography
**Current new platform**: Inter (sans) + Playfair Display (headings)
These should be evaluated against the legacy site's actual typography. If the legacy site uses different fonts, update the design tokens accordingly.
## 7. Information Pages
### 7.1 Source Content
| Legacy URL | New Route | Content Status |
|-----------|-----------|---------------|
| /quienes-somos | /about | Content extracted — see BRAND_INVENTORY.md |
| /quienes-somos | /contact | Content extracted — partial |
| /envios-y-formas-de-pago | /shipping | 404 on legacy — needs content creation |
| /terminos-y-condiciones | /terms | Legal text migrated — needs legal review |
| /politica-de-privacidad | /privacy | 404 on legacy — needs content creation |
| /politica-de-cookies | /cookies | 404 on legacy — needs content creation |
| /aviso-legal | /legal | Not on legacy — may need creation |
### 7.2 Content Architecture Decision
**Option A**: Typed static content in React components (current approach, works for simple pages)
**Option B**: Markdown/MDX files
**Option C**: CMS-backed (backend endpoint)
For this migration, Option A (typed static content) is acceptable if the new `ContentPage` component supports rich content. If rich content is needed, evaluate Option B.
Do NOT build a CMS just for five pages.
### 7.3 Single Source of Truth
Business information (address, phone, email, WhatsApp, hours) must be defined once and referenced from:
- Footer
- Contact page
- About page
- Checkout confirmation
- Legal pages
Create a `businessConfig` constant in `frontend/src/lib/businessConfig.ts` (or existing equivalent). Do NOT duplicate strings.
## 8. SEO Redirects
See `LEGACY_REDIRECT_MAP.md` for full redirect map.
Key principles:
- Every indexed legacy URL must either redirect (301/308) or return 410
- Product pages: 301 redirect to new slug format
- Informational pages: 301 redirect to new routes
- Category pages: map legacy OpenCart routes to new category routes
## 9. Admin Branding
Admin reuses:
- Logo (simplified or same)
- Basic brand colors (primary green)
Admin does NOT need:
- Full brand editorial content
- Marketing imagery
- Consumer-focused styling
Priority: operational usability. Admin must be clearly part of MercadoDeVida ecosystem without reducing table/form readability.
## 10. Out of Scope
- Rebuilding the legacy site functionality
- Migrating user accounts
- Migrating order history
- Migrating product catalog (already done)
- Changing domain registrar or DNS
- Third-party integrations (Instagram, Facebook)
- Payment gateway changes
- New marketing pages
## 11. Feature Flag
Not required for brand migration. Brand is purely additive.
## 12. Acceptance Criteria
See `TESTS.md`.

View File

@@ -0,0 +1,298 @@
# BRAND MIGRATION — TASKS.md
## Phase 1: Asset Extraction
### BRAND-FE-001
**ID**: BRAND-FE-001
**Title**: Extract legacy CSS and brand colors
**Goal**: Get exact hex values from mercadodevida.es stylesheet
**Why**: Do not approximate brand colors
**Dependencies**: None
**Applications**: Frontend, Admin
**Modules**: Design tokens
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Inspect legacy site CSS via DevTools or curl; compare with current tokens; update @theme if needed
**Tests**: Visual comparison of legacy vs new site colors
**Expected blast radius**: Low — purely visual
**Definition of Done**: Extracted colors documented in BRAND_INVENTORY.md; any divergence from current tokens identified
### BRAND-FE-002
**ID**: BRAND-FE-002
**Title**: Download official logo
**Goal**: Get highest quality logo from legacy site
**Why**: No AI reconstruction or approximation allowed
**Dependencies**: BRAND-FE-001
**Applications**: Frontend, Admin
**Modules**: Public assets
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Find logo image URL from legacy HTML; download SVG/PNG; save to /public/images/; generate favicon variants
**Tests**: Logo renders at correct size in Header and Footer; favicon loads
**Expected blast radius**: Low
**Definition of Done**: Logo file exists in /public/images/; renders in Header, Footer, and Admin sidebar
## Phase 2: Business Configuration
### BRAND-FE-003
**ID**: BRAND-FE-003
**Title**: Create businessConfig constant
**Goal**: Single source of truth for company information
**Why**: No duplicate address/phone/email strings
**Dependencies**: BRAND-FE-001
**Applications**: Frontend (all pages)
**Modules**: frontend/src/lib/businessConfig.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Create businessConfig.ts with all known company info; use `as const` for type safety; mark social URLs as null pending extraction
**Tests**: businessConfig type check; usage in Footer, Header, Contact, About, Checkout confirmation
**Expected blast radius**: Frontend text content
**Definition of Done**: All company info references businessConfig; no hardcoded strings in components
## Phase 3: Storefront Brand Application
### BRAND-FE-004
**ID**: BRAND-FE-004
**Title**: Update Header with logo + business config
**Goal**: Header reflects official brand identity
**Why**: Primary brand touchpoint
**Dependencies**: BRAND-FE-002, BRAND-FE-003
**Applications**: Frontend
**Modules**: Header component
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Replace inline SVG logo with actual logo file; use businessConfig for nav text; verify no hardcoded strings remain
**Tests**: Logo renders; nav links correct; business name in correct places
**Expected blast radius**: Low
**Definition of Done**: Header matches brand; logo correct size and quality
### BRAND-FE-005
**ID**: BRAND-FE-005
**Title**: Update Footer with full business info + social
**Goal**: Footer contains all business contact information
**Why**: Trust and legal requirement (company info on all pages)
**Dependencies**: BRAND-FE-002, BRAND-FE-003
**Applications**: Frontend
**Modules**: Footer component
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Use businessConfig for all footer data; add social links; add company registration info; verify copyright year is dynamic
**Tests**: All footer fields populated from businessConfig; no placeholder text
**Expected blast radius**: Low
**Definition of Done**: Footer contains address, phone, email, WhatsApp, hours, social, legal links, dynamic copyright
### BRAND-FE-006
**ID**: BRAND-FE-006
**Title**: Migrate About page content
**Goal**: Faithful migration of /quienes-somos content
**Why**: Preserve founder story and brand voice
**Dependencies**: BRAND-FE-003
**Applications**: Frontend
**Modules**: /about page
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Replace current /about content with migrated content from BRAND_INVENTORY.md; use ContentPage component; add legal flag comment
**Tests**: Page renders migrated content correctly; text fidelity verified
**Expected blast radius**: Low
**Definition of Done**: About page matches legacy /quienes-somos; founders' story preserved; noted as needing legal review if claims made
### BRAND-FE-007
**ID**: BRAND-FE-007
**Title**: Update Contact page
**Goal**: Contact information from businessConfig + contact form
**Why**: Customer trust; functional contact channel
**Dependencies**: BRAND-FE-003
**Applications**: Frontend
**Modules**: /contact page
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Use businessConfig for all contact info; add functional contact form (mailto or API endpoint); include map/location if feasible; hours from businessConfig
**Tests**: All contact methods displayed; form submits successfully
**Expected blast radius**: Low
**Definition of Done**: Phone, email, WhatsApp, address, hours all correct and functional
### BRAND-FE-008
**ID**: BRAND-FE-008
**Title**: Migrate/create legal pages (Terms, Privacy, Cookies)
**Goal**: Legal pages exist with migrated or new content
**Why**: Legal compliance; customer trust
**Dependencies**: None (can run parallel)
**Applications**: Frontend
**Modules**: /terms, /privacy, /cookies pages
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: For Terms: migrate from legacy (see BRAND_INVENTORY.md); for Privacy/Cookies: create RGPD-compliant templates; add ⚠LEGAL_REVIEW flag to all; use ContentPage component
**Tests**: All three pages accessible at correct routes; content renders; links in footer correct
**Expected blast radius**: Low
**Definition of Done**: Pages exist and are accessible; flagged as needing legal review; legal links work
### BRAND-FE-009
**ID**: BRAND-FE-009
**Title**: Migrate/create Shipping page
**Goal**: Shipping policy accessible
**Why**: Customer information requirement
**Dependencies**: BRAND-FE-003
**Applications**: Frontend
**Modules**: /shipping page
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Review existing /shipping page; enhance with additional shipping methods, costs, estimated delivery times, geographical coverage (Spain peninsular); use businessConfig for company info
**Tests**: Shipping page complete with all delivery options; pricing included if applicable
**Expected blast radius**: Low
**Definition of Done**: Shipping page reflects current shipping policy; clear geographic scope stated
## Phase 4: SEO Redirects
### BRAND-FE-010
**ID**: BRAND-FE-010
**Title**: Implement legacy URL redirects in next.config.ts
**Goal**: Preserve SEO equity; no broken links for indexed URLs
**Why**: Indexed legacy URLs must not 404
**Dependencies**: Complete LEGACY_REDIRECT_MAP.md
**Applications**: Frontend (Next.js config)
**Modules**: next.config.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Add `redirects()` async function in next.config.ts; implement all confirmed 308 redirects from LEGACY_REDIRECT_MAP.md; handle OpenCart route patterns with 410
**Tests**: curl each legacy URL → correct redirect or target page
**Expected blast radius**: Low — purely routing
**Definition of Done**: All legacy static page URLs redirect correctly; OpenCart query-string routes return 404
### BRAND-FE-011
**ID**: BRAND-FE-011
**Title**: Product slug redirects
**Goal**: Redirect legacy product URLs to new product pages
**Why**: Preserve SEO on indexed product pages
**Dependencies**: BRAND-FE-010 (part of same config update)
**Applications**: Frontend
**Modules**: next.config.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Evaluate Option A vs B from LEGACY_REDIRECT_MAP.md; implement accordingly
**Tests**: At least 5 legacy product URLs from sitemap → correct redirect
**Expected blast radius**: Low
**Definition of Done**: Legacy product slugs handled (either redirect to matching slug or to /products)
## Phase 5: Admin
### BRAND-ADM-001
**ID**: BRAND-ADM-001
**Title**: Admin sidebar logo
**Goal**: Admin sidebar uses real logo instead of inline SVG
**Why**: Brand consistency
**Dependencies**: BRAND-FE-002
**Applications**: Admin
**Modules**: DashboardLayout sidebar
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Copy logo to apps/admin/public/; update sidebar to use <img> instead of inline SVG
**Tests**: Logo visible in admin sidebar
**Expected blast radius**: Low
**Definition of Done**: Admin sidebar shows actual logo; correct size
## SEO
### BRAND-SEO-001
**ID**: BRAND-SEO-001
**Title**: Sitemap update after migration
**Goal**: New sitemap reflects migrated routes
**Why**: Search engine indexing
**Dependencies**: BRAND-FE-006, BRAND-FE-007, BRAND-FE-008, BRAND-FE-009, BRAND-FE-010
**Applications**: Frontend
**Modules**: sitemap.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Update frontend/src/app/sitemap.ts to include /about, /contact, /shipping, /terms, /privacy, /cookies; verify /products and /categories still included
**Tests**: sitemap.xml accessible; contains all expected routes
**Expected blast radius**: Low
**Definition of Done**: Sitemap valid XML; includes all public routes
### BRAND-SEO-002
**ID**: BRAND-SEO-002
**Title**: robots.txt update
**Goal**: Allow crawlers for new routes; block legacy OpenCart routes
**Why**: SEO and security
**Dependencies**: BRAND-FE-010
**Applications**: Frontend
**Modules**: robots.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Update robots.ts to reflect new site structure; add Sitemap directive pointing to new sitemap
**Tests**: /robots.txt accessible; correct directives present
**Expected blast radius**: Low
**Definition of Done**: robots.txt allows crawling of all public pages; blocks admin and legacy internals
## QA
### BRAND-QA-001
**ID**: BRAND-QA-001
**Title**: Visual regression and route tests
**Goal**: Brand elements render correctly; redirects work
**Why**: Quality assurance
**Dependencies**: All FE/ADM tasks
**Applications**: QA
**Modules**: E2E or visual tests
**Tests**:
- Logo renders on Header, Footer, Admin sidebar
- Footer contains all business contact info from businessConfig
- About page renders migrated content
- Legacy redirects: /quienes-somos → /about (308), /contacto → /contact (308)
- Sitemap contains all public routes
- No hardcoded company info outside businessConfig
**Expected blast radius**: N/A
**Definition of Done**: All tests pass; visual QA sign-off
---
## Task Summary Table
| Task | Layer | Feature | Depends On | Risk | Parallel |
|------|-------|---------|-----------|------|---------|
| BRAND-FE-001 | FE | Extract legacy CSS | — | Low | * |
| BRAND-FE-002 | FE | Download logo | BRAND-FE-001 | Low | * |
| BRAND-FE-003 | FE | businessConfig | BRAND-FE-001 | Low | * |
| BRAND-FE-004 | FE | Header update | BRAND-FE-002, BRAND-FE-003 | Low | * |
| BRAND-FE-005 | FE | Footer update | BRAND-FE-002, BRAND-FE-003 | Low | * |
| BRAND-FE-006 | FE | About page | BRAND-FE-003 | Low | * |
| BRAND-FE-007 | FE | Contact page | BRAND-FE-003 | Low | * |
| BRAND-FE-008 | FE | Legal pages | — | Medium | * |
| BRAND-FE-009 | FE | Shipping page | BRAND-FE-003 | Low | * |
| BRAND-FE-010 | FE | Static page redirects | BRAND-FE-001 | Low | BRAND-FE-006..009 |
| BRAND-FE-011 | FE | Product slug redirects | BRAND-FE-010 | Medium | * |
| BRAND-ADM-001 | Admin | Admin logo | BRAND-FE-002 | Low | * |
| BRAND-SEO-001 | SEO | Sitemap update | BRAND-FE-006..009 | Low | * |
| BRAND-SEO-002 | SEO | robots.txt update | BRAND-FE-010 | Low | * |
| BRAND-QA-001 | QA | Visual + route tests | All above | Low | After FE tasks |
**Parallel group**: BRAND-FE-001 through BRAND-FE-003 can run in parallel. BRAND-FE-004 through BRAND-FE-009 can run in parallel after FE-003. BRAND-FE-010 depends on BRAND-FE-001 (extract URLs). BRAND-SEO-001 and BRAND-SEO-002 depend on content pages being done.
**Recommended order**:
1. BRAND-FE-001, BRAND-FE-002, BRAND-FE-003 (parallel)
2. BRAND-FE-004, BRAND-FE-005, BRAND-FE-006, BRAND-FE-007, BRAND-FE-008, BRAND-FE-009 (parallel)
3. BRAND-FE-010 + BRAND-FE-011 (after FE-001)
4. BRAND-ADM-001 (after FE-002)
5. BRAND-SEO-001 + BRAND-SEO-002 (after content pages)
6. BRAND-QA-001 (after everything)
**High-risk tasks**:
- BRAND-FE-008 (legal pages — requires human review, cannot be auto-approved)
- BRAND-FE-011 (product redirects — wrong option could lose SEO equity)
**Migration risks**:
- Legal text is legacy and may not comply with current RGPD — must be reviewed by human before publishing
- Legacy site may still be actively used — coordinate go-live date
- Legacy product slugs may not match new platform slug format — requires slug mapping

View File

@@ -0,0 +1,117 @@
# BRAND MIGRATION — TESTS.md
## Visual Regression Tests
```
BRAND-VIS-001: Logo renders in Header at correct dimensions
BRAND-VIS-002: Logo renders in Footer at correct dimensions
BRAND-VIS-003: Logo renders in Admin sidebar
BRAND-VIS-004: Favicon renders in browser tab
BRAND-VIS-005: Primary color matches legacy (inspect CSS after BRAND-FE-001)
BRAND-VIS-006: Accent color matches legacy (inspect CSS after BRAND-FE-001)
BRAND-VIS-007: Footer contains company name from businessConfig
BRAND-VIS-008: Footer contains full address from businessConfig
BRAND-VIS-009: Footer contains phone from businessConfig
BRAND-VIS-010: Footer contains email from businessConfig
BRAND-VIS-011: Footer contains WhatsApp from businessConfig
BRAND-VIS-012: Footer contains business hours from businessConfig
BRAND-VIS-013: Header uses Inter font (system / Google Fonts)
BRAND-VIS-014: Heading font (Playfair Display) renders for page titles
BRAND-VIS-015: Responsive: logo and footer text readable on mobile
```
## Route Tests
```
BRAND-ROUTE-001: GET /about → 200, About page with migrated content
BRAND-ROUTE-002: GET /contact → 200, Contact page with businessConfig info
BRAND-ROUTE-003: GET /shipping → 200, Shipping policy
BRAND-ROUTE-004: GET /terms → 200, Terms page (may be flagged for review)
BRAND-ROUTE-005: GET /privacy → 200, Privacy policy
BRAND-ROUTE-006: GET /cookies → 200, Cookie policy
BRAND-ROUTE-007: GET /legal → 200 or /404 (if not created)
```
## Redirect Tests
```
BRAND-RED-001: GET /quienes-somos → 308 → /about
BRAND-RED-002: GET /contacto → 308 → /contact
BRAND-RED-003: GET /terminos-y-condiciones → 308 → /terms
BRAND-RED-004: GET /politica-de-cookies → 308 → /cookies
BRAND-RED-005: GET /politica-de-privacidad → 308 → /privacy
BRAND-RED-006: GET /donde-estamos → 308 → /contact
BRAND-RED-007: GET /admin → 404 (OpenCart internals blocked)
BRAND-RED-008: GET /?route=product/product&id=42 → 404 (OpenCart query blocked)
BRAND-RED-009: Legacy product slug from sitemap → appropriate redirect
BRAND-RED-010: Legacy category slug → appropriate redirect
```
## SEO Tests
```
BRAND-SEO-001: GET /sitemap.xml → valid XML with all public routes
BRAND-SEO-002: GET /robots.txt → contains sitemap directive
BRAND-SEO-003: GET /robots.txt → does not block /about, /contact, /shipping, /terms, /privacy, /cookies
BRAND-SEO-004: GET /robots.txt → blocks /admin
BRAND-SEO-005: <title> on /about → includes "Mercado de Vida" or "MercadoDeVida"
BRAND-SEO-006: <meta name="description"> on /about → present and meaningful
```
## Content Tests
```
BRAND-CONTENT-001: /about page mentions founders (Ivana & Ricardo)
BRAND-CONTENT-002: /about page mentions Estepona/Costa del Sol
BRAND-CONTENT-003: /contact page has clickable phone link (tel:)
BRAND-CONTENT-004: /contact page has clickable email link (mailto:)
BRAND-CONTENT-005: /contact page has WhatsApp link
BRAND-CONTENT-006: Footer copyright year is current year (dynamic)
BRAND-CONTENT-007: All business info fields populated (no empty phone/email/address)
BRAND-CONTENT-008: Legal pages have ⚠LEGAL_REVIEW flag or equivalent visible in source
```
## businessConfig Tests
```
BRAND-CFG-001: businessConfig.phone matches string used in Header, Footer, Contact
BRAND-CFG-002: businessConfig.email matches string used in Header, Footer, Contact
BRAND-CFG-003: businessConfig.address matches string used in Footer
BRAND-CFG-004: No hardcoded address string in any component outside businessConfig
BRAND-CFG-005: No hardcoded phone string in any component outside businessConfig
BRAND-CFG-006: No hardcoded email string in any component outside businessConfig
```
## Given/When/Then Acceptance Criteria
```
GIVEN a user visits /quienes-somos
WHEN the redirect is implemented
THEN they receive a 308 redirect to /about
AND they land on the About page with migrated content
GIVEN a user visits the footer
WHEN the page renders
THEN the address, phone, email, WhatsApp, and hours all come from businessConfig
AND no string is hardcoded in the Footer component
GIVEN a developer needs to update the company phone number
WHEN they update businessConfig.phone
THEN every page that displays the phone reflects the new value
AND no manual find-replace is required
GIVEN legal text has been migrated from the legacy site
WHEN the Terms page is published
THEN it is flagged as requiring human/legal review
AND no AI-modified version is presented as newly legally approved
GIVEN a search engine bot crawls the site
WHEN it requests /sitemap.xml
THEN it contains all public routes (/about, /contact, /shipping, /products, /categories, etc.)
AND no internal admin routes are exposed
GIVEN a legacy product URL is accessed
WHEN the new platform handles it
THEN it either redirects to the correct new product page
OR returns 404 — it must NOT silently show a broken page
```

View File

@@ -0,0 +1,311 @@
# ADMIN BULK UPDATE — DESIGN.md
## 1. Module Structure
```
src/modules/bulk/
├── domain/
│ ├── bulk-operation.ts # Types, enums
│ └── ports.ts # BulkOperationHandler interface
├── application/
│ ├── bulk-service.ts # Orchestrator: validate → preview → execute
│ └── handlers/
│ ├── price-handlers.ts # SET, INCREASE/DECREASE (percent + fixed)
│ └── category-handlers.ts # ADD, REMOVE, REPLACE
├── infrastructure/
│ └── pg-bulk-repository.ts # Batch product reads + price updates
└── api/
└── bulk.routes.ts # POST /admin/bulk/preview, POST /admin/bulk/execute
```
## 2. Domain Types
```typescript
// src/modules/bulk/domain/bulk-operation.ts
export type BulkOperationType =
| 'SET_PRICE'
| 'INCREASE_PERCENT'
| 'DECREASE_PERCENT'
| 'INCREASE_FIXED'
| 'DECREASE_FIXED'
| 'ADD_CATEGORY'
| 'REMOVE_CATEGORY'
| 'REPLACE_CATEGORIES';
export interface BulkPriceParams {
netUnitAmountCents?: number; // for SET_PRICE
percent?: number; // for INCREASE/DECREASE_PERCENT
fixedCents?: number; // for INCREASE/DECREASE_FIXED
vatRate?: VatRate; // vatRate must be specified on price changes
}
export interface BulkCategoryParams {
categoryId?: string; // for ADD/REMOVE
categoryIds?: string[]; // for REPLACE
}
export type BulkOperationParams = BulkPriceParams | BulkCategoryParams;
export interface BulkOperationRequest {
productIds: string[];
operation: BulkOperationType;
parameters: BulkOperationParams;
}
export interface PreviewItem {
productId: string;
currentPriceCents: number | null;
proposedPriceCents: number | null;
currentCategoryIds: string[];
proposedCategoryIds: string[] | null;
status: 'valid' | 'failed';
error: string | null;
}
export interface PreviewResult {
items: PreviewItem[];
summary: {
total: number;
valid: number;
failed: number;
};
}
export interface ExecuteResult {
operationId: string;
status: 'COMPLETED' | 'COMPLETED_WITH_ERRORS' | 'FAILED';
results: {
total: number;
successful: number;
failed: number;
errors: { productId: string; error: string }[];
};
}
```
## 3. Handler Interface
```typescript
// src/modules/bulk/domain/ports.ts
export interface ValidationResult {
valid: boolean;
error?: string;
}
export interface BulkOperationHandler {
readonly type: BulkOperationType;
readonly description: string; // e.g., "Increase price by %"
validate(params: unknown): ValidationResult;
// Returns the proposed change for one product (dry-run)
preview(product: ProductWithPrice, params: unknown): PreviewItem;
// Returns the domain patch to apply (actual write)
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch;
}
```
## 4. Price Handlers
### 4.1 IncreasePercentHandler
```typescript
class IncreasePercentHandler implements BulkOperationHandler {
readonly type = 'INCREASE_PERCENT';
validate(params: unknown): ValidationResult {
const p = params as BulkPriceParams;
if (!p.percent || p.percent <= 0 || p.percent > 1000) {
return { valid: false, error: 'PERCENT_INVALID: must be 01000' };
}
if (!p.vatRate) return { valid: false, error: 'VAT_RATE_REQUIRED' };
return { valid: true };
}
preview(product: ProductWithPrice, params: unknown): PreviewItem {
if (!product.price) {
return { productId: product.id, currentPriceCents: null, proposedPriceCents: null,
currentCategoryIds: [], proposedCategoryIds: null, status: 'failed', error: 'PRICE_NOT_FOUND' };
}
const proposed = Math.round(product.price.netUnitAmountCents * (1 + p.percent / 100));
return { ... };
}
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch {
// Returns patch for PATCH /catalog/products/:id
return { pricing: { netUnitAmountCents: proposed, vatRate: p.vatRate } };
}
}
```
**Rounding**: Use `Math.round()` — standard currency rounding. Backend pricing service may apply additional rounding; preview uses the same calculation.
**VAT**: VAT is stored as net price. The proposed change is applied to the net price. Gross price is computed by the pricing domain (not by this handler).
### 4.2 DecreasePercentHandler
Same as IncreasePercentHandler but `1 - p.percent / 100`. Floor at 0 (no negative prices).
### 4.3 IncreaseFixedHandler / DecreaseFixedHandler
Apply `current + fixedCents` or `current - fixedCents`. Floor at 0.
### 4.4 SetPriceHandler
Validate `netUnitAmountCents >= 0`. Apply exact value.
## 5. Category Handlers
### 5.1 AddCategoryHandler
```typescript
class AddCategoryHandler implements BulkOperationHandler {
readonly type = 'ADD_CATEGORY';
validate(params): ValidationResult {
const p = params as BulkCategoryParams;
if (!p.categoryId) return { valid: false, error: 'CATEGORY_ID_REQUIRED' };
return { valid: true };
}
buildPatch(product: Product, params): ProductPatch {
const newIds = [...new Set([...product.categoryIds, p.categoryId])];
return { categoryIds: newIds };
}
}
```
### 5.2 RemoveCategoryHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: product.categoryIds.filter(id => id !== p.categoryId) };
}
```
### 5.3 ReplaceCategoriesHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: p.categoryIds ?? [] };
}
```
## 6. BulkService Orchestrator
```typescript
export class BulkService {
constructor(
private readonly products: BulkProductRepository, // read-only with price
private readonly productRepo: ProductRepository, // write
private readonly audit: AuditLogger,
) {}
async preview(req: BulkOperationRequest): Promise<PreviewResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const items = products.map(p => handler.preview(p, req.parameters));
return {
items,
summary: { total: items.length, valid: items.filter(i => i.status === 'valid').length, failed: ... }
};
}
async execute(req: BulkOperationRequest): Promise<ExecuteResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const operationId = generateId();
const { successful, failed } = await this.applyInTransaction(products, handler, req.parameters);
await this.audit.log({
actorId: req.actorId,
action: 'bulk.execute',
target: `bulk:${handler.type.toLowerCase()}`,
metadata: { operationId, operation: req.operation, parameters: req.parameters, results: { total: products.length, successful, failed } },
});
return {
operationId,
status: failed === 0 ? 'COMPLETED' : 'COMPLETED_WITH_ERRORS',
results: { total: products.length, successful, failed, errors: failedItems },
};
}
}
```
## 7. Transaction Strategy
```typescript
private async applyInTransaction(products, handler, params) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// ... apply each patch
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
```
On rollback, no product is partially modified.
## 8. API Routes
```
POST /admin/bulk/preview
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 PreviewResult
POST /admin/bulk/execute
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 ExecuteResult
```
## 9. Admin UI
```
/admin/bulk-update (new route)
└── Step 1: Select Products
├── Product list with checkboxes
├── Search/filter bar (reuse existing product list)
└── Selected count display: "18 products selected"
└── Step 2: Choose Operation
├── [Price] → sub-options appear
└── [Categories] → sub-options appear
└── Step 3: Configure
├── Price: operation selector + parameter input
└── Category: operation selector + category picker
└── Step 4: Preview
└── Table: Product | Current | Proposed | Status
└── Step 5: Confirm
└── Warning: "18 changes will be applied. This cannot be undone."
└── Step 6: Results
└── Summary: X successful, Y failed
```
Each step is a separate view in the wizard. No animations. Simple form state.
## 10. No Second Page for Bulk Update
Adding `/admin/bulk-update` as a separate route is cleaner than embedding in the products list. It keeps the product list focused on browsing and editing, and the bulk wizard has its own clear lifecycle.

View File

@@ -0,0 +1,225 @@
# ADMIN BULK UPDATE — SPEC.md
## 1. Concept & Vision
A safe, auditable bulk update module for MercadoDeVida Admin that lets operators change prices and categories across multiple products at once. Every bulk operation requires a **preview** before execution, confirms before committing, and logs every change to the audit trail.
**Rule: PREVIEW != WRITE. SELECT → PREVIEW → CONFIRM → EXECUTE. NEVER SILENT MUTATION.**
## 2. Initial Capabilities
- **Bulk Price Update**: SET, INCREASE BY %, DECREASE BY %, INCREASE BY FIXED, DECREASE BY FIXED
- **Bulk Category Update**: ADD CATEGORY, REMOVE CATEGORY, REPLACE CATEGORIES
The architecture must allow adding future operations (stock, visibility, brands, etc.) without redesign.
## 3. Why Not PATCH in a Loop?
Making N individual `PATCH /products/:id` calls from the browser for bulk operations causes:
- N network requests
- N separate audit entries (not grouped)
- Partial failures with no atomicity
- Race conditions if prices change between calls
- Poor observability
A single backend bulk operation groups these concerns: atomic or near-atomic execution, single audit entry, transactional semantics.
## 4. Bulk Operation Types
### 4.1 Price Operations
```
SET_PRICE → Set netUnitAmountCents to exact value
INCREASE_PERCENT → netUnitAmountCents = current * (1 + pct/100), round to nearest cent
DECREASE_PERCENT → netUnitAmountCents = current * (1 - pct/100), round to nearest cent
INCREASE_FIXED → netUnitAmountCents = current + fixedCents
DECREASE_FIXED → netUnitAmountCents = current - fixedCents
```
### 4.2 Category Operations
```
ADD_CATEGORY → Append categoryId to product's categoryIds (deduplicated)
REMOVE_CATEGORY → Remove categoryId from product's categoryIds
REPLACE_CATEGORIES → Replace product's categoryIds entirely with provided list
```
**Critical**: "UPDATE CATEGORY" does not exist. The three explicit operations above have very different semantics.
## 5. Selection Strategy
Admin can select products through:
- **Checkbox row selection**: tick individual products in the list
- **Search/filter**: apply filters to the list, implicitly selecting the filtered set
The UI must be explicit about what "Selected products" means:
- "18 products selected" = 18 explicitly checked rows
- "All 42 filtered products" = entire filtered result set (requires separate confirmation)
Do NOT mix these semantics.
## 6. Preview
Preview is a **dry-run**. No product is modified.
Preview is powered by a backend endpoint that returns proposed changes without applying them:
```
POST /admin/bulk/preview
{
"productIds": ["uuid1", "uuid2", ...],
"operation": "INCREASE_PERCENT",
"parameters": { "percent": 5 }
}
→ 200
{
"items": [
{
"productId": "uuid1",
"currentPriceCents": 350,
"proposedPriceCents": 368, ← calculated by backend
"currentCategoryIds": ["cat-a"],
"proposedCategoryIds": null, ← null if not a category op
"status": "valid",
"error": null
},
{
"productId": "uuid2",
"currentPriceCents": 290,
"proposedPriceCents": null,
"status": "failed",
"error": "PRICE_NOT_FOUND" ← variant has no price
}
],
"summary": {
"total": 20,
"valid": 18,
"failed": 2
}
}
```
The preview response is **not stored**. It is regenerated on each request. Backend calculates prices using existing pricing rules (VAT, rounding, etc.) — NOT frontend JavaScript.
## 7. Confirmation
After preview, admin sees:
- What will change (product, old value, new value)
- What will fail (product, reason)
- Summary: "18 products will be updated, 2 skipped"
Admin must explicitly confirm:
```
Are you sure you want to apply these 18 changes?
This action cannot be undone.
[Cancel] [Apply Changes]
```
## 8. Execution
Confirmed preview is executed via:
```
POST /admin/bulk/execute
{
"productIds": ["uuid1", "uuid2", ...],
"operation": "INCREASE_PERCENT",
"parameters": { "percent": 5 }
}
→ 200
{
"operationId": "bulk-uuid",
"status": "COMPLETED",
"results": {
"total": 20,
"successful": 18,
"failed": 2,
"errors": [
{ "productId": "uuid2", "error": "PRICE_NOT_FOUND" }
]
}
}
```
Backend applies changes in a single transaction per operation. If the operation fails partway, the transaction rolls back (no partial mutations on error).
## 9. Audit
Every bulk execution logs ONE audit entry via existing AuditLogger:
```json
{
"actorId": "admin-uuid",
"action": "bulk.execute",
"target": "bulk:price.increase_percent",
"metadata": {
"operationId": "bulk-uuid",
"operation": "INCREASE_PERCENT",
"parameters": { "percent": 5 },
"productIds": ["uuid1", "uuid2"],
"results": {
"total": 20,
"successful": 18,
"failed": 2,
"failures": [{ "productId": "uuid2", "error": "PRICE_NOT_FOUND" }]
}
}
}
```
Each individual product mutation is NOT logged separately for bulk operations (that would create N audit entries for one admin action).
## 10. RBAC
Bulk operations require dedicated permission:
```
products.bulk_update
```
In the current `permissions.ts`, `admin` role gets all permissions via `can(role, _)`. Future granular permissions can add `products.bulk_update` to the `Permission` type.
## 11. Extension Points
The BulkUpdateService uses a handler registry:
```typescript
interface BulkOperationHandler {
readonly type: string; // e.g., 'INCREASE_PERCENT'
readonly domain: string; // e.g., 'price', 'category'
validate(params: unknown): ValidationResult;
buildPreview(product: Product, params: unknown): PreviewItem;
apply(product: Product, params: unknown): ProductPatch;
}
const HANDLERS: ReadonlyMap<string, BulkOperationHandler> = new Map([
['INCREASE_PERCENT', new IncreasePercentHandler()],
['ADD_CATEGORY', new AddCategoryHandler()],
// Future: ['STOCK_SET', new StockSetHandler()]
]);
```
Adding a new operation = adding one new handler class + registering it. No changes to routing or execution engine.
## 12. Out of Scope
- Stock bulk update (future)
- Visibility bulk update (future)
- Brand bulk update (future)
- Asynchronous background jobs (not needed at expected data volume)
- Operation cancellation (not needed at expected data volume)
- Bulk export (different feature)
## 13. Feature Flag
```
admin_bulk_update
default: false
```
Flip to true after MVP is tested.
## 14. Acceptance Criteria
See `TESTS.md`.

View File

@@ -0,0 +1,306 @@
# ADMIN BULK UPDATE — TASKS.md
## Backend
### BULK-BE-001
**ID**: BULK-BE-001
**Title**: BulkOperation domain types
**Goal**: Define all types for bulk operations
**Why**: Shared contracts for handlers, orchestrator, and API
**Dependencies**: None
**Applications**: Backend
**Modules**: bulk/domain/bulk-operation.ts
**Database impact**: None
**API contracts**: Shapes of request/response objects
**Permissions**: N/A
**Implementation**: Define BulkOperationType enum, BulkOperationParams union, PreviewItem, PreviewResult, ExecuteResult interfaces; ValidationResult
**Tests**: Type tests (TypeScript compilation)
**Expected blast radius**: Low
**Definition of Done**: All types defined; TypeScript compiles without errors
### BULK-BE-002
**ID**: BULK-BE-002
**Title**: BulkOperationHandler port + handler registry
**Goal**: Define the extension interface for bulk operations
**Why**: Allows adding future operations without touching core orchestrator
**Dependencies**: BULK-BE-001
**Applications**: Backend
**Modules**: bulk/domain/ports.ts
**Database impact**: None
**API contracts**: BulkOperationHandler interface
**Permissions**: N/A
**Implementation**: Define `BulkOperationHandler` interface; create `HANDLERS` registry map; export from bulk/index.ts
**Tests**: Handler lookup by type works; unknown type returns undefined
**Expected blast radius**: Low
**Definition of Done**: Interface exists; registry is extensible; adding a handler requires only adding to registry
### BULK-BE-003
**ID**: BULK-BE-003
**Title**: Price bulk operation handlers
**Goal**: Implement SET, INCREASE_PERCENT, DECREASE_PERCENT, INCREASE_FIXED, DECREASE_FIXED
**Why**: Initial price operations
**Dependencies**: BULK-BE-002
**Applications**: Backend
**Modules**: bulk/application/handlers/price-handlers.ts
**Database impact**: None
**API contracts**: None (domain)
**Permissions**: N/A
**Implementation**: Five handler classes; each implements validate(), preview(), buildPatch(); rounding uses Math.round(); VAT is stored as net price
**Tests**:
- BULK-BE-UT-001: INCREASE_PERCENT 5% on 350 cents → 368 cents
- BULK-BE-UT-002: DECREASE_PERCENT 5% on 350 cents → 333 cents
- BULK-BE-UT-003: INCREASE_FIXED 50 on 350 cents → 400 cents
- BULK-BE-UT-004: DECREASE_FIXED 50 on 350 cents → 300 cents
- BULK-BE-UT-005: DECREASE_FIXED below 0 → floor at 0
- BULK-BE-UT-006: SET_PRICE 500 → 500
- BULK-BE-UT-007: invalid percent (0, negative, >1000) → ValidationResult.valid=false
- BULK-BE-UT-008: missing VAT rate → ValidationResult.valid=false
- BULK-BE-UT-009: product without price → PreviewItem.status=failed, error=PRICE_NOT_FOUND
**Expected blast radius**: Low
**Definition of Done**: All five handlers produce correct price calculations; invalid params rejected at validate()
### BULK-BE-004
**ID**: BULK-BE-004
**Title**: Category bulk operation handlers
**Goal**: Implement ADD_CATEGORY, REMOVE_CATEGORY, REPLACE_CATEGORIES
**Why**: Initial category operations
**Dependencies**: BULK-BE-002
**Applications**: Backend
**Modules**: bulk/application/handlers/category-handlers.ts
**Database impact**: None
**API contracts**: None (domain)
**Permissions**: N/A
**Implementation**: Three handler classes; ADD deduplicates; REMOVE filters; REPLACE replaces entirely; validates categoryId exists (optional — can be deferred)
**Tests**:
- BULK-BE-UT-010: ADD_CATEGORY to product with 2 categories → 3 categories
- BULK-BE-UT-011: ADD_CATEGORY with duplicate → no-op (deduplicated)
- BULK-BE-UT-012: REMOVE_CATEGORY from product with 3 categories → 2 categories
- BULK-BE-UT-013: REMOVE_CATEGORY not present → no change
- BULK-BE-UT-014: REPLACE_CATEGORIES with 2 new categories → exactly those 2
- BULK-BE-UT-015: ADD without categoryId → ValidationResult.valid=false
**Expected blast radius**: Low
**Definition of Done**: All three handlers produce correct category patches; validation rejects invalid input
### BULK-BE-005
**ID**: BULK-BE-005
**Title**: BulkService orchestrator
**Goal**: Orchestrate preview + execute; integrate with audit
**Why**: Core business logic tying handlers, repository, and audit together
**Dependencies**: BULK-BE-001, BULK-BE-002, BULK-BE-003, BULK-BE-004
**Applications**: Backend
**Modules**: bulk/application/bulk-service.ts
**Database impact**: None
**API contracts**: None (service)
**Permissions**: N/A
**Implementation**: BulkService with preview() and execute(); execute() uses transaction; logs audit entry on execution; rollback on error; generate operationId
**Tests**:
- BULK-BE-UT-020: preview() calls handler.validate() and handler.preview() for each product
- BULK-BE-UT-021: execute() calls handler.buildPatch() for each valid product
- BULK-BE-UT-022: execute() logs one audit entry per bulk operation
- BULK-BE-UT-023: execute() rollback on error → no product modified
- BULK-BE-UT-024: unknown operation type → AppError(400)
**Expected blast radius**: Medium — changes persistence
**Definition of Done**: BulkService correctly orchestrates all operations; audit entry is complete; transaction is atomic
### BULK-BE-006
**ID**: BULK-BE-006
**Title**: BulkProductRepository — read products with pricing in batch
**Goal**: Efficiently load product+price data for bulk operations
**Why**: Avoids N queries for N products
**Dependencies**: BULK-BE-001
**Applications**: Backend
**Modules**: bulk/infrastructure/pg-bulk-repository.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Single SQL query to JOIN catalog_products + pricing_variants; returns ProductWithPrice[] (internal type); handles products with no price gracefully
**Tests**: BULK-BE-IT-001: batch load of 20 products returns correct data; BULK-BE-IT-002: product without price returns null price field
**Expected blast radius**: Low
**Definition of Done**: Products loaded in one query; missing price handled gracefully
### BULK-BE-007
**ID**: BULK-BE-007
**Title**: Bulk API routes — preview and execute
**Goal**: Expose bulk operations via REST API
**Why**: Admin UI needs these endpoints
**Dependencies**: BULK-BE-005, BULK-BE-006
**Applications**: Backend
**Modules**: bulk/api/bulk.routes.ts
**Database impact**: None (read for preview; write for execute)
**API contracts**: POST /admin/bulk/preview, POST /admin/bulk/execute
**Permissions**: admin role required on both
**Implementation**: Register routes in build-app.ts; parse request body; call BulkService; serialize response; handle errors with AppError
**Tests**:
- BULK-BE-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
- BULK-BE-IT-011: POST /admin/bulk/preview with invalid params → 422
- BULK-BE-IT-012: POST /admin/bulk/preview without auth → 401
- BULK-BE-IT-013: POST /admin/bulk/execute with valid params → 200 + ExecuteResult
- BULK-BE-IT-014: POST /admin/bulk/execute → applies changes to DB; products updated
- BULK-BE-IT-015: audit log entry created after execute
**Expected blast radius**: Low — new routes
**Definition of Done**: Both routes respond correctly; auth enforced; changes persisted
### BULK-BE-008
**ID**: BULK-BE-008
**Title**: Wire bulk module into build-app.ts
**Goal**: Register bulk routes with Fastify
**Why**: Module must be mounted in the application
**Dependencies**: BULK-BE-007
**Applications**: Backend
**Modules**: app/build-app.ts
**Database impact**: None
**API contracts**: None
**Permissions**: N/A
**Implementation**: Import registerBulkRoutes; call in build-app.ts alongside other modules; pass pool, authenticate, audit
**Tests**: Integration test that /admin/bulk/preview and /admin/bulk/execute respond
**Expected blast radius**: Low
**Definition of Done**: Bulk routes accessible at /admin/bulk/*
## Admin
### BULK-ADM-001
**ID**: BULK-ADM-001
**Title**: Bulk Update page shell + product selection
**Goal**: Product selection step of the wizard
**Why**: Admin needs to pick which products to update
**Dependencies**: BULK-BE-007
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None
**API contracts**: GET /catalog/products (existing)
**Permissions**: products.bulk_update
**Implementation**: Full-width page with product table; checkbox column; search/filter bar; selected count badge; "Continue to Configure" button
**Tests**: Products load; checkboxes select/deselect; selected count accurate; filter narrows selection
**Expected blast radius**: Low
**Definition of Done**: Admin can select products and see selection count
### BULK-ADM-002
**ID**: BULK-ADM-002
**Title**: Operation selector + configuration step
**Goal**: Choose and configure the bulk operation type
**Why**: Second step of the wizard
**Dependencies**: BULK-ADM-001
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None
**API contracts**: None yet
**Permissions**: products.bulk_update
**Implementation**: Two cards: Price / Categories; selecting Price shows sub-options (SET, +%, -%, +FIXED, -FIXED) with parameter inputs; selecting Categories shows (ADD, REMOVE, REPLACE) with category picker; Back button returns to selection
**Tests**: Selecting each operation shows correct sub-options; invalid input shows error state
**Expected blast radius**: Low
**Definition of Done**: Admin can select operation type and enter parameters
### BULK-ADM-003
**ID**: BULK-ADM-003
**Title**: Preview step
**Goal**: Show proposed changes before committing
**Why**: Critical safety step — see what will change before writing
**Dependencies**: BULK-BE-007, BULK-ADM-002
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: None (dry-run)
**API contracts**: POST /admin/bulk/preview
**Permissions**: products.bulk_update
**Implementation**: Call preview API with selected productIds, operation, parameters; display table (Product | Current | Proposed | Status); show summary bar ("18 valid, 2 failed"); Back button to Configure
**Tests**: Preview loads; shows correct current→proposed values; failed rows highlighted with error reason
**Expected blast radius**: Low
**Definition of Done**: Preview accurately reflects what execute() would do; no product modified
### BULK-ADM-004
**ID**: BULK-ADM-004
**Title**: Confirmation + execution step
**Goal**: Require explicit confirmation before executing bulk operation
**Why**: Safety barrier — prevent accidental bulk changes
**Dependencies**: BULK-ADM-003, BULK-BE-007
**Applications**: Admin
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
**Database impact**: MODIFIES PRODUCTS
**API contracts**: POST /admin/bulk/execute
**Permissions**: products.bulk_update
**Implementation**: Show warning message ("X changes will be applied. This cannot be undone."); [Cancel] [Apply Changes] buttons; on Apply: call execute API; on success: show result summary; on error: show error + retry option
**Tests**: Confirmation dialog shows before any write; cancel returns to preview; apply calls execute; result shows success/failure counts
**Expected blast radius**: HIGH — modifies products
**Definition of Done**: Confirmation required; execute called only after explicit confirmation; result displayed accurately
### BULK-ADM-005
**ID**: BULK-ADM-005
**Title**: Navigation sidebar — add Bulk Update link
**Goal**: Make bulk update accessible from admin navigation
**Why**: Discoverability
**Dependencies**: BULK-ADM-001
**Applications**: Admin
**Modules**: apps/admin/src/lib/permissions.ts
**Database impact**: None
**API contracts**: None
**Permissions**: products.bulk_update
**Implementation**: Add to NAV_ITEMS: { href: '/bulk-update', label: 'Actualización masiva', icon: '📦', permission: 'products.bulk_update' }
**Tests**: Bulk Update appears in sidebar for admin users
**Expected blast radius**: Low
**Definition of Done**: Nav item visible in admin sidebar
## QA
### BULK-QA-001
**ID**: BULK-QA-001
**Title**: Bulk update E2E tests
**Goal**: End-to-end validation of complete bulk update flow
**Why**: Critical safety feature — full flow must work
**Dependencies**: BULK-BE-007, BULK-ADM-004
**Applications**: QA
**Modules**: E2E tests
**Tests**:
- BULK-QA-UT-001: Select 5 products → preview shows correct proposed prices
- BULK-QA-UT-002: Preview does NOT modify any product
- BULK-QA-UT-003: Confirm → products actually updated in DB
- BULK-QA-UT-004: Audit log entry exists for bulk operation
- BULK-QA-UT-005: 5% increase on products with VAT general → correct net price applied
- BULK-QA-UT-006: ADD_CATEGORY → categories added without removing existing
- BULK-QA-UT-007: REMOVE_CATEGORY → category removed, others preserved
- BULK-QA-UT-008: REPLACE_CATEGORIES → only new categories remain
- BULK-QA-UT-009: Product without price → failed in preview and in execute (not silently skipped)
- BULK-QA-UT-010: Non-admin user → 401 on /admin/bulk/execute
**Expected blast radius**: N/A
**Definition of Done**: All tests pass; QA sign-off obtained
---
## Task Summary Table
| Task | Layer | Feature | Depends On | Risk | Parallel |
|------|-------|---------|-----------|------|---------|
| BULK-BE-001 | Backend | Domain types | — | Low | * |
| BULK-BE-002 | Backend | Handler interface | BULK-BE-001 | Low | * |
| BULK-BE-003 | Backend | Price handlers | BULK-BE-002 | Low | * |
| BULK-BE-004 | Backend | Category handlers | BULK-BE-002 | Low | * |
| BULK-BE-005 | Backend | BulkService orchestrator | BULK-BE-001..004 | Medium | * |
| BULK-BE-006 | Backend | BulkProductRepository | BULK-BE-001 | Low | * |
| BULK-BE-007 | Backend | API routes | BULK-BE-005, BULK-BE-006 | Medium | * |
| BULK-BE-008 | Backend | Wire into build-app | BULK-BE-007 | Low | * |
| BULK-ADM-001 | Admin | Product selection UI | BULK-BE-007 | Low | * |
| BULK-ADM-002 | Admin | Operation config step | BULK-ADM-001 | Low | * |
| BULK-ADM-003 | Admin | Preview step | BULK-ADM-002, BULK-BE-007 | Low | * |
| BULK-ADM-004 | Admin | Confirm + execute step | BULK-ADM-003, BULK-BE-007 | Medium | * |
| BULK-ADM-005 | Admin | Sidebar nav link | BULK-ADM-001 | Low | * |
| BULK-QA-001 | QA | E2E tests | BULK-BE-007, BULK-ADM-004 | Medium | After all |
**Parallel group**: BULK-BE-001 and BULK-BE-002 are independent; BULK-BE-003 and BULK-BE-004 are parallel (both depend on BE-002); BULK-BE-005 depends on all four handlers; BULK-BE-006 independent; BULK-BE-007 depends on BE-005 + BE-006; Admin tasks BULK-ADM-001 through BULK-ADM-005 are sequential (wizard flow).
**Recommended order**:
1. BULK-BE-001 + BULK-BE-002 + BULK-BE-006 (parallel, no deps)
2. BULK-BE-003 + BULK-BE-004 (parallel, both depend on BE-002)
3. BULK-BE-005 (depends on BE-001..004)
4. BULK-BE-007 (depends on BE-005, BE-006)
5. BULK-BE-008 (depends on BE-007)
6. BULK-ADM-001 (depends on BE-007)
7. BULK-ADM-002 through BULK-ADM-004 (sequential wizard)
8. BULK-ADM-005 (parallel with any ADM task)
9. BULK-QA-001 (after everything)
**High-risk tasks**:
- BULK-BE-005: Transaction logic must be correct — wrong rollback = data corruption
- BULK-ADM-004: Executes actual DB writes — requires confirmation UI; must not auto-submit
- BULK-BE-007: New API endpoints with admin auth — verify auth enforced
**MVP boundary**: BULK-BE-001 through BULK-BE-008 (backend) + BULK-ADM-001 through BULK-ADM-005. BULK-QA-001 is required before go-live.
**Migration risks**: None — bulk update is purely additive; no existing data migration required.

View File

@@ -0,0 +1,166 @@
# ADMIN BULK UPDATE — TESTS.md
## Unit Tests
### Price Handlers
```
BULK-UT-001: INCREASE_PERCENT 5% on 350 cents → proposed = 368 cents
BULK-UT-002: INCREASE_PERCENT 100% on 200 cents → proposed = 400 cents
BULK-UT-003: DECREASE_PERCENT 5% on 350 cents → proposed = 333 cents
BULK-UT-004: DECREASE_PERCENT 50% on 200 cents → proposed = 100 cents
BULK-UT-005: DECREASE_FIXED 50 on 350 cents → proposed = 300 cents
BULK-UT-006: INCREASE_FIXED 50 on 350 cents → proposed = 400 cents
BULK-UT-007: DECREASE_FIXED 500 on 350 cents → proposed = 0 (floor at 0)
BULK-UT-008: SET_PRICE 500 → proposed = 500
BULK-UT-009: INCREASE_PERCENT with percent=0 → ValidationResult.valid=false
BULK-UT-010: INCREASE_PERCENT with percent=-5 → ValidationResult.valid=false
BULK-UT-011: INCREASE_PERCENT with percent=1001 → ValidationResult.valid=false
BULK-UT-012: INCREASE_PERCENT without vatRate → ValidationResult.valid=false
BULK-UT-013: DECREASE_PERCENT with vatRate=general → validation passes
BULK-UT-014: preview() for product without price → status=failed, error=PRICE_NOT_FOUND
BULK-UT-015: preview() for product with price → status=valid, proposedPriceCents calculated
BULK-UT-016: buildPatch() returns correct { pricing: { netUnitAmountCents, vatRate } }
```
### Category Handlers
```
BULK-UT-020: ADD_CATEGORY to product with [A, B] → proposedCategoryIds = [A, B, C] (C added)
BULK-UT-021: ADD_CATEGORY where C already present → no duplicate (deduplicated)
BULK-UT-022: REMOVE_CATEGORY from product with [A, B, C] → [A, B]
BULK-UT-023: REMOVE_CATEGORY where not present → no change
BULK-UT-024: REPLACE_CATEGORIES with [X, Y] → proposedCategoryIds = [X, Y]
BULK-UT-025: REPLACE_CATEGORIES with empty array → proposedCategoryIds = []
BULK-UT-026: ADD_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-027: REMOVE_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-028: buildPatch() returns correct { categoryIds: [...] }
```
### BulkService
```
BULK-UT-030: preview() returns items for all provided productIds
BULK-UT-031: preview() returns summary with correct valid/failed counts
BULK-UT-032: preview() throws AppError(400) for unknown operation type
BULK-UT-033: preview() throws AppError(422) for invalid parameters
BULK-UT-034: execute() returns operationId
BULK-UT-035: execute() returns COMPLETED when all succeed
BULK-UT-036: execute() returns COMPLETED_WITH_ERRORS when some fail
BULK-UT-037: execute() calls audit.log() with correct metadata
BULK-UT-038: execute() rollback on error → no product modified (verify by checking DB)
```
## Integration Tests
### Database
```
BULK-IT-001: catalog_products.updated_at updated after bulk price change
BULK-IT-002: pricing_variants.net_unit_amount_cents updated after SET_PRICE
BULK-IT-003: catalog_product_categories updated correctly for ADD_CATEGORY
BULK-IT-004: catalog_product_categories updated correctly for REMOVE_CATEGORY
BULK-IT-005: catalog_product_categories updated correctly for REPLACE_CATEGORIES
BULK-IT-006: Concurrent bulk operations on overlapping product sets → no race condition
BULK-IT-007: Price changed by bulk update → subsequent checkout uses new price
```
### API Routes
```
BULK-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
BULK-IT-011: POST /admin/bulk/preview with DECREASE_PERCENT → 200 + PreviewResult
BULK-IT-012: POST /admin/bulk/preview with ADD_CATEGORY → 200 + PreviewResult
BULK-IT-013: POST /admin/bulk/preview with REMOVE_CATEGORY → 200 + PreviewResult
BULK-IT-014: POST /admin/bulk/preview with REPLACE_CATEGORIES → 200 + PreviewResult
BULK-IT-015: POST /admin/bulk/preview with invalid percent → 422 + error message
BULK-IT-016: POST /admin/bulk/preview without session cookie → 401
BULK-IT-017: POST /admin/bulk/preview with customer role → 403
BULK-IT-020: POST /admin/bulk/execute with valid SET_PRICE → 200 + ExecuteResult + DB updated
BULK-IT-021: POST /admin/bulk/execute with valid INCREASE_PERCENT → 200 + DB updated
BULK-IT-022: POST /admin/bulk/execute with valid ADD_CATEGORY → 200 + DB updated
BULK-IT-023: POST /admin/bulk/execute → security_audit_log row created
BULK-IT-024: POST /admin/bulk/execute without auth → 401
BULK-IT-025: POST /admin/bulk/execute with customer role → 403
BULK-IT-026: POST /admin/bulk/execute with partial failures → 200 + COMPLETED_WITH_ERRORS
```
## Admin UI Tests
```
BULK-ADM-UT-001: Page loads with product table and checkboxes
BULK-ADM-UT-002: Selecting 3 products updates "3 products selected" counter
BULK-ADM-UT-003: Deselecting all shows "0 products selected"
BULK-ADM-UT-004: Search filter narrows product list
BULK-ADM-UT-005: "Continue" button disabled when 0 products selected
BULK-ADM-UT-010: Clicking Price card shows SET, +%, -%, +FIXED, -FIXED options
BULK-ADM-UT-011: Clicking Categories card shows ADD, REMOVE, REPLACE options
BULK-ADM-UT-012: INCREASE_PERCENT selected → percentage input appears
BULK-ADM-UT-013: INCREASE_PERCENT with invalid input → error state on field
BULK-ADM-UT-014: ADD_CATEGORY selected → category picker appears
BULK-ADM-UT-020: Preview shows product rows with current → proposed values
BULK-ADM-UT-021: Failed rows highlighted in red with error reason
BULK-ADM-UT-022: Summary bar shows "X valid, Y failed"
BULK-ADM-UT-023: "Back to Configure" navigates back
BULK-ADM-UT-024: Preview does NOT show [Apply Changes] button
BULK-ADM-UT-030: Confirmation dialog shows warning text with correct count
BULK-ADM-UT-031: "Cancel" returns to preview without calling execute
BULK-ADM-UT-032: "Apply Changes" calls execute API once
BULK-ADM-UT-033: Result shows success count and failure count
BULK-ADM-UT-034: "New Bulk Update" button resets wizard to step 1
```
## Given/When/Then Acceptance Criteria
```
GIVEN 20 selected products with valid prices
WHEN operator previews a 5% price increase
THEN no product is modified
AND all 20 proposed prices are calculated by the backend
AND the preview shows current and proposed values
GIVEN a product without a price variant
WHEN operator previews any price operation on that product
THEN the preview item has status=failed and error=PRICE_NOT_FOUND
AND the summary shows failed=1
GIVEN 20 selected products
WHEN operator clicks "Apply Changes" on the confirmation step
THEN the backend applies the changes in a single transaction
AND the security_audit_log receives one entry for the bulk operation
AND each affected product's updated_at is updated
AND the result shows successful=20, failed=0
GIVEN a bulk execute with 2 products invalid and 18 valid
WHEN the operation runs
THEN the 18 valid products are updated
AND the 2 invalid products are not modified
AND the result shows COMPLETED_WITH_ERRORS with failure details
AND the audit log entry captures the partial failure
GIVEN a non-admin user
WHEN they call POST /admin/bulk/execute
THEN the backend returns 403 Forbidden
AND no audit entry is created
AND no product is modified
GIVEN a preview shows 18 valid changes
WHEN a second operator applies a price change to one of those products before execute
THEN the execute still applies the original preview's proposed price
AND no optimistic concurrency check is silently bypassed (document this behavior)
```
---
## Preview Staleness Policy
Preview data may be stale when execute runs (another admin changed a price). Current design: execute uses the same calculation on current DB state, which is correct behavior for bulk updates (we want to apply the percentage to whatever the current price is, not the stale preview price).
If stricter concurrency control is needed (e.g., "apply only if price hasn't changed since preview"), this requires:
- `expected_version` or `updated_at` in preview response
- Compare at execute time
- This is a future enhancement, not MVP scope.

View File

@@ -0,0 +1,311 @@
# EXPIRATION TRACKING — DESIGN.md
## 1. Architecture
```
┌──────────────────────────────────┐
│ Product (extended) │
│ expiration_tracking_enabled │
└──────────────┬───────────────────┘
│ determines behavior of
┌──────────────▼───────────────────┐
│ InventoryDomain │
│ │
│ variant-level stock (legacy) │ ← non-tracking products
│ lot-level stock (new) │ ← tracking products
│ └─ FEFO allocation │
│ └─ expired exclusion │
└──────────────┬───────────────────┘
│ InventoryServicePort
┌──────────────▼───────────────────┐
│ Checkout │
│ calls reserve() → gets lots │
│ NO lot querying │
└──────────────────────────────────┘
```
## 2. Database
### 2.1 New Table: `inventory_lots`
```sql
CREATE TABLE inventory_lots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL REFERENCES catalog_variants(id)
ON DELETE CASCADE,
quantity integer NOT NULL DEFAULT 0
CHECK (quantity >= 0),
expiration_date date
-- NULL when product has expiration_tracking_enabled=false
-- REQUIRED when product has expiration_tracking_enabled=true
CONSTRAINT no_past_expiry CHECK (
expiration_date IS NULL OR expiration_date >= CURRENT_DATE
),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX inventory_lots_variant_id_idx ON inventory_lots(variant_id);
CREATE INDEX inventory_lots_expiration_idx ON inventory_lots(expiration_date)
WHERE expiration_date IS NOT NULL;
```
### 2.2 Product Extension: `catalog_products`
```sql
-- New column
ALTER TABLE catalog_products
ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;
```
### 2.3 Movements Extension: `inventory_movements`
```sql
-- New operation types
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check;
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check CHECK (
operation IN (
'reserve', 'release', 'confirm', 'set_available',
'lot_create', 'lot_adjust', 'lot_delete'
)
);
-- New columns (optional, for lot traceability)
ALTER TABLE inventory_movements
ADD COLUMN lot_id uuid REFERENCES inventory_lots(id) ON DELETE SET NULL;
```
## 3. Domain Layer
### 3.1 New Port: `InventoryLotRepository`
```typescript
export interface InventoryLotRepository {
create(input: CreateLotCommand): Promise<InventoryLot>;
findById(id: string): Promise<InventoryLot | undefined>;
findByVariantId(variantId: string): Promise<InventoryLot[]>;
update(id: string, patch: Partial<{ quantity: number; expirationDate: Date }>): Promise<InventoryLot | undefined>;
delete(id: string): Promise<void>;
// Derived queries
findAvailableLots(variantId: string): Promise<InventoryLot[]>; // expiration > today, quantity > 0
findNearExpiry(variantId: string, withinDays: number): Promise<InventoryLot[]>;
findExpired(variantId: string): Promise<InventoryLot[]>;
}
```
### 3.2 New Service: `LotService`
```typescript
export class LotService {
constructor(
private readonly lots: InventoryLotRepository,
private readonly products: ProductRepository, // read-only, to check policy
) {}
async createLot(cmd: CreateLotCommand): Promise<InventoryLot> {
const product = await this.products.findByVariantId(cmd.variantId);
const requiresExpiry = product?.expiration_tracking_enabled ?? false;
if (requiresExpiry && !cmd.expirationDate) {
throw new MissingExpirationDateError(cmd.variantId);
}
if (cmd.expirationDate && cmd.expirationDate < today()) {
throw new PastExpirationDateError(cmd.expirationDate);
}
return this.lots.create(cmd);
}
async getAvailableStock(variantId: string): Promise<number> {
const lots = await this.lots.findAvailableLots(variantId);
return lots.reduce((sum, lot) => sum + lot.quantity, 0);
}
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
// FEFO: sort by expiration_date ASC, allocate from earliest
const lots = await this.lots.findAvailableLots(variantId);
// ... allocation logic
}
}
export interface LotAllocation {
lotId: string;
allocatedQuantity: number;
}
```
### 3.3 Extended `InventoryServicePort`
```typescript
export interface InventoryServicePort {
// existing — unchanged contract
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
}
```
`InventoryService` (the default implementation) is extended internally to delegate to `LotService` when the product has expiration tracking enabled.
### 3.4 New Feature Flag Check
```typescript
// In InventoryService.checkAvailability
const featureEnabled = await this.flags.isEnabled('expiration_tracking');
if (!featureEnabled) {
// existing variant-level behavior
return this.repo.findByVariantId(variantId).then(toAvailability);
}
// With feature enabled:
const product = await this.productRepo.findByVariantId(variantId);
if (!product.expiration_tracking_enabled) {
return this.repo.findByVariantId(variantId).then(toAvailability);
}
const available = await this.lotService.getAvailableStock(variantId);
return { available: available > 0, availableQuantity: available };
```
## 4. Checkout Integration
The checkout injects `InventoryServicePort`. With the feature flag off, behavior is unchanged. With it on, `InventoryService` internally uses FEFO lot allocation.
No changes to `CheckoutService` are required. The `reservedVariantIds` in the checkout response are sufficient for current order tracking.
If lot-level traceability is added later:
- Add `lotIds: string[]` to `OrderLine`
- Populate at `InventoryService.confirm()` time
- Add read-only lot info to order detail API
## 5. FEFO Implementation
```typescript
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
const lots = await this.lots.findAvailableLots(variantId);
// findAvailableLots already filters: expiration_date > today AND quantity > 0
// AND orders by expiration_date ASC (FEFO)
const allocations: LotAllocation[] = [];
let remaining = quantity;
for (const lot of lots) {
if (remaining <= 0) break;
const take = Math.min(lot.quantity, remaining);
allocations.push({ lotId: lot.id, allocatedQuantity: take });
remaining -= take;
}
if (remaining > 0) {
throw new InsufficientStockError(variantId, quantity);
}
return allocations;
}
```
Reservation then deducts from each lot in order:
```typescript
async reserveFromLots(allocations: LotAllocation[]): Promise<void> {
for (const alloc of allocations) {
await this.lots.adjustQuantity(alloc.lotId, -alloc.allocatedQuantity);
}
}
```
## 6. API Routes
### 6.1 New Routes (registered in `build-app.ts`)
```
GET /inventory/lots (admin) — list lots with filters
POST /inventory/lots (admin) — create lot
GET /inventory/lots/:id (admin) — get lot
PATCH /inventory/lots/:id (admin) — update lot
DELETE /inventory/lots/:id (admin) — delete lot
```
Existing routes unchanged:
```
GET /inventory/:variantId/availability
PUT /inventory/:variantId/stock
POST /inventory/:variantId/reservations
POST /inventory/:variantId/reservations/release
POST /inventory/:variantId/reservations/confirm
```
### 6.2 GET /inventory/lots
Query params:
```
?variant_id=uuid
?filter=expiring|expired|all|no-expiry
?limit=20
?offset=0
```
Response:
```json
{
"items": [
{
"id": "uuid",
"variantId": "uuid",
"quantity": 20,
"expirationDate": "2026-09-10",
"status": "VALID",
"createdAt": "2026-08-01T..."
}
],
"total": 42
}
```
Status is derived (not stored):
- `EXPIRED`: expiration_date < today
- `NEAR_EXPIRY`: expiration_date <= today + FLAG_EXPIRY_WARNING_DAYS
- `VALID`: otherwise
### 6.3 POST /inventory/lots
Request:
```json
{
"variantId": "uuid",
"quantity": 20,
"expirationDate": "2026-09-10"
}
```
Response: `201 Created` with lot object.
Errors:
- `422 MISSING_EXPIRATION_DATE`: product has expiry tracking but no date provided
- `422 PAST_EXPIRATION_DATE`: date is in the past
## 7. Audit
Every lot mutation is logged via existing `AuditLogger`:
```
action: "lot.create" | "lot.adjust" | "lot.delete"
target: "inventory_lot:{id}"
metadata: { variantId, quantity, expirationDate, actor }
```
## 8. Extension Points
The `LotService` is a clean domain service. Future operations can be added without modifying `InventoryService`:
```
LotService.adjustQuantity(lotId, delta)
LotService.mergeLots(sourceLotId, targetLotId)
LotService.expireLot(lotId) → sets quantity to 0, keeps for audit
```
The `InventoryLotRepository` interface cleanly isolates persistence.

View File

@@ -0,0 +1,105 @@
# EXPIRATION TRACKING — MIGRATION.md
## Migration Philosophy
**No breaking changes to existing products or checkout flow.**
All existing products must retain their current behavior after migration.
## Phase 0: Feature Flag Off
The feature flag `expiration_tracking` starts as **off**. All code paths default to existing behavior.
## Phase 1: Database Migration (Zero-downtime safe)
```sql
-- 1. Add column to products (nullable, default false)
ALTER TABLE catalog_products
ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;
-- 2. Create lots table
CREATE TABLE inventory_lots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL REFERENCES catalog_variants(id) ON DELETE CASCADE,
quantity integer NOT NULL DEFAULT 0 CHECK (quantity >= 0),
expiration_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX inventory_lots_variant_id_idx ON inventory_lots(variant_id);
CREATE INDEX inventory_lots_expiration_idx ON inventory_lots(expiration_date)
WHERE expiration_date IS NOT NULL;
-- 3. Extend movements for lot traceability
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check;
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check CHECK (
operation IN (
'reserve', 'release', 'confirm', 'set_available',
'lot_create', 'lot_adjust', 'lot_delete'
)
);
ALTER TABLE inventory_movements
ADD COLUMN lot_id uuid REFERENCES inventory_lots(id) ON DELETE SET NULL;
```
All changes are additive. No existing data is modified or deleted.
## Phase 2: Seed Existing Stock as Lots (for tracking-enabled products only)
For each existing product where `expiration_tracking_enabled = true`:
- Query `inventory_stock` for each variant
- Create one `inventory_lots` entry per variant with:
- `quantity = inventory_stock.available`
- `expiration_date = NULL` (admin must add expiration dates)
**Products with `expiration_tracking_enabled = false`**: no lots created. Existing variant-level stock table continues to be the source of truth.
## Phase 3: Code Deployment
Deploy code with:
- Feature flag `expiration_tracking = off`
- New domain classes (LotService, InventoryLotRepository, new routes)
- All existing paths still work via the flag check
## Phase 4: Admin Onboarding
Admin can now:
1. Enable expiration tracking on products
2. Create inventory lots with expiration dates
3. See lot-level stock view
**Admin instruction**: When enabling expiration tracking for a product, the admin should create inventory lots and set expiration dates before the product goes on sale. Existing stock without lots is not tracked.
## Phase 5: Flip Feature Flag
After admin confirms all required products have lots configured:
```
FLAG_EXPIRATION_TRACKING = true
```
## Rollback Plan
1. Set `FLAG_EXPIRATION_TRACKING = false`
2. Existing data is preserved (lots table + new columns)
3. Code paths revert to variant-level behavior
4. No data loss
## What Breaks if We Skip Feature Flag
If `expiration_tracking` is always on:
- Existing products without expiration_tracking flag → still create lots with null expiry dates
- Checkout continues to work (null expiry = always valid)
- No user-facing breakage
The flag is for gradual rollout and operational safety, not a hard architectural requirement.
## No-Tracking Products After Migration
Products with `expiration_tracking_enabled = false`:
- Continue using `inventory_stock` table
- `GET /inventory/:variantId/availability` uses variant-level query
- No lots are created or queried
- Behavior is byte-for-byte identical to pre-migration

View File

@@ -0,0 +1,247 @@
# EXPIRATION TRACKING — SPEC.md
## 1. Concept & Vision
MercadoDeVida sells perishable goods (fresh food, supplements, cosmetics) that require expiration tracking. The platform must distinguish between **lot-level inventory** (which can expire) and **variant-level inventory** (which already exists). Expiration dates belong to inventory lots, not products. Products only declare whether they require expiration tracking. Expired lots remain visible for audit/waste tracking but contribute zero sellable units.
## 2. Design Principles
- **LOT OWNS EXPIRATION**: A product does not expire — a specific inventory arrival (lot) expires.
- **BACKEND IS AUTHORITATIVE**: Admin UX improvements are not authoritative. The backend validates all expiration requirements.
- **NO BREAKING CHANGE**: Products without expiration tracking behave exactly as before. Migration default is `expiration_tracking_enabled = false`.
- **MODULAR**: Expiration logic lives in Inventory domain, not in Checkout. Checkout calls `InventoryService.reserve()` which applies expiration rules internally.
- **AUDITABLE**: All lot mutations are tracked. Expired lots are never silently deleted.
## 3. Domain Model
### 3.1 Product — Extension
```
Product.expiration_tracking_enabled: boolean
default: false
note: products created before migration default to false
```
This boolean determines whether a product's inventory lots must carry expiration dates. It is **not** a computed status — admin configures it per product.
### 3.2 InventoryLot — New Entity
```
InventoryLot
id: uuid (PK)
variant_id: uuid (FK → catalog_variants.id)
quantity: integer (>= 0)
expiration_date: date (nullable; required when expiration_tracking_enabled = true)
created_at: timestamptz
updated_at: timestamptz
```
Each lot represents a single inventory arrival for a variant with its own expiration date.
```
inventory_lots (table)
CONSTRAINT expiration_date_future_or_null
CHECK (expiration_date IS NULL OR expiration_date >= CURRENT_DATE)
```
### 3.3 Availability Calculation
For a variant with expiration tracking:
```
sum(quantity) over lots where expiration_date > today
```
Expired lots (expiration_date < today) contribute zero units.
### 3.4 Reservation Strategy (FEFO)
For expiration-tracking variants, available lots are allocated **FEFO** (First Expired First Out):
```
lots ordered by expiration_date ASC
→ reserve from earliest-expiring lot first
→ when exhausted, move to next
```
For non-expiration-tracking variants: existing variant-level behavior is preserved.
## 4. Expiration Policy
```
Product.expiration_tracking_enabled = false
→ existing variant-level stock (inventory_stock table)
→ no expiration dates required
→ no FEFO
→ backward compatible
Product.expiration_tracking_enabled = true
→ new lot-level inventory (inventory_lots table)
→ expiration_date required on receive
→ FEFO allocation
→ expired lots excluded from available stock
```
## 5. Checkout Integration
Checkout calls `InventoryService.reserve(variantId, quantity)` it does NOT query lots directly.
The Inventory domain applies:
1. Load lots for variant, ordered by expiration_date ASC
2. Allocate from earliest-expiring first
3. Return 409 if insufficient non-expired stock
4. Return reserved lot IDs for order traceability (optional, see Section 7)
This preserves existing `InventoryServicePort` contract. A new port method `reserve(variantId, quantity, {fefo: true})` can be introduced without breaking existing callers.
## 6. Order Line Traceability (Optional)
If lot-level traceability is needed in orders:
```
OrderLine.lotIds: uuid[] (optional)
populated at confirm time
used for: waste reports, supplier claims, recall handling
```
This is NOT required for MVP. Document it as a future task if:
- Regulatory requirements emerge
- Supplier quality claims need lot evidence
- Recall workflows are added
## 7. Admin Requirements
### 7.1 Product Editor — General Tab
Add checkbox:
```
☐ Product has expiration-controlled inventory
```
When disabled: no expiration UI shown.
When enabled: inventory section shows lot management.
### 7.2 Inventory — Lot View
For expiration-tracking variants, replace/extend the existing stock table with:
| Lot | Quantity | Expiration | Status |
|-----|----------|-------------|--------|
| auto | 10 | 10/09/2026 | NEAR_EXPIRY |
| auto | 35 | 15/11/2026 | VALID |
| auto | 4 | 01/08/2026 | EXPIRED |
Statuses computed (not stored):
- `EXPIRED`: expiration_date < today
- `NEAR_EXPIRY`: expiration_date <= today + warning_days
- `VALID`: otherwise
### 7.3 Admin Inventory Filters
```
/admin/inventory?filter=expiring
/admin/inventory?filter=expired
/admin/inventory?filter=all
/admin/inventory?filter=no-expiry
```
Extends existing `/admin/inventory` no separate route.
### 7.4 Admin Dashboard (Secondary)
As separate task, not MVP scope:
- Widget: "X expired lots" + "X lots expiring within 7 days"
- Link to filtered inventory view
## 8. Expiry Warning Threshold
System-wide configuration via `FLAG_EXPIRY_WARNING_DAYS` (env var, default 7).
Per-product configuration is NOT implemented in MVP. Introduce if business requirement emerges.
## 9. Notifications (Future)
Out of MVP scope but documented:
- Cron job identifies near-expiry lots daily
- Sends alert to admin email
- Generates waste report
## 10. API Contracts
### Product (extended)
```
GET /products/:id
→ includes expiration_tracking_enabled: boolean
PATCH /products/:id
body: { expiration_tracking_enabled?: boolean }
→ updates product policy
→ if toggled ON: no migration of existing stock (admin receives instruction)
→ if toggled OFF: existing lots remain visible but no new lots require expiry
```
### Inventory Lots
```
GET /inventory/lots?variant_id=X&filter=expiring|expired|all
→ list lots for variant with status derived
POST /inventory/lots
body: { variant_id, quantity, expiration_date }
→ creates new lot
→ 422 if variant requires expiry but expiration_date missing
→ 422 if expiration_date is in the past
PATCH /inventory/lots/:id
body: { quantity?, expiration_date? }
→ updates lot
→ 422 if expiration_date in past
DELETE /inventory/lots/:id
→ removes lot (audit logged)
```
### Inventory Availability (extended)
```
GET /inventory/:variantId/availability
→ existing behavior
→ for expiration-tracking variants: sums non-expired lot quantities
→ for non-tracking: existing variant-level sum
```
### Inventory Reserve (extended)
```
POST /inventory/:variantId/reservations
→ existing behavior
→ for expiration-tracking variants: FEFO allocation from non-expired lots
→ returns lot allocation info (new field in response)
```
## 11. Migration Strategy
See `MIGRATION.md`.
## 12. Out of Scope
- Lot-level supplier tracking (lot_number, supplier_id, cost, received_at)
- Automatic lot expiration notifications
- Per-product expiry warning threshold
- Order line lot traceability
- Public storefront expiration display (no customer requirement)
- Lot-level pricing
- Partial lot reservations across multiple lots (future)
## 13. Feature Flag
```
expiration_tracking
default: false (off)
enables: lot model, FEFO logic, expiration admin UI
```
Flip to true after migration completes.
## 14. Acceptance Criteria
See `TESTS.md`.

View File

@@ -0,0 +1,248 @@
# EXPIRATION TRACKING — TASKS.md
## Backend
### EXP-BE-001
**ID**: EXP-BE-001
**Title**: Product expiration_tracking_enabled column
**Goal**: Add boolean column to catalog_products
**Why**: Products need to declare whether they require expiration tracking
**Dependencies**: None
**Applications**: Backend
**Modules**: catalog_products table, Product domain, ProductRepository
**Database impact**: ALTER TABLE catalog_products ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false
**API contracts**: GET /products/:id returns field; PATCH /products/:id accepts field
**Permissions**: admin
**Implementation**: Add to NewProduct interface, ProductPatch type, pg-product-repository
**Tests**: Unit — default false; Integration — column exists with correct default
**Migration**: See MIGRATION.md Phase 1
**Expected blast radius**: Low — only affects new product queries
**Definition of Done**: Column exists, defaults to false, persists on create/update
### EXP-BE-002
**ID**: EXP-BE-002
**Title**: InventoryLot domain and repository
**Goal**: New InventoryLot entity and InventoryLotRepository port + PgInventoryLotRepository
**Why**: Core data model for lot-level stock
**Dependencies**: EXP-BE-001
**Applications**: Backend
**Modules**: inventory/domain, inventory/infrastructure
**Database impact**: CREATE TABLE inventory_lots (see DESIGN.md)
**API contracts**: None yet (repository only)
**Permissions**: N/A
**Implementation**: InventoryLot interface, CreateLotCommand, InventoryLotRepository port, PgInventoryLotRepository
**Tests**: Unit — CRUD operations; Integration — table constraints, FK
**Expected blast radius**: Low — new table, no existing data touched
**Definition of Done**: Lot CRUD works, FK to catalog_variants enforced, CHECK constraint on expiration_date
### EXP-BE-003
**ID**: EXP-BE-003
**Title**: LotService — create, update, delete, FEFO allocation
**Goal**: Domain service handling lot business rules
**Why**: Encapsulates expiration validation and FEFO logic
**Dependencies**: EXP-BE-002
**Applications**: Backend
**Modules**: inventory/application
**API contracts**: None (domain service)
**Permissions**: N/A
**Implementation**: LotService class with createLot (validates expiry required), allocateLots (FEFO), adjustQuantity
**Tests**: Unit — missing expiry rejected; past expiry rejected; FEFO order; partial lot allocation
**Expected blast radius**: Low
**Definition of Done**: LotService methods have correct business rules; FEFO allocates from earliest expiry first
### EXP-BE-004
**ID**: EXP-BE-004
**Title**: InventoryLots API routes
**Goal**: CRUD endpoints for lots + availability query with filter
**Why**: Admin needs to manage lots; frontend needs to display them
**Dependencies**: EXP-BE-003
**Applications**: Backend
**Modules**: inventory/api
**API contracts**: GET /inventory/lots, POST /inventory/lots, PATCH /inventory/lots/:id, DELETE /inventory/lots/:id (all admin)
**Permissions**: admin role required
**Implementation**: New route registrations in inventory module; serialize LotService results
**Tests**: Integration — CRUD round-trip; filter=expiring|expired|all
**Expected blast radius**: Low — new routes
**Definition of Done**: All 5 routes respond correctly; filter parameters work; auth enforced
### EXP-BE-005
**ID**: EXP-BE-005
**Title**: InventoryService — integrate LotService for expiration products
**Goal**: Extend existing InventoryService to delegate to LotService when expiry is enabled
**Why**: Preserve existing InventoryServicePort contract while adding expiration support
**Dependencies**: EXP-BE-001, EXP-BE-003
**Applications**: Backend, Checkout
**Modules**: inventory/application, checkout
**API contracts**: Existing InventoryServicePort contract unchanged
**Permissions**: N/A
**Implementation**: In InventoryService, check product.expiration_tracking_enabled; if true, use LotService.getAvailableStock and LotService.allocateLots; feature flag gates behavior
**Tests**: Unit — delegation to LotService for expiry products; existing path for non-expiry products
**Expected blast radius**: Checkout uses InventoryServicePort — must not break
**Definition of Done**: Checkout reserve/confirm still works for both expiry and non-expiry products; FEFO used for expiry products
### EXP-BE-006
**ID**: EXP-BE-006
**Title**: Extend inventory_movements with lot operation types
**Goal**: Track lot_create, lot_adjust, lot_delete in movement audit log
**Why**: Full auditability of lot changes
**Dependencies**: EXP-BE-002
**Applications**: Backend
**Modules**: inventory/infrastructure, security
**Database impact**: ALTER TABLE inventory_movements — new operation types + optional lot_id FK
**API contracts**: Movement audit reflects lot operations
**Permissions**: N/A
**Implementation**: Add operation types in pg-inventory-repository insertMovement calls
**Tests**: Integration — movements logged with correct operation type
**Expected blast radius**: Low
**Definition of Done**: Lot mutations produce audit entries
## Database
### EXP-DB-001
**ID**: EXP-DB-001
**Title**: Run expiration tracking migrations
**Goal**: Apply all DB changes from MIGRATION.md Phase 1
**Why**: Infrastructure for lot model
**Dependencies**: EXP-BE-001 (column on catalog_products), EXP-BE-002 (lots table)
**Applications**: Database
**Modules**: N/A
**Database impact**: See MIGRATION.md Phase 1
**API contracts**: N/A
**Permissions**: DBA
**Implementation**: Add migration file or run raw SQL against dev DB; apply via docker-compose migration pipeline
**Tests**: Verify schema after migration
**Expected blast radius**: Low — additive changes
**Definition of Done**: Migration runs without error; new columns/tables exist with correct constraints
## Admin
### EXP-ADM-001
**ID**: EXP-ADM-001
**Title**: Product Editor — expiration tracking toggle
**Goal**: Add checkbox to General tab: "Track expiration dates"
**Why**: Admin configures per-product policy
**Dependencies**: EXP-BE-001
**Applications**: Admin
**Modules**: ProductEditor, GeneralSection
**Database impact**: None (uses EXP-BE-001)
**API contracts**: PATCH /catalog/products/:id
**Permissions**: products.write
**Implementation**: Add toggle to GeneralSection; saves { expiration_tracking_enabled: boolean } on save
**Tests**: Toggle saves correctly; shows/hides expiration UI based on state
**Expected blast radius**: Low
**Definition of Done**: Admin can enable/disable expiry tracking per product; toggle persists
### EXP-ADM-002
**ID**: EXP-ADM-002
**Title**: Inventory — lot-level stock view
**Goal**: Show lots table for expiry-tracking products in inventory page
**Why**: Operational visibility into expiration state
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: InventorySection, inventory page
**Database impact**: None
**API contracts**: GET /inventory/lots?variant_id=X
**Permissions**: inventory.read
**Implementation**: Extend InventorySection to show lots when product has expiry enabled; compute status (VALID/NEAR_EXPIRY/EXPIRED) client-side from FLAG_EXPIRY_WARNING_DAYS
**Tests**: Lot table renders correctly; status computed from dates
**Expected blast radius**: Low
**Definition of Done**: Lots displayed with correct quantity, date, and status badge
### EXP-ADM-003
**ID**: EXP-ADM-003
**Title**: Inventory — lot create/edit/delete
**Goal**: Inline lot management in inventory section
**Why**: Admin must be able to add/update/remove lots
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: InventorySection
**Database impact**: None
**API contracts**: POST/PATCH/DELETE /inventory/lots/:id
**Permissions**: inventory.write
**Implementation**: Add lot form (quantity, expiration date); inline edit on lot row; delete confirmation
**Tests**: Create lot with required expiry date; edit quantity; delete lot; 422 shown for missing expiry
**Expected blast radius**: Low
**Definition of Done**: Admin can fully manage lots; validation errors shown correctly
### EXP-ADM-004
**ID**: EXP-ADM-004
**Title**: Inventory filters — expiring, expired, all, no-expiry
**Goal**: Filter inventory page by expiration status
**Why**: Operational efficiency for stock management
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: inventory page
**Database impact**: None
**API contracts**: GET /inventory/lots?filter=expiring|expired|all|no-expiry
**Permissions**: inventory.read
**Implementation**: Add filter tabs/dropdown to inventory page; calls API with filter param
**Tests**: Each filter returns correct lot subset
**Expected blast radius**: Low
**Definition of Done**: Filters work; filter state reflected in URL or UI
## QA
### EXP-QA-001
**ID**: EXP-QA-001
**Title**: Expiration tracking regression tests
**Goal**: Ensure existing checkout flow is unbroken
**Why**: No regressions on existing products
**Dependencies**: EXP-BE-005, EXP-DB-001
**Applications**: QA
**Modules**: E2E tests
**Tests**:
- Normal product (non-expiry) still checks out correctly
- Expiry product with no lots: unavailable
- Expiry product with valid lot: available and reservable
- FEFO: earliest expiry lot consumed first
- Expired lot: contributes zero sellable units
**Expected blast radius**: N/A
**Definition of Done**: All regression tests pass
### EXP-QA-002
**ID**: EXP-QA-002
**Title**: Expiration tracking unit/integration tests
**Goal**: Comprehensive test coverage for all new domain code
**Why**: Business rules must be correct
**Dependencies**: EXP-BE-003, EXP-BE-004, EXP-BE-005
**Applications**: QA
**Modules**: Backend test suite
**Tests**: See TESTS.md
**Expected blast radius**: N/A
**Definition of Done**: 100% pass rate on expiration-specific tests
---
## Task Summary Table
| Task | Layer | Feature | Depends On | Risk | Parallel |
|------|-------|---------|-----------|------|---------|
| EXP-BE-001 | Backend | Product expiry column | — | Low | * |
| EXP-BE-002 | Backend | Lot model + repository | — | Low | * |
| EXP-BE-003 | Backend | LotService domain | EXP-BE-002 | Low | * |
| EXP-BE-004 | Backend | Lot API routes | EXP-BE-003 | Low | * |
| EXP-BE-005 | Backend | InventoryService + LotService | EXP-BE-001, EXP-BE-003 | Medium | * |
| EXP-BE-006 | Backend | Movement audit for lots | EXP-BE-002 | Low | EXP-BE-004 |
| EXP-DB-001 | DB | Run migrations | EXP-BE-001, EXP-BE-002 | Low | * |
| EXP-ADM-001 | Admin | Product expiry toggle | EXP-BE-001 | Low | * |
| EXP-ADM-002 | Admin | Lot stock view | EXP-BE-004 | Low | * |
| EXP-ADM-003 | Admin | Lot CRUD | EXP-BE-004 | Low | * |
| EXP-ADM-004 | Admin | Expiry filters | EXP-BE-004 | Low | * |
| EXP-QA-001 | QA | Checkout regression | EXP-BE-005, EXP-DB-001 | Medium | After backend |
| EXP-QA-002 | QA | Domain unit tests | All BE tasks | Low | With backend |
**Parallel group**: EXP-BE-001 and EXP-BE-002 can run in parallel. BE-003 depends on BE-002. BE-004 and BE-006 depend on BE-003. BE-005 depends on BE-001 + BE-003.
**Recommended order**:
1. EXP-BE-001 + EXP-BE-002 (parallel, no dependencies)
2. EXP-BE-003 (depends on BE-002)
3. EXP-BE-004 + EXP-BE-006 (depend on BE-003, parallel)
4. EXP-DB-001 (run after BE-001 + BE-002 code is deployed)
5. EXP-BE-005 (depends on BE-001 + BE-003)
6. EXP-ADM-001 (depends on BE-001)
7. EXP-ADM-002 + EXP-ADM-003 + EXP-ADM-004 (depend on BE-004, parallel)
8. EXP-QA-001 + EXP-QA-002 (after all backend + admin)
**High-risk tasks**: EXP-BE-005 (changes InventoryService contract internal behavior, affects checkout — thorough regression testing required).
**MVP boundary**: EXP-BE-001 through EXP-BE-005 + EXP-DB-001 + EXP-ADM-001 through EXP-ADM-004. EXP-BE-006 (audit) is low priority for MVP. EXP-QA-002 is bundled with implementation. EXP-QA-001 is blocking go-live.

View File

@@ -0,0 +1,131 @@
# EXPIRATION TRACKING — TESTS.md
## Unit Tests
### LotService
```
EXP-UT-001: createLot with expiry product and valid date → succeeds
EXP-UT-002: createLot with expiry product and missing date → throws MissingExpirationDateError (422)
EXP-UT-003: createLot with past date → throws PastExpirationDateError (422)
EXP-UT-004: createLot with non-expiry product and no date → succeeds (null expiry allowed)
EXP-UT-005: allocateLots FEFO order — earliest expiry first
EXP-UT-006: allocateLots partial — consumes only available quantity from earliest
EXP-UT-007: allocateLots insufficient → throws InsufficientStockError
EXP-UT-008: allocateLots across multiple lots (quantity exceeds first lot)
EXP-UT-009: getAvailableStock excludes expired lots
EXP-UT-010: getAvailableStock excludes lots with expiration_date = today (near_expiry status)
EXP-UT-011: updateLot quantity to 0 → still exists (not auto-deleted)
EXP-UT-012: deleteLot removes lot and logs audit
```
### InventoryService (extended)
```
EXP-UT-020: checkAvailability non-expiry product → existing variant-level sum
EXP-UT-021: checkAvailability expiry product with valid lots → sum of non-expired quantities
EXP-UT-022: checkAvailability expiry product with only expired lots → 0 available
EXP-UT-023: reserve expiry product → FEFO allocation from non-expired lots
EXP-UT-024: reserve non-expiry product → existing behavior unchanged
EXP-UT-025: release returns reserved quantity to FEFO lots
EXP-UT-026: feature flag OFF → delegates to existing variant-level path
```
### Product (expiration field)
```
EXP-UT-030: product.create sets expiration_tracking_enabled to false by default
EXP-UT-031: product.patch can update expiration_tracking_enabled
EXP-UT-032: product.findById returns expiration_tracking_enabled
```
## Integration Tests
### Database
```
EXP-IT-001: inventory_lots table enforces CHECK expiration_date >= CURRENT_DATE
EXP-IT-002: inventory_lots FK to catalog_variants ON DELETE CASCADE
EXP-IT-003: inventory_movements allows new operation types
EXP-IT-004: catalog_products expiration_tracking_enabled defaults to false
```
### API Routes
```
EXP-IT-010: POST /inventory/lots with valid body → 201 + lot object
EXP-IT-011: POST /inventory/lots missing expiration_date on expiry product → 422
EXP-IT-012: POST /inventory/lots with past expiration_date → 422
EXP-IT-013: PATCH /inventory/lots/:id updates quantity
EXP-IT-014: DELETE /inventory/lots/:id → 200 + lot removed
EXP-IT-015: GET /inventory/lots?variant_id=X → returns lots for variant
EXP-IT-016: GET /inventory/lots?filter=expiring → returns only near-expiry lots
EXP-IT-017: GET /inventory/lots?filter=expired → returns expired lots
EXP-IT-018: GET /inventory/lots?filter=no-expiry → returns null-expiry lots
EXP-IT-019: All lot endpoints require admin auth → 401 without session
```
### Full Flow
```
EXP-IT-030: Enable expiry on product → create lots → check availability → available
EXP-IT-031: Enable expiry on product → no lots created → availability = 0
EXP-IT-032: Reserve from FEFO lots → earliest expires first → correct lot decremented
EXP-IT-033: Expired lot never contributes to availability
EXP-IT-034: After feature flag OFF → reverts to variant-level behavior
```
## Admin Tests
```
EXP-ADM-UT-001: ProductEditor — enabling expiry toggle shows lot management UI
EXP-ADM-UT-002: ProductEditor — disabling expiry hides lot management UI
EXP-ADM-UT-003: Lot table — correct VALID/NEAR_EXPIRY/EXPIRED status badges
EXP-ADM-UT-004: Create lot form — expiry date required for tracking products
EXP-ADM-UT-005: Create lot form — expiry date optional for non-tracking products
EXP-ADM-UT-006: Inventory filter tabs — each shows correct subset
```
## Checkout Regression Tests
```
EXP-E2E-001: Checkout — normal non-expiry product → completes successfully
EXP-E2E-002: Checkout — expiry product with no valid lots → unavailable
EXP-E2E-003: Checkout — expiry product with valid lots → reserves correctly
EXP-E2E-004: Checkout — FEFO: oldest expiry lot decremented first
EXP-E2E-005: Checkout — cart with mixed expiry/non-expiry products → both work
EXP-E2E-006: Checkout — reserve then release → lots restored to correct quantities
```
## Given/When/Then Acceptance Criteria
```
GIVEN a product with expiration_tracking_enabled = false
WHEN inventory is received for a variant of that product
THEN expiration_date is NOT required
AND the variant uses existing variant-level stock
AND checkout works as before
GIVEN a product with expiration_tracking_enabled = true
WHEN inventory is received without an expiration date
THEN the backend returns 422 MISSING_EXPIRATION_DATE
GIVEN a product with expiration_tracking_enabled = true
WHEN a lot is received with an expiration date in the past
THEN the backend returns 422 PAST_EXPIRATION_DATE
GIVEN an inventory lot whose expiration_date is in the past
WHEN sellable inventory is calculated
THEN that lot contributes zero sellable units
AND the lot remains visible in admin
AND the lot can be manually adjusted/deleted by admin
GIVEN a product with expiration_tracking_enabled = true and multiple lots
WHEN a customer reserves units
THEN lots are allocated in FEFO order (earliest expiration first)
AND when the earliest lot is exhausted, allocation continues to the next
GIVEN the feature flag expiration_tracking = false
WHEN any existing checkout flow runs
THEN behavior is byte-for-byte identical to pre-migration
```