feat(F-048): completed feature
This commit is contained in:
2
project/.gitignore
vendored
2
project/.gitignore
vendored
@@ -3,3 +3,5 @@ dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
apps/admin/public/uploads/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --port 3004",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3004",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function LoginPage() {
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
MercadoDeVida
|
||||
mercadodevida
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
|
||||
</div>
|
||||
@@ -170,7 +170,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
© {new Date().getFullYear()} MercadoDeVida
|
||||
© {new Date().getFullYear()} mercadodevida
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Brand } from '@/types';
|
||||
import { brandsApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
@@ -17,7 +18,7 @@ function autoSeoTitle(name: string): string {
|
||||
}
|
||||
|
||||
function autoSeoDescription(name: string): string {
|
||||
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
return `${name} — Compra online en mercadodevida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
}
|
||||
|
||||
export default function BrandsPage() {
|
||||
@@ -228,10 +229,7 @@ export default function BrandsPage() {
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(b)} className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
<button onClick={() => handleDelete(b.id)} className="text-xs text-red-600 hover:underline">Eliminar</button>
|
||||
</div>
|
||||
<RowActions onEdit={() => openEdit(b)} onDelete={() => handleDelete(b.id)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { Category } from '@/types';
|
||||
import { categoriesApi } from '@/lib/api-client';
|
||||
|
||||
@@ -24,7 +24,16 @@ function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Cat
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{cat.parentId ? 'Sí' : 'Raíz'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cat.isParent ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{cat.isParent ? '📂 Parent' : '🍃 Child'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{cat.parentId ? 'Sí' : <span className="italic text-gray-400">Raíz</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onEdit(cat)} className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
|
||||
@@ -43,6 +52,32 @@ function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Cat
|
||||
);
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
slugManual: boolean;
|
||||
description: string;
|
||||
parentId: string | null;
|
||||
isParent: boolean;
|
||||
seoTitle: string;
|
||||
seoTitleManual: boolean;
|
||||
seoDescription: string;
|
||||
seoDescManual: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
slug: '',
|
||||
slugManual: false,
|
||||
description: '',
|
||||
parentId: null,
|
||||
isParent: false,
|
||||
seoTitle: '',
|
||||
seoTitleManual: false,
|
||||
seoDescription: '',
|
||||
seoDescManual: false,
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,15 +85,7 @@ export default function CategoriesPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDescription, setSeoDescription] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
@@ -76,39 +103,65 @@ export default function CategoriesPage() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Listado plano de categorías que son PARENT (contenedores) y pueden tener hijos.
|
||||
// Excluye la propia categoría que se está editando para evitar auto-anidado.
|
||||
const parentOptions = useMemo(() => {
|
||||
const flat = (cats: Category[]): Category[] =>
|
||||
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
|
||||
return flat(tree)
|
||||
.filter((c) => c.isParent && (!editing || c.id !== editing.id));
|
||||
}, [tree, editing]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setDescription('');
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescManual(false);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (cat: Category) => {
|
||||
setEditing(cat);
|
||||
setName(cat.name); setSlug(cat.slug); setSlugManual(true);
|
||||
setDescription(cat.description ?? '');
|
||||
setSeoTitle((cat as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDescription((cat as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
setForm({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
slugManual: true,
|
||||
description: cat.description ?? '',
|
||||
parentId: cat.parentId,
|
||||
isParent: cat.isParent ?? false,
|
||||
seoTitle: (cat as any).seoTitle ?? '',
|
||||
seoTitleManual: true,
|
||||
seoDescription: (cat as any).seoDescription ?? '',
|
||||
seoDescManual: true,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugManual) setSlug(slugify(value));
|
||||
if (!seoTitleManual) setSeoTitle(value);
|
||||
if (!seoDescManual) setSeoDescription(`${value} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: value,
|
||||
slug: f.slugManual ? f.slug : slugify(value),
|
||||
seoTitle: f.seoTitleManual ? f.seoTitle : value,
|
||||
seoDescription: f.seoDescManual ? f.seoDescription : `${value} — Compra online en mercadodevida. Productos naturales y ecológicos.`,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug,
|
||||
description: form.description || undefined,
|
||||
parentId: form.parentId,
|
||||
isParent: form.isParent,
|
||||
seoTitle: form.seoTitle || undefined,
|
||||
seoDescription: form.seoDescription || undefined,
|
||||
};
|
||||
if (editing) {
|
||||
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
await categoriesApi.update(editing.id, payload);
|
||||
setMsg('Categoría actualizada');
|
||||
} else {
|
||||
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
await categoriesApi.create(payload);
|
||||
setMsg('Categoría creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
@@ -154,7 +207,7 @@ export default function CategoriesPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
value={form.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ej: Alimentación"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
@@ -165,22 +218,53 @@ export default function CategoriesPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Slug *</label>
|
||||
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
|
||||
<span className="text-xs text-gray-400">{form.slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slugManual: true, slug: e.target.value }))}
|
||||
placeholder="auto-generado"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FIX-19: tipo parent/child + categoría padre */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Categoría padre</label>
|
||||
<select
|
||||
value={form.parentId ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, parentId: e.target.value || null }))}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="">— Sin padre (raíz) —</option>
|
||||
{parentOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Solo se listan las categorías marcadas como parent (contenedor).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer px-4 py-2.5 border border-gray-200 rounded-xl bg-gray-50 w-full">
|
||||
<input type="checkbox" checked={form.isParent}
|
||||
onChange={(e) => setForm((f) => ({ ...f, isParent: e.target.checked }))}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="font-medium">📂 Es categoría parent (contenedor)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Una categoría <strong>child</strong> es una hoja: no puede contener hijos. Si necesita tener subcategorías, márquela como parent.
|
||||
</p>
|
||||
|
||||
{/* Descripción */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Descripción opcional de la categoría"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
@@ -191,44 +275,44 @@ export default function CategoriesPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Título SEO (Google)</label>
|
||||
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoTitleManual ? 'editado' : 'copiado del nombre'}
|
||||
<span className={`text-xs ${form.seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{form.seoTitleManual ? 'editado' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={seoTitle}
|
||||
value={form.seoTitle}
|
||||
maxLength={60}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seoTitleManual: true, seoTitle: e.target.value }))}
|
||||
placeholder="Título para Google (copiado del nombre)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
|
||||
<div className="mt-1 text-xs text-gray-400">{form.seoTitle.length}/60</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Descripción SEO (Google)</label>
|
||||
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoDescManual ? 'editada' : 'auto-generada'}
|
||||
<span className={`text-xs ${form.seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{form.seoDescManual ? 'editada' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
value={form.seoDescription}
|
||||
maxLength={160}
|
||||
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seoDescManual: true, seoDescription: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Descripción para Google (max 160 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDescription.length}/160</div>
|
||||
<div className="mt-1 text-xs text-gray-400">{form.seoDescription.length}/160</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
disabled={saving || !form.name || !form.slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
@@ -255,6 +339,7 @@ export default function CategoriesPage() {
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Nombre</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Tipo</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Subcategoría</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
@@ -269,4 +354,4 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,35 @@ import { cmsApi } from '@/lib/api-client';
|
||||
|
||||
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
|
||||
|
||||
// ── Iconos (patrón /categories) ───────────────────────────────────────────────
|
||||
const PencilIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
);
|
||||
const EyeIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
const EyeOffIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EMPTY_FORM = { slug: '', title: '', body: '' };
|
||||
|
||||
export default function CmsPage() {
|
||||
const [items, setItems] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Page | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState({ slug: '', title: '', body: '' });
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -21,15 +43,25 @@ export default function CmsPage() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(EMPTY_FORM); setShowForm(true); };
|
||||
const openEdit = (p: Page) => { setEditing(p); setForm({ slug: p.slug, title: p.title, body: p.body }); setShowForm(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
if (editing) {
|
||||
await cmsApi.update(editing.id, { slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página actualizada');
|
||||
} else {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
setForm({ slug: '', title: '', body: '' });
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al guardar'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const togglePublish = async (id: string, currentStatus: string) => {
|
||||
@@ -44,15 +76,15 @@ export default function CmsPage() {
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Páginas CMS</h1>
|
||||
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
|
||||
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Nueva página</h2>
|
||||
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label,t]) => (
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar página' : 'Nueva página'}</h2>
|
||||
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<input value={(form as Record<string,string>)[k]} onChange={e => setForm({...form,[k]:e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
@@ -63,8 +95,8 @@ export default function CmsPage() {
|
||||
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} disabled={!form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">Crear</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
<button onClick={handleSave} disabled={saving || !form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">{saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear')}</button>
|
||||
<button onClick={() => { setShowForm(false); setEditing(null); }} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -84,9 +116,23 @@ export default function CmsPage() {
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{p.status === 'published' ? 'Publicada' : 'Borrador'}
|
||||
</span>
|
||||
<button onClick={() => togglePublish(p.id, p.status)} className="text-xs text-[#2D6A4F] hover:underline">
|
||||
{p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
</button>
|
||||
{/* Acciones con iconos (FIX-13) */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(p)} title="Editar" aria-label="Editar"
|
||||
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors">
|
||||
{PencilIcon}
|
||||
</button>
|
||||
<button onClick={() => togglePublish(p.id, p.status)}
|
||||
title={p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
aria-label={p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
p.status === 'published'
|
||||
? 'text-amber-500 hover:text-amber-600 hover:bg-amber-50'
|
||||
: 'text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50'
|
||||
}`}>
|
||||
{p.status === 'published' ? EyeOffIcon : EyeIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,114 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import type { Customer } from '@/types';
|
||||
import type { Customer, Address } from '@/types';
|
||||
|
||||
// ── FIX-18: edición de datos personales (no credenciales) + CRUD de direcciones ──
|
||||
|
||||
interface AddressFormState {
|
||||
label: string;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ADDRESS: AddressFormState = {
|
||||
label: '',
|
||||
recipientName: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function AddressForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial?: Address;
|
||||
onSave: (data: AddressFormState) => void;
|
||||
onCancel: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [f, setF] = useState<AddressFormState>(
|
||||
initial
|
||||
? {
|
||||
label: initial.label ?? '',
|
||||
recipientName: initial.recipientName,
|
||||
street: initial.street,
|
||||
city: initial.city,
|
||||
postalCode: initial.postalCode,
|
||||
country: initial.country,
|
||||
isDefault: initial.isDefault ?? false,
|
||||
}
|
||||
: EMPTY_ADDRESS,
|
||||
);
|
||||
|
||||
const set = (k: keyof AddressFormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setF((prev) => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const valid = f.recipientName && f.street && f.city && f.postalCode && f.country;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Etiqueta</label>
|
||||
<input value={f.label} onChange={set('label')} placeholder="Casa, Oficina…"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Destinatario *</label>
|
||||
<input value={f.recipientName} onChange={set('recipientName')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Dirección *</label>
|
||||
<input value={f.street} onChange={set('street')} placeholder="Calle, número, piso"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ciudad *</label>
|
||||
<input value={f.city} onChange={set('city')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Código postal *</label>
|
||||
<input value={f.postalCode} onChange={set('postalCode')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">País *</label>
|
||||
<input value={f.country} onChange={set('country')} placeholder="España"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="checkbox" checked={f.isDefault} onChange={(e) => setF((p) => ({ ...p, isDefault: e.target.checked }))}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
Predeterminada
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSave(f)} disabled={!valid || saving}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-lg">
|
||||
{saving ? 'Guardando…' : 'Guardar dirección'}
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-lg hover:bg-white">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -10,29 +116,98 @@ export default function CustomerDetailPage() {
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
|
||||
// Personal data (no credenciales)
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
// Addresses
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [showAddressForm, setShowAddressForm] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||
const [savingAddress, setSavingAddress] = useState(false);
|
||||
|
||||
const loadAddresses = useCallback(async () => {
|
||||
try {
|
||||
const d = await customersApi.listAddresses(id);
|
||||
setAddresses(d.items ?? []);
|
||||
} catch {
|
||||
/* silencioso */
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customer) return;
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
useEffect(() => {
|
||||
customersApi
|
||||
.get(id)
|
||||
.then((c) => {
|
||||
setCustomer(c);
|
||||
setDisplayName(c.displayName ?? '');
|
||||
setPhone(c.phone ?? '');
|
||||
})
|
||||
.catch(() => setError('No se encontró el cliente'))
|
||||
.finally(() => setLoading(false));
|
||||
loadAddresses();
|
||||
}, [id, loadAddresses]);
|
||||
|
||||
const flash = (text: string) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
|
||||
await fetch(`/api/customers/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: customer.role }),
|
||||
const updated = await customersApi.update(id, {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
setMsg('Cliente actualizado');
|
||||
setCustomer(updated);
|
||||
flash('Datos personales guardados');
|
||||
} catch {
|
||||
setMsg('Error al guardar');
|
||||
flash('Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAddress = async (data: AddressFormState) => {
|
||||
setSavingAddress(true);
|
||||
try {
|
||||
const payload = {
|
||||
label: data.label || null,
|
||||
recipientName: data.recipientName,
|
||||
street: data.street,
|
||||
city: data.city,
|
||||
postalCode: data.postalCode,
|
||||
country: data.country,
|
||||
isDefault: data.isDefault,
|
||||
};
|
||||
if (editingAddress) {
|
||||
await customersApi.updateAddress(id, editingAddress.id, payload);
|
||||
flash('Dirección actualizada');
|
||||
} else {
|
||||
await customersApi.createAddress(id, payload);
|
||||
flash('Dirección creada');
|
||||
}
|
||||
setShowAddressForm(false);
|
||||
setEditingAddress(null);
|
||||
loadAddresses();
|
||||
} catch {
|
||||
flash('Error al guardar dirección');
|
||||
} finally {
|
||||
setSavingAddress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAddress = async (addressId: string) => {
|
||||
if (!confirm('¿Eliminar esta dirección?')) return;
|
||||
try {
|
||||
await customersApi.deleteAddress(id, addressId);
|
||||
loadAddresses();
|
||||
} catch {
|
||||
flash('Error al eliminar dirección');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,49 +215,108 @@ export default function CustomerDetailPage() {
|
||||
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700">← Clientes</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
|
||||
<p className="text-sm text-gray-900">{customer.email}</p>
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
)}
|
||||
|
||||
{/* Datos personales */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Datos personales</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">El email y las credenciales de acceso no se pueden modificar desde aquí.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
|
||||
<select
|
||||
value={customer.role}
|
||||
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
|
||||
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="customer">Cliente</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
|
||||
</p>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (solo lectura)</label>
|
||||
<input value={customer.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-400">
|
||||
Registrado: {customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
|
||||
</p>
|
||||
<button onClick={saveProfile} disabled={savingProfile}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">
|
||||
{savingProfile ? 'Guardando…' : 'Guardar datos personales'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
{msg}
|
||||
{/* Direcciones */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Direcciones ({addresses.length})</h2>
|
||||
{!showAddressForm && (
|
||||
<button onClick={() => { setEditingAddress(null); setShowAddressForm(true); }}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-lg">
|
||||
+ Añadir dirección
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 space-y-4">
|
||||
{showAddressForm && (
|
||||
<AddressForm
|
||||
initial={editingAddress ?? undefined}
|
||||
onSave={handleSaveAddress}
|
||||
onCancel={() => { setShowAddressForm(false); setEditingAddress(null); }}
|
||||
saving={savingAddress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
{addresses.length === 0 && !showAddressForm ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">Este cliente no tiene direcciones.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{addresses.map((a) => (
|
||||
<div key={a.id} className="border border-gray-200 rounded-xl p-4 flex items-start justify-between gap-4">
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-gray-900">{a.recipientName}</p>
|
||||
{a.label && <span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">{a.label}</span>}
|
||||
{a.isDefault && <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Predeterminada</span>}
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{a.street}</p>
|
||||
<p className="text-gray-500">{a.postalCode} {a.city}, {a.country}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button onClick={() => { setEditingAddress(a); setShowAddressForm(true); }} title="Editar" aria-label="Editar"
|
||||
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => handleDeleteAddress(a.id)} title="Eliminar" aria-label="Eliminar"
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Customer } from '@/types';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -84,69 +86,9 @@ function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: ()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formulario editar cliente ────────────────────────────────────────────────
|
||||
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(customer.displayName || '');
|
||||
setPhone(customer.phone || '');
|
||||
}, [customer]);
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await customersApi.update(customer.id, {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={customer.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Página principal ───────────────────────────────────────────────────────────
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -155,7 +97,6 @@ export default function CustomersPage() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<Customer | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,7 +122,6 @@ export default function CustomersPage() {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
@@ -255,8 +195,7 @@ export default function CustomersPage() {
|
||||
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button onClick={() => setEditing(c)}
|
||||
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
<RowActions onEdit={() => router.push(`/customers/${c.id}`)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -287,12 +226,6 @@ export default function CustomersPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal editar */}
|
||||
{editing && (
|
||||
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
|
||||
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function Sidebar({
|
||||
<div className="px-4 py-5 border-b border-gray-100">
|
||||
<img
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
alt="mercadodevida"
|
||||
className="h-9 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
@@ -103,7 +103,9 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar navItems={navItems} user={user} onLogout={logout} />
|
||||
<main className="flex-1 min-w-0">
|
||||
{children}
|
||||
<div className="w-full max-w-[1280px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>('/admin/stats')
|
||||
.get<Stats>('/api/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => setError('No se pudieron cargar las estadísticas'))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { promotionsApi } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
|
||||
|
||||
@@ -125,7 +126,7 @@ export default function PromotionsPage() {
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
|
||||
<td className="px-4 py-3.5"><button onClick={() => handleDelete(p.code)} className="text-xs text-red-600 hover:underline">Eliminar</button></td>
|
||||
<td className="px-4 py-3.5"><RowActions onDelete={() => handleDelete(p.code)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -89,13 +89,6 @@ export default function SettingsPage() {
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
<a href="/shipping" className="block px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<span className="mr-2">🚚</span>Envíos
|
||||
</a>
|
||||
<a href="/tax-rates" className="block px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<span className="mr-2">💰</span>IVA
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Form area */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
|
||||
import { RowActions } from '@/components/ui/RowActions';
|
||||
|
||||
type Tab = 'zones' | 'methods';
|
||||
|
||||
@@ -17,8 +18,7 @@ function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () =>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -86,8 +86,7 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Strip the Secure flag from the backend's Set-Cookie so the browser
|
||||
* (which connects over HTTP) actually stores the session cookie.
|
||||
* Also drop SameSite=Lax to avoid browser restrictions.
|
||||
* FIX-14: admin panel auth now goes through the backoffice auth endpoint,
|
||||
* which sets a separate `backoffice_session` cookie (independent of the
|
||||
* storefront `mdv_session`). This keeps the physical separation between
|
||||
* backoffice users and storefront customers.
|
||||
*/
|
||||
function makeLocalhostCompatible(cookie: string): string {
|
||||
return cookie
|
||||
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch(`${API}/auth/login`, {
|
||||
const backendRes = await fetch(`${API}/backoffice/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -43,4 +44,4 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
/** FIX-14: logout hits the backoffice endpoint and clears backoffice_session. */
|
||||
export async function POST() {
|
||||
try {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
await fetch(`${API}/auth/logout`, {
|
||||
const { cookies } = await import('next/headers');
|
||||
const token = (await cookies()).get('backoffice_session')?.value;
|
||||
await fetch(`${API}/backoffice/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookies },
|
||||
headers: token ? { Cookie: `backoffice_session=${token}` } : {},
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete('mdv_session');
|
||||
return response;
|
||||
}
|
||||
const res = NextResponse.json({ ok: true }, { status: 200 });
|
||||
res.cookies.set('backoffice_session', '', { path: '/', maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/** FIX-14: admin panel auth now resolves through the backoffice endpoint. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/auth/me`, {
|
||||
const backendRes = await fetch(`${API}/backoffice/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
if (!backendRes.ok) return NextResponse.json({ user: null });
|
||||
@@ -13,4 +14,4 @@ export async function GET(req: NextRequest) {
|
||||
} catch {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,109 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
const EXTENSION_BY_TYPE: Readonly<Record<string, string>> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/avif': 'avif',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticatedBackofficeRequest(request))) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) {
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
const extension = EXTENSION_BY_TYPE[file.type];
|
||||
if (!extension) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
|
||||
{ error: `Tipo no permitido. Usa: ${Object.keys(EXTENSION_BY_TYPE).join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
|
||||
if (file.size <= 0 || file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo debe ocupar entre 1 byte y 10MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Unique filename
|
||||
const ext = file.name.split('.').pop() ?? 'jpg';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!hasExpectedImageSignature(buffer, file.type)) {
|
||||
return NextResponse.json({ error: 'El contenido no coincide con un formato de imagen válido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}.${extension}`;
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
await writeFile(filePath, buffer, { flag: 'wx' });
|
||||
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url, filename, size: file.size });
|
||||
return NextResponse.json({
|
||||
url: `/uploads/${filename}`,
|
||||
filename,
|
||||
size: file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function isAuthenticatedBackofficeRequest(request: NextRequest): Promise<boolean> {
|
||||
const cookies = request.headers.get('cookie');
|
||||
if (!cookies) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API}/backoffice/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = (await response.json()) as { id?: unknown; role?: unknown; user?: null };
|
||||
return (
|
||||
typeof body.id === 'string' &&
|
||||
(body.role === 'admin' || body.role === 'editor') &&
|
||||
body.user !== null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedImageSignature(buffer: Buffer, type: string): boolean {
|
||||
if (type === 'image/jpeg') {
|
||||
return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
}
|
||||
if (type === 'image/png') {
|
||||
return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
|
||||
}
|
||||
if (type === 'image/gif') {
|
||||
const signature = buffer.subarray(0, 6).toString('ascii');
|
||||
return signature === 'GIF87a' || signature === 'GIF89a';
|
||||
}
|
||||
if (type === 'image/webp') {
|
||||
return (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
);
|
||||
}
|
||||
if (type === 'image/avif') {
|
||||
if (buffer.length < 12 || buffer.subarray(4, 8).toString('ascii') !== 'ftyp') return false;
|
||||
const brands = buffer.subarray(8, Math.min(buffer.length, 32)).toString('ascii');
|
||||
return brands.includes('avif') || brands.includes('avis');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MercadoDeVida Admin',
|
||||
template: '%s | MercadoDeVida Admin',
|
||||
default: 'mercadodevida Admin',
|
||||
template: '%s | mercadodevida Admin',
|
||||
},
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
49
project/apps/admin/src/app/uploads/[filename]/route.ts
Normal file
49
project/apps/admin/src/app/uploads/[filename]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves uploaded files dynamically from disk on every request.
|
||||
*
|
||||
* Next.js production mode caches the public/ directory listing at build/start
|
||||
* time, so files uploaded after startup return 404 when served as static
|
||||
* assets. This route handler reads the file fresh from disk each time,
|
||||
* making newly uploaded images immediately available without a restart.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await params;
|
||||
|
||||
// Prevent path traversal
|
||||
const safe = path.basename(filename);
|
||||
if (safe !== filename || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = path.extname(safe).toLowerCase();
|
||||
const filePath = path.join(process.cwd(), 'public', 'uploads', safe);
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
59
project/apps/admin/src/components/ui/RowActions.tsx
Normal file
59
project/apps/admin/src/components/ui/RowActions.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Normalized table row actions following the /categories pattern:
|
||||
* icon buttons (not text) with hover color and title tooltips.
|
||||
*
|
||||
* FIX-12: replaces text "Editar"/"Eliminar" buttons across backoffice tables.
|
||||
*/
|
||||
|
||||
const PencilIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface RowActionsProps {
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
editTitle?: string;
|
||||
deleteTitle?: string;
|
||||
}
|
||||
|
||||
export function RowActions({
|
||||
onEdit,
|
||||
onDelete,
|
||||
editTitle = 'Editar',
|
||||
deleteTitle = 'Eliminar',
|
||||
}: RowActionsProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors"
|
||||
title={editTitle}
|
||||
aria-label={editTitle}
|
||||
>
|
||||
{PencilIcon}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={deleteTitle}
|
||||
aria-label={deleteTitle}
|
||||
>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
setName(v);
|
||||
if (!slugManual) setSlug(slugify(v));
|
||||
if (!seoTitleManual) setSeoTitle(v);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en mercadodevida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -273,16 +273,16 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
|
||||
<label key={key}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
|
||||
<button key={key} type="button" onClick={() => toggleAttr(key)}
|
||||
aria-pressed={attributes.includes(key)}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm text-left ${
|
||||
attributes.includes(key)
|
||||
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||
}`}>
|
||||
<input type="checkbox" checked={attributes.includes(key)}
|
||||
onChange={() => toggleAttr(key)} className="hidden" />
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${attributes.includes(key) ? 'bg-[#2D6A4F]' : 'bg-gray-300'}`} />
|
||||
{label}
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,19 @@ import type { ProductVariant, VariantPrice } from '@/types';
|
||||
const VAT_GENERAL = 1.21;
|
||||
const VAT_REDUCED = 1.10;
|
||||
|
||||
function fmt(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
// ── FIX-17: trabajo en euros con 2 decimales, acepta coma o punto ────────────
|
||||
/** Convierte una entrada de usuario ("12", "12,30", "12.30") a céntimos. */
|
||||
function eurToCents(input: string): number {
|
||||
if (!input) return 0;
|
||||
const normalized = String(input).replace(',', '.');
|
||||
const val = parseFloat(normalized);
|
||||
if (isNaN(val)) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
|
||||
/** Convierte céntimos a string de euros con 2 decimales ("12.00"). */
|
||||
function centsToEur(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
|
||||
@@ -23,7 +34,7 @@ interface PricingSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PricingSection({ productId }: PricingSectionProps) {
|
||||
export function PricingSection({ productId }: { productId: string }) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [loadingPrices, setLoadingPrices] = useState(true);
|
||||
@@ -31,7 +42,7 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Edit state per variant
|
||||
// Edit state per variant (valores en EUROS como string, p.ej. "12.00")
|
||||
const [net, setNet] = useState<Record<string, string>>({});
|
||||
const [offer, setOffer] = useState<Record<string, string>>({});
|
||||
const [cost, setCost] = useState<Record<string, string>>({});
|
||||
@@ -54,13 +65,13 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
pricingApi.getVariantPrice(v.id)
|
||||
.then((p) => {
|
||||
setPrices(prev => ({ ...prev, [v.id]: p }));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? centsToEur(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? centsToEur(p.costCents) : '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
|
||||
})
|
||||
.catch(() => {
|
||||
setNet(prev => ({ ...prev, [v.id]: '0' }));
|
||||
setNet(prev => ({ ...prev, [v.id]: '0.00' }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
|
||||
@@ -73,23 +84,17 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
}, [variants]);
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const netCents = parseInt(net[variantId] ?? '0', 10);
|
||||
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
|
||||
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
|
||||
if (isNaN(netCents) || netCents < 0) return;
|
||||
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
|
||||
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
|
||||
const netCents = eurToCents(net[variantId] ?? '0');
|
||||
const offerCentsVal = offer[variantId] ? eurToCents(offer[variantId]) : null;
|
||||
const costCentsVal = cost[variantId] ? eurToCents(cost[variantId]) : null;
|
||||
if (netCents < 0) return;
|
||||
if (offerCentsVal !== null && offerCentsVal < 0) return;
|
||||
if (costCentsVal !== null && costCentsVal < 0) return;
|
||||
setSaving(variantId);
|
||||
setMsg(prev => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
|
||||
if (offerCentsVal !== null) {
|
||||
// set offer via separate update
|
||||
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
|
||||
} else {
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
}
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
|
||||
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
@@ -127,11 +132,11 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map(v => {
|
||||
const p = prices[v.id];
|
||||
const netCents = parseInt(net[v.id] ?? '0', 10);
|
||||
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
|
||||
const netCents = eurToCents(net[v.id] ?? '0');
|
||||
const costCents = cost[v.id] ? eurToCents(cost[v.id]) : 0;
|
||||
const vr = vatRate[v.id] ?? 'general';
|
||||
const grossCents = calcGross(netCents, vr);
|
||||
const grossEur = centsToEur(grossCents);
|
||||
const marginBruto = calcMarginBruto(grossCents, costCents);
|
||||
const editing = saving === v.id;
|
||||
|
||||
@@ -144,12 +149,12 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={cost[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -159,15 +164,15 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={grossCents}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={grossEur}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const gross = parseInt(e.target.value, 10) || 0;
|
||||
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
|
||||
const grossInputCents = eurToCents(e.target.value);
|
||||
const newNetCents = Math.round(grossInputCents / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(newNetCents) }));
|
||||
}}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -177,12 +182,12 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={offer[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="—"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -216,11 +221,11 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={netCents}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={net[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -250,6 +255,7 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p><strong>Formato:</strong> introduce los precios en euros con dos decimales (12 o 12,50 o 12.50).</p>
|
||||
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
|
||||
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
|
||||
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
|
||||
|
||||
@@ -56,6 +56,7 @@ export const authApi = {
|
||||
};
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────────
|
||||
// Backend: /catalog/products (admin list), /products/:id (admin CRUD)
|
||||
|
||||
export const productsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; q?: string }) => {
|
||||
@@ -68,15 +69,15 @@ export const productsApi = {
|
||||
`/api/catalog/products${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Product>(`/api/catalog/products/${id}`),
|
||||
get: (id: string) => api.get<import('@/types').Product>(`/api/products/${id}`),
|
||||
getVariants: (id: string) =>
|
||||
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/catalog/products/${id}/variants`),
|
||||
create: (data: unknown) => api.post<import('@/types').Product>('/api/catalog/products', data),
|
||||
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/products/${id}/variants`),
|
||||
create: (data: unknown) => api.post<import('@/types').Product>('/api/products', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Product>(`/api/catalog/products/${id}`, data),
|
||||
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
|
||||
setState: (id: string, state: 'active' | 'archived') =>
|
||||
api.patch(`/api/catalog/products/${id}/state`, { state }),
|
||||
delete: (id: string) => api.delete(`/api/catalog/products/${id}`),
|
||||
api.patch(`/api/products/${id}/state`, { state }),
|
||||
delete: (id: string) => api.delete(`/api/products/${id}`),
|
||||
};
|
||||
|
||||
// ── Orders ────────────────────────────────────────────────────────────────────
|
||||
@@ -114,6 +115,15 @@ export const customersApi = {
|
||||
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
|
||||
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
|
||||
api.post<import('@/types').Customer>('/api/auth/register', data),
|
||||
// ── Direcciones (FIX-18) ───────────────────────────────────────────────────
|
||||
listAddresses: (id: string) =>
|
||||
api.get<{ items: import('@/types').Address[] }>(`/api/users/${id}/addresses`),
|
||||
createAddress: (id: string, data: Omit<import('@/types').Address, 'id' | 'userId' | 'createdAt' | 'updatedAt'>) =>
|
||||
api.post<import('@/types').Address>(`/api/users/${id}/addresses`, data),
|
||||
updateAddress: (id: string, addressId: string, data: Partial<Omit<import('@/types').Address, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>) =>
|
||||
api.patch<import('@/types').Address>(`/api/users/${id}/addresses/${addressId}`, data),
|
||||
deleteAddress: (id: string, addressId: string) =>
|
||||
api.delete<void>(`/api/users/${id}/addresses/${addressId}`),
|
||||
};
|
||||
|
||||
// ── Brands ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -135,6 +135,20 @@ export interface Customer {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
label?: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// ── Categories & Brands ────────────────────────────────────────────────────────
|
||||
|
||||
export interface Category {
|
||||
@@ -146,6 +160,7 @@ export interface Category {
|
||||
seoDescription?: string;
|
||||
imageUrl?: string;
|
||||
description?: string;
|
||||
isParent?: boolean;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
|
||||
@@ -38,4 +38,21 @@ export default tseslint.config(
|
||||
'no-console': ['error', { allow: ['error', 'log'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['migrations/**/*.{js,cjs}', 'scripts/**/*.cjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
__dirname: 'readonly',
|
||||
console: 'readonly',
|
||||
exports: 'writable',
|
||||
module: 'writable',
|
||||
process: 'readonly',
|
||||
require: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
6895
project/frontend/package-lock.json
generated
Normal file
6895
project/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,12 @@ import { fetchPage } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Quiénes somos',
|
||||
description: 'Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza.',
|
||||
description: 'Conoce la historia, misión y valores de mercadodevida. Productos naturales y orgánicos de confianza.',
|
||||
};
|
||||
|
||||
const FALLBACK_HTML = `
|
||||
<h2>Nuestra historia</h2>
|
||||
<p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado. Seleccionamos cuidadosamente cada producto para asegurar la máxima calidad y transparencia.</p>
|
||||
<p>mercadodevida nació de la convicción de que comer bien no debería ser complicado. Seleccionamos cuidadosamente cada producto para asegurar la máxima calidad y transparencia.</p>
|
||||
<h2>Nuestra misión</h2>
|
||||
<p>Facilitar el acceso a productos naturales y orgánicos de alta calidad, directamente desde productores certificados, sin intermediarios.</p>
|
||||
<h2>Valores</h2>
|
||||
@@ -31,7 +31,7 @@ export default async function AboutPage() {
|
||||
return (
|
||||
<ContentPage
|
||||
title={cms?.title ?? 'Quiénes somos'}
|
||||
description="Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza."
|
||||
description="Conoce la historia, misión y valores de mercadodevida. Productos naturales y orgánicos de confianza."
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</ContentPage>
|
||||
|
||||
@@ -211,7 +211,10 @@ export default function AccountPage() {
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Pedidos — MercadoDeVida',
|
||||
title: 'Admin Pedidos — mercadodevida',
|
||||
};
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin — MercadoDeVida',
|
||||
title: 'Admin — mercadodevida',
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
|
||||
import { fetchProducts } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Productos — MercadoDeVida',
|
||||
title: 'Admin Productos — mercadodevida',
|
||||
};
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
if (!brand) return { title: 'Marca no encontrada' };
|
||||
return {
|
||||
title: brand.seoTitle ?? brand.name,
|
||||
description: brand.seoDescription ?? `Productos ${brand.name} en MercadoDeVida.`,
|
||||
description: brand.seoDescription ?? `Productos ${brand.name} en mercadodevida.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
import { fetchBrands } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Marcas — MercadoDeVida',
|
||||
title: 'Marcas — mercadodevida',
|
||||
description: 'Todas las marcas de productos naturales y ecológicos.',
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
|
||||
import CartPageContent from '@/components/cart/CartPageContent';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Carrito — MercadoDeVida',
|
||||
title: 'Carrito — mercadodevida',
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
if (!category) return { title: 'Categoría no encontrada' };
|
||||
return {
|
||||
title: category.seoTitle ?? category.name,
|
||||
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en MercadoDeVida.`,
|
||||
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en mercadodevida.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
import { fetchCategories } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Categorías — MercadoDeVida',
|
||||
title: 'Categorías — mercadodevida',
|
||||
description: 'Explora todas las categorías de productos naturales y orgánicos.',
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
|
||||
import CheckoutClient from '@/components/checkout/CheckoutClient';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Checkout — MercadoDeVida',
|
||||
title: 'Checkout — mercadodevida',
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchPage } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contacto',
|
||||
description: 'Ponte en contacto con el equipo de MercadoDeVida. Resolvemos tus dudas sobre productos, pedidos y envíos.',
|
||||
description: 'Ponte en contacto con el equipo de mercadodevida. Resolvemos tus dudas sobre productos, pedidos y envíos.',
|
||||
};
|
||||
|
||||
const FALLBACK_HTML = `
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Política de cookies',
|
||||
description: 'Información sobre el uso de cookies en MercadoDeVida.',
|
||||
description: 'Información sobre el uso de cookies en mercadodevida.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ const opensans = Open_Sans({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MercadoDeVida — Productos naturales y orgánicos',
|
||||
title: 'mercadodevida — Productos naturales y orgánicos',
|
||||
description:
|
||||
'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.',
|
||||
icons: {
|
||||
icon: '/images/favicon.png',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'MercadoDeVida — Productos naturales y orgánicos',
|
||||
title: 'mercadodevida — Productos naturales y orgánicos',
|
||||
description: 'Tienda online de productos naturales, orgánicos y saludables.',
|
||||
type: 'website',
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
||||
const { orderId } = await searchParams;
|
||||
return {
|
||||
title: orderId
|
||||
? `Pedido ${orderId.slice(0, 8).toUpperCase()} confirmado — MercadoDeVida`
|
||||
: 'Pedido confirmado — MercadoDeVida',
|
||||
? `Pedido ${orderId.slice(0, 8).toUpperCase()} confirmado — mercadodevida`
|
||||
: 'Pedido confirmado — mercadodevida',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Política de privacidad',
|
||||
description: 'Información sobre cómo MercadoDeVida recopila, usa y protege tus datos personales.',
|
||||
description: 'Información sobre cómo mercadodevida recopila, usa y protege tus datos personales.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function PrivacyPage() {
|
||||
|
||||
<h2>Responsable del tratamiento</h2>
|
||||
<p>
|
||||
MercadoDeVida<br />
|
||||
mercadodevida<br />
|
||||
Email: hola@mercadodevida.es
|
||||
</p>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Metadata } from 'next';
|
||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Productos — MercadoDeVida',
|
||||
title: 'Productos — mercadodevida',
|
||||
description: 'Todos los productos naturales y orgánicos.',
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
||||
const { q } = await searchParams;
|
||||
const title = q ? `Buscar: "${q}"` : 'Buscar productos';
|
||||
return { title, description: `${title} en MercadoDeVida.` };
|
||||
return { title, description: `${title} en mercadodevida.` };
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: Props) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Términos y condiciones',
|
||||
description: 'Condiciones generales de venta de MercadoDeVida. Lea atentamente antes de realizar su pedido.',
|
||||
description: 'Condiciones generales de venta de mercadodevida. Lea atentamente antes de realizar su pedido.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function TermsPage() {
|
||||
|
||||
<h2>Identificación del vendedor</h2>
|
||||
<p>
|
||||
MercadoDeVida<br />
|
||||
mercadodevida<br />
|
||||
Email: hola@mercadodevida.es
|
||||
</p>
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export function Footer() {
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<p className="text-xs" style={{ color: 'rgba(232,245,224,0.4)' }}>
|
||||
© 2026 MercadoDeVida. Todos los derechos reservados.
|
||||
© 2026 mercadodevida. Todos los derechos reservados.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="https://instagram.com/mercadodevida/" target="_blank" rel="noopener" aria-label="Instagram"
|
||||
|
||||
@@ -120,13 +120,7 @@ export function Header() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
if (!q.trim()) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadResults(q.trim());
|
||||
@@ -146,6 +140,16 @@ export function Header() {
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setQ(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (term: string) => {
|
||||
setQ(term);
|
||||
setShowDropdown(false);
|
||||
@@ -168,7 +172,7 @@ export function Header() {
|
||||
<Link href="/" className="flex items-center shrink-0">
|
||||
<Image
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
alt="mercadodevida"
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-auto object-contain"
|
||||
@@ -183,7 +187,7 @@ export function Header() {
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onFocus={() => { if (q.trim() && (results.length > 0 || suggestions.length > 0)) setShowDropdown(true); }}
|
||||
placeholder="Buscar productos, marcas..."
|
||||
className="w-full pl-10 pr-10 py-2.5 border border-gray-200 rounded-full text-sm
|
||||
|
||||
@@ -26,15 +26,13 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('mdv_cart');
|
||||
if (stored) setItems(JSON.parse(stored));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((newItems: CartItem[]) => {
|
||||
setItems(newItems);
|
||||
localStorage.setItem('mdv_cart', JSON.stringify(newItems));
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('mdv_cart');
|
||||
if (stored) setItems(JSON.parse(stored));
|
||||
} catch {}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback((item: CartItem) => {
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
// migration: 024_product_channels_attributes
|
||||
// Adds sales channels, featured flag, and attribute tags to the catalog_products table
|
||||
|
||||
/** @param {import('pg').Pool} pool */
|
||||
exports.up = async (pool) => {
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
// Add channels column
|
||||
await pool.query(`
|
||||
pgm.sql(`
|
||||
ALTER TABLE catalog_products
|
||||
ADD COLUMN IF NOT EXISTS channels TEXT NOT NULL DEFAULT 'all'
|
||||
CHECK (channels IN ('online', 'offline', 'all'))
|
||||
CHECK (channels IN ('online', 'offline', 'all'))
|
||||
`);
|
||||
|
||||
// Add featured column
|
||||
await pool.query(`
|
||||
pgm.sql(`
|
||||
ALTER TABLE catalog_products
|
||||
ADD COLUMN IF NOT EXISTS featured BOOLEAN NOT NULL DEFAULT FALSE
|
||||
`);
|
||||
|
||||
// Add attributes column (stored as JSONB array of strings)
|
||||
await pool.query(`
|
||||
pgm.sql(`
|
||||
ALTER TABLE catalog_products
|
||||
ADD COLUMN IF NOT EXISTS attributes JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
`);
|
||||
|
||||
// Add offer_cents and cost_cents to pricing_variant_prices
|
||||
await pool.query(`
|
||||
pgm.sql(`
|
||||
ALTER TABLE pricing_variant_prices
|
||||
ADD COLUMN IF NOT EXISTS offer_cents INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS cost_cents INTEGER
|
||||
`);
|
||||
|
||||
// Create index for featured products (common query)
|
||||
await pool.query(`
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS catalog_products_featured_idx
|
||||
ON catalog_products (featured) WHERE featured = TRUE
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async (pool) => {
|
||||
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS channels`);
|
||||
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS featured`);
|
||||
await pool.query(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS attributes`);
|
||||
await pool.query(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS offer_cents`);
|
||||
await pool.query(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS cost_cents`);
|
||||
await pool.query(`DROP INDEX IF EXISTS catalog_products_featured_idx`);
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS channels`);
|
||||
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS featured`);
|
||||
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS attributes`);
|
||||
pgm.sql(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS offer_cents`);
|
||||
pgm.sql(`ALTER TABLE pricing_variant_prices DROP COLUMN IF EXISTS cost_cents`);
|
||||
pgm.sql(`DROP INDEX IF EXISTS catalog_products_featured_idx`);
|
||||
};
|
||||
|
||||
@@ -1,57 +1,96 @@
|
||||
// migration: 025_cms_footer_pages
|
||||
// Seeds initial CMS pages for footer links if they don't exist yet
|
||||
|
||||
/** @param {import('pg').Pool} pool */
|
||||
exports.up = async (pool) => {
|
||||
const pages = [
|
||||
{
|
||||
slug: 'about',
|
||||
title: 'Quiénes somos',
|
||||
body: '<h2>Nuestra historia</h2><p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado.</p><h2>Nuestra misión</h2><p>Facilitar el acceso a productos naturales y orgánicos de alta calidad.</p>',
|
||||
status: 'published',
|
||||
},
|
||||
{
|
||||
slug: 'contact',
|
||||
title: 'Contacto',
|
||||
body: '<h2>Email</h2><p><a href="mailto:hola@mercadodevida.es">hola@mercadodevida.es</a></p><p>Intentamos responder en 24-48h laborables.</p>',
|
||||
status: 'published',
|
||||
},
|
||||
{
|
||||
slug: 'shipping',
|
||||
title: 'Envíos y entregas',
|
||||
body: '<h2>Zonas de envío</h2><p>España peninsular. Canarias, Ceuta y Melilla: contacta con nosotros.</p><h2>Envío estándar (3-5 días)</h2><p>Coste según peso. Gratis a partir de un umbral.</p><h2>Envío express 24h</h2><p>Disponible para pedidos antes de las 13:00h.</p>',
|
||||
status: 'published',
|
||||
},
|
||||
{
|
||||
slug: 'privacy',
|
||||
title: 'Política de privacidad',
|
||||
body: '<h2>Responsable del tratamiento</h2><p>MercadoDeVida. hola@mercadodevida.es</p><h2>Finalidad</h2><p>Gestión de pedidos, atención al cliente y envío de comunicaciones comerciales.</p><h2>Tus derechos</h2><p>Acceso, rectificación, supresión y portabilidad de tus datos. Contacta: hola@mercadodevida.es</p>',
|
||||
status: 'published',
|
||||
},
|
||||
{
|
||||
slug: 'terms',
|
||||
title: 'Términos y condiciones',
|
||||
body: '<h2>Contratación</h2><p>Al realizar un pedido aceptas estos términos. Los precios incluyen IVA.</p><h2>Envíos</h2><p>Ver sección de envíos.</p><h2>Devoluciones</h2><p>Tienes 14 días para devolver productos no perecederos en su estado original.</p>',
|
||||
status: 'published',
|
||||
},
|
||||
{
|
||||
slug: 'cookies',
|
||||
title: 'Política de cookies',
|
||||
body: '<h2>¿Qué son las cookies?</h2><p>Pequños archivos que se almacenan en tu navegador para mejorar tu experiencia.</p><h2>Cookies que usamos</h2><p>Cookies de sesión (carrito, login), cookies analíticas (opcionales) y cookies de preferencias.</p><h2>Tu consentimiento</h2><p>Al navegar aceptas el uso de cookies. Puedes desactivarlas en tu navegador.</p>',
|
||||
status: 'published',
|
||||
},
|
||||
];
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
// About page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'about',
|
||||
'Quiénes somos',
|
||||
'<h2>Nuestra historia</h2><p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado.</p><h2>Nuestra misión</h2><p>Facilitar el acceso a productos naturales y orgánicos de alta calidad.</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
|
||||
for (const p of pages) {
|
||||
await pool.query(
|
||||
`INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (slug) DO NOTHING`,
|
||||
[p.slug, p.title, p.body, p.status],
|
||||
);
|
||||
}
|
||||
// Contact page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'contact',
|
||||
'Contacto',
|
||||
'<h2>Email</h2><p><a href="mailto:hola@mercadodevida.es">hola@mercadodevida.es</a></p><p>Intentamos responder en 24-48h laborables.</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
|
||||
// Shipping page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'shipping',
|
||||
'Envíos y entregas',
|
||||
'<h2>Zonas de envío</h2><p>España peninsular. Canarias, Ceuta y Melilla: contacta con nosotros.</p><h2>Envío estándar (3-5 días)</h2><p>Coste según peso. Gratis a partir de un umbral.</p><h2>Envío express 24h</h2><p>Disponible para pedidos antes de las 13:00h.</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
|
||||
// Privacy page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'privacy',
|
||||
'Política de privacidad',
|
||||
'<h2>Responsable del tratamiento</h2><p>MercadoDeVida. hola@mercadodevida.es</p><h2>Finalidad</h2><p>Gestión de pedidos, atención al cliente y envío de comunicaciones comerciales.</p><h2>Tus derechos</h2><p>Acceso, rectificación, supresión y portabilidad de tus datos. Contacta: hola@mercadodevida.es</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
|
||||
// Terms page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'terms',
|
||||
'Términos y condiciones',
|
||||
'<h2>Contratación</h2><p>Al realizar un pedido aceptas estos términos. Los precios incluyen IVA.</p><h2>Envíos</h2><p>Ver sección de envíos.</p><h2>Devoluciones</h2><p>Tienes 14 días para devolver productos no perecederos en su estado original.</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
|
||||
// Cookies page
|
||||
pgm.sql(`
|
||||
INSERT INTO cms_pages (slug, title, body, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'cookies',
|
||||
'Política de cookies',
|
||||
'<h2>¿Qué son las cookies?</h2><p>Pequños archivos que se almacenan en tu navegador para mejorar tu experiencia.</p><h2>Cookies que usamos</h2><p>Cookies de sesión (carrito, login), cookies analíticas (opcionales) y cookies de preferencias.</p><h2>Tu consentimiento</h2><p>Al navegar aceptas el uso de cookies. Puedes desactivarlas en tu navegador.</p>',
|
||||
'published',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async (pool) => {
|
||||
await pool.query(`DELETE FROM cms_pages WHERE slug IN ('about','contact','shipping','privacy','terms','cookies')`);
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(
|
||||
`DELETE FROM cms_pages WHERE slug IN ('about','contact','shipping','privacy','terms','cookies')`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,83 +1,44 @@
|
||||
/**
|
||||
* Migration 026: Fuzzy search with trigram similarity (pg_trgm)
|
||||
* Migration 026: Fuzzy search with trigram similarity (pg_trgm).
|
||||
*
|
||||
* - Enables pg_trgm extension for Levenshtein-like fuzzy matching
|
||||
* - Creates GIN indexes on product name/description for fast trigram similarity
|
||||
* - Adds similarity index on brands and categories for term suggestions
|
||||
*
|
||||
* Run: node migrations/026_fuzzy_search.js
|
||||
* This is an ordered node-pg-migrate migration. It must not open its own
|
||||
* connection or execute work while the migration module is being loaded.
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida',
|
||||
});
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS catalog_products_name_trgm_idx
|
||||
ON catalog_products
|
||||
USING GIN (name gin_trgm_ops)
|
||||
`);
|
||||
|
||||
async function migrate() {
|
||||
const client = await pool.connect();
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS catalog_products_description_trgm_idx
|
||||
ON catalog_products
|
||||
USING GIN (description gin_trgm_ops)
|
||||
`);
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS brands_brands_name_trgm_idx
|
||||
ON brands_brands
|
||||
USING GIN (name gin_trgm_ops)
|
||||
`);
|
||||
|
||||
// 1. Enable pg_trgm extension
|
||||
await client.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
`);
|
||||
console.log('✓ pg_trgm extension enabled');
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS categories_categories_name_trgm_idx
|
||||
ON categories_categories
|
||||
USING GIN (name gin_trgm_ops)
|
||||
`);
|
||||
};
|
||||
|
||||
// 2. GIN index on product name for trigram similarity
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS catalog_products_name_trgm_idx
|
||||
ON catalog_products
|
||||
USING GIN (name gin_trgm_ops);
|
||||
`);
|
||||
console.log('✓ catalog_products name trigram index');
|
||||
|
||||
// 3. GIN index on product description
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS catalog_products_description_trgm_idx
|
||||
ON catalog_products
|
||||
USING GIN (description gin_trgm_ops);
|
||||
`);
|
||||
console.log('✓ catalog_products description trigram index');
|
||||
|
||||
// 4. GIN index on brand names (for suggestions)
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS brands_brands_name_trgm_idx
|
||||
ON brands_brands
|
||||
USING GIN (name gin_trgm_ops);
|
||||
`);
|
||||
console.log('✓ brands_brands name trigram index');
|
||||
|
||||
// 5. GIN index on category names (for suggestions)
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS categories_categories_name_trgm_idx
|
||||
ON categories_categories
|
||||
USING GIN (name gin_trgm_ops);
|
||||
`);
|
||||
console.log('✓ categories_categories name trigram index');
|
||||
|
||||
// 6. Record migration
|
||||
await client.query(`
|
||||
INSERT INTO pgmigrations (name, run_on)
|
||||
VALUES ('026_fuzzy_search', NOW())
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
`);
|
||||
console.log('✓ Migration recorded');
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('\n✅ Migration 026 complete: fuzzy search indexes created');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('❌ Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql('DROP INDEX IF EXISTS categories_categories_name_trgm_idx');
|
||||
pgm.sql('DROP INDEX IF EXISTS brands_brands_name_trgm_idx');
|
||||
pgm.sql('DROP INDEX IF EXISTS catalog_products_description_trgm_idx');
|
||||
pgm.sql('DROP INDEX IF EXISTS catalog_products_name_trgm_idx');
|
||||
pgm.sql('DROP EXTENSION IF EXISTS pg_trgm');
|
||||
};
|
||||
|
||||
23
project/migrations/027_category_is_parent.js
Normal file
23
project/migrations/027_category_is_parent.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* FIX-19: categories parent/child type.
|
||||
* Adds is_parent flag (container category). A CHILD is a leaf and cannot
|
||||
* contain children; only PARENT categories can contain other categories.
|
||||
*/
|
||||
|
||||
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE categories_categories ADD COLUMN is_parent boolean NOT NULL DEFAULT false`);
|
||||
// Migration rule: categories that already have children become parents.
|
||||
pgm.sql(`
|
||||
UPDATE categories_categories c
|
||||
SET is_parent = true
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM categories_categories ch WHERE ch.parent_id = c.id
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
export const down = (pgm) => {
|
||||
pgm.sql('ALTER TABLE categories_categories DROP COLUMN IF EXISTS is_parent');
|
||||
};
|
||||
71
project/migrations/028_backoffice_users.js
Normal file
71
project/migrations/028_backoffice_users.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* FIX-14: Physical separation of backoffice users (admin/editor) from
|
||||
* storefront customers (identity_users). Backoffice users get their own
|
||||
* table, their own sessions, and their own auth cookie/session mechanism.
|
||||
*
|
||||
* Customers (role = 'customer') remain in identity_users.
|
||||
* Admin/editor users are COPIED into backoffice_users (preserving password
|
||||
* hash and mfa_enrolled flag) and then REMOVED from identity_users so the
|
||||
* tables do not overlap at all.
|
||||
*/
|
||||
|
||||
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
|
||||
export const up = (pgm) => {
|
||||
// 1) Create the new backoffice schema namespace (table names: backoffice_*)
|
||||
pgm.sql(`
|
||||
CREATE TABLE backoffice_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email citext NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'admin'
|
||||
CHECK (role IN ('admin', 'editor')),
|
||||
mfa_enrolled boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
pgm.sql(`
|
||||
CREATE TABLE backoffice_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz
|
||||
)
|
||||
`);
|
||||
pgm.sql('CREATE INDEX backoffice_sessions_user_id_idx ON backoffice_sessions (user_id)');
|
||||
|
||||
// 2) Migrate admin/editor rows from identity_users → backoffice_users.
|
||||
// Preserve id and password_hash so existing sessions/hashes survive.
|
||||
pgm.sql(`
|
||||
INSERT INTO backoffice_users (id, email, password_hash, role, mfa_enrolled, created_at, updated_at)
|
||||
SELECT id, email, password_hash,
|
||||
CASE WHEN role = 'editor' THEN 'editor' ELSE 'admin' END,
|
||||
COALESCE(mfa_enrolled, false),
|
||||
created_at, updated_at
|
||||
FROM identity_users
|
||||
WHERE role IN ('admin', 'editor')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`);
|
||||
|
||||
// 3) Remove admin/editor rows from identity_users so the tables are disjoint.
|
||||
pgm.sql(`DELETE FROM identity_users WHERE role IN ('admin', 'editor')`);
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate'). MigrationBuilder} pgm */
|
||||
export const down = (pgm) => {
|
||||
// Recreate the dropped rows in identity_users so the schema is reversible
|
||||
// for the dev environment (best-effort — email uniqueness may collide if
|
||||
// a customer signed up with the same email after the migration).
|
||||
pgm.sql(`
|
||||
INSERT INTO identity_users (id, email, password_hash, role, created_at, updated_at)
|
||||
SELECT id, email, password_hash,
|
||||
CASE WHEN role = 'editor' THEN 'admin' ELSE role END,
|
||||
created_at, updated_at
|
||||
FROM backoffice_users
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`);
|
||||
pgm.sql('DROP TABLE IF EXISTS backoffice_sessions');
|
||||
pgm.sql('DROP TABLE IF EXISTS backoffice_users');
|
||||
};
|
||||
267
project/package-lock.json
generated
267
project/package-lock.json
generated
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/swagger": "^9.8.1",
|
||||
"@fastify/swagger-ui": "^6.1.1",
|
||||
"argon2": "^0.45.1",
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
@@ -624,6 +626,22 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
|
||||
"integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
|
||||
@@ -826,6 +844,100 @@
|
||||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/send": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz",
|
||||
"integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"fast-decode-uri-component": "^1.0.1",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/static": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz",
|
||||
"integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/accept-negotiator": "^2.0.0",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"@fastify/send": "^4.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"fastq": "^1.17.1",
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/swagger": {
|
||||
"version": "9.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz",
|
||||
"integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"json-schema-resolver": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"rfdc": "^1.3.1",
|
||||
"yaml": "^2.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/swagger-ui": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.1.tgz",
|
||||
"integrity": "sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^10.1.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"rfdc": "^1.3.1",
|
||||
"yaml": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -899,6 +1011,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -2046,6 +2167,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
@@ -2094,7 +2228,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2125,6 +2258,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2198,6 +2340,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2780,6 +2928,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2817,6 +2985,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
|
||||
@@ -2920,6 +3094,39 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz",
|
||||
"integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"fast-uri": "^3.0.5",
|
||||
"rfdc": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/Eomm/json-schema-resolver?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-resolver/node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -3044,6 +3251,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -3070,7 +3289,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -3154,6 +3372,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3705,6 +3929,12 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3768,6 +3998,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
@@ -3951,6 +4190,15 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -4292,6 +4540,21 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MercadoDeVida vNext backend - modular monolith skeleton",
|
||||
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node --env-file=.env dist/infrastructure/http/server.js",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"lint": "eslint src scripts migrations && prettier --check src scripts migrations",
|
||||
"lint:boundaries": "node scripts/check-module-boundaries.mjs src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -24,6 +24,8 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/swagger": "^9.8.1",
|
||||
"@fastify/swagger-ui": "^6.1.1",
|
||||
"argon2": "^0.45.1",
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
|
||||
@@ -72,7 +72,12 @@ function checkFile(file, rootAbs, violations) {
|
||||
// R1: stay inside own module or go to shared
|
||||
const ownModule = relTarget.startsWith(path.join('modules', sourceInModule) + path.sep);
|
||||
const toShared = targetParts[0] === 'shared';
|
||||
if (!ownModule && !toShared) {
|
||||
const toOtherModulePublicIndex =
|
||||
targetInModule !== null &&
|
||||
targetInModule !== sourceInModule &&
|
||||
targetParts.length === 3 &&
|
||||
targetParts[2] === 'index';
|
||||
if (!ownModule && !toShared && !toOtherModulePublicIndex) {
|
||||
violations.push(
|
||||
`R1 violation: ${relFile} imports "${specifier}" (escapes module "${sourceInModule}")`,
|
||||
);
|
||||
|
||||
@@ -51,7 +51,12 @@ async function seed(client) {
|
||||
await sql(client, 'DELETE FROM catalog_product_variants', [], 'clear-variants');
|
||||
await sql(client, 'DELETE FROM catalog_product_categories', [], 'clear-prod-cats');
|
||||
await sql(client, 'DELETE FROM catalog_products', [], 'clear-products');
|
||||
await sql(client, "DELETE FROM categories_categories WHERE parent_id IS NOT NULL", [], 'clear-children');
|
||||
await sql(
|
||||
client,
|
||||
'DELETE FROM categories_categories WHERE parent_id IS NOT NULL',
|
||||
[],
|
||||
'clear-children',
|
||||
);
|
||||
await sql(client, 'DELETE FROM categories_categories', [], 'clear-categories');
|
||||
await sql(client, 'DELETE FROM brands_brands', [], 'clear-brands');
|
||||
await sql(client, 'DELETE FROM shipping_methods', [], 'clear-methods');
|
||||
@@ -81,12 +86,15 @@ async function seed(client) {
|
||||
const children = catData.filter((c) => c.parentSlug);
|
||||
for (const cat of [...parents, ...children]) {
|
||||
const parentId = cat.parentSlug ? catBySlug[cat.parentSlug] : null;
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO categories_categories (id, parent_id, name, slug, seo_title) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, seo_title=EXCLUDED.seo_title',
|
||||
[randomUUID(), parentId, cat.name, cat.slug, cat.name],
|
||||
`cat-${cat.slug}`
|
||||
`cat-${cat.slug}`,
|
||||
);
|
||||
const r = await client.query('SELECT id FROM categories_categories WHERE slug = $1', [cat.slug]);
|
||||
const r = await client.query('SELECT id FROM categories_categories WHERE slug = $1', [
|
||||
cat.slug,
|
||||
]);
|
||||
if (r.rows[0]) catBySlug[cat.slug] = r.rows[0].id;
|
||||
}
|
||||
console.log(`+ ${catData.length} categories`);
|
||||
@@ -102,10 +110,11 @@ async function seed(client) {
|
||||
];
|
||||
|
||||
for (const brand of brandData) {
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO brands_brands (id, name, slug, seo_title) VALUES ($1,$2,$3,$4) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, seo_title=EXCLUDED.seo_title',
|
||||
[randomUUID(), brand.name, brand.slug, brand.name],
|
||||
`brand-${brand.slug}`
|
||||
`brand-${brand.slug}`,
|
||||
);
|
||||
const r = await client.query('SELECT id FROM brands_brands WHERE slug = $1', [brand.slug]);
|
||||
if (r.rows[0]) brandBySlug[brand.slug] = r.rows[0].id;
|
||||
@@ -114,25 +123,112 @@ async function seed(client) {
|
||||
|
||||
// ── 3. Products + variants + prices + stock ───────────────────────────
|
||||
const productData = [
|
||||
{ name: 'Almendras Crudas Ecologicas', slug: 'almendras-crudas-ecologicas', brandSlug: 'ecovida', catSlug: 'frutos-secos', price: 895, vat: 'general' },
|
||||
{ name: 'Aceite de Oliva Virgen Extra Bio', slug: 'aceite-oliva-virgen-extra-bio', brandSlug: 'naturgreen', catSlug: 'aceites', price: 1290, vat: 'general' },
|
||||
{ name: 'Te Verde Matcha Premium', slug: 'te-verde-matcha-premium', brandSlug: 'biosana', catSlug: 'hierbas-infusiones', price: 1590, vat: 'general' },
|
||||
{ name: 'Vitamina D3 + K2', slug: 'vitamina-d3-k2', brandSlug: 'verdepura', catSlug: 'vitaminas', price: 1890, vat: 'reduced' },
|
||||
{ name: 'Proteina Guisante Ecologica', slug: 'proteina-guisante-ecologica', brandSlug: 'tierranatural', catSlug: 'proteinas', price: 2490, vat: 'reduced' },
|
||||
{ name: 'Complejo B Natural', slug: 'complejo-b-natural', brandSlug: 'ecovida', catSlug: 'vitaminas', price: 1190, vat: 'reduced' },
|
||||
{ name: 'Crema Facial Hidratante Aloe Vera', slug: 'crema-facial-aloe-vera', brandSlug: 'solnatural', catSlug: 'cremas', price: 2190, vat: 'general' },
|
||||
{ name: 'Jabon Negro Africano', slug: 'jabon-negro-africano', brandSlug: 'biosana', catSlug: 'jabones', price: 790, vat: 'general' },
|
||||
{ name: 'Serum Antioxidante Rosa Mosqueta', slug: 'serum-rosa-mosqueta', brandSlug: 'naturgreen', catSlug: 'cremas', price: 2690, vat: 'general' },
|
||||
{ name: 'Detergente Ecologico Ropa', slug: 'detergente-ecologico-ropa', brandSlug: 'verdepura', catSlug: 'limpieza-ecologica', price: 990, vat: 'general' },
|
||||
{ name: 'Limpiador Multiusos Bio', slug: 'limpiador-multiusos-bio', brandSlug: 'tierranatural', catSlug: 'limpieza-ecologica', price: 890, vat: 'general' },
|
||||
{ name: 'Desinfectante Natural Superficies', slug: 'desinfectante-natural-superficies', brandSlug: 'solnatural', catSlug: 'limpieza-ecologica', price: 1190, vat: 'general' },
|
||||
{
|
||||
name: 'Almendras Crudas Ecologicas',
|
||||
slug: 'almendras-crudas-ecologicas',
|
||||
brandSlug: 'ecovida',
|
||||
catSlug: 'frutos-secos',
|
||||
price: 895,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Aceite de Oliva Virgen Extra Bio',
|
||||
slug: 'aceite-oliva-virgen-extra-bio',
|
||||
brandSlug: 'naturgreen',
|
||||
catSlug: 'aceites',
|
||||
price: 1290,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Te Verde Matcha Premium',
|
||||
slug: 'te-verde-matcha-premium',
|
||||
brandSlug: 'biosana',
|
||||
catSlug: 'hierbas-infusiones',
|
||||
price: 1590,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Vitamina D3 + K2',
|
||||
slug: 'vitamina-d3-k2',
|
||||
brandSlug: 'verdepura',
|
||||
catSlug: 'vitaminas',
|
||||
price: 1890,
|
||||
vat: 'reduced',
|
||||
},
|
||||
{
|
||||
name: 'Proteina Guisante Ecologica',
|
||||
slug: 'proteina-guisante-ecologica',
|
||||
brandSlug: 'tierranatural',
|
||||
catSlug: 'proteinas',
|
||||
price: 2490,
|
||||
vat: 'reduced',
|
||||
},
|
||||
{
|
||||
name: 'Complejo B Natural',
|
||||
slug: 'complejo-b-natural',
|
||||
brandSlug: 'ecovida',
|
||||
catSlug: 'vitaminas',
|
||||
price: 1190,
|
||||
vat: 'reduced',
|
||||
},
|
||||
{
|
||||
name: 'Crema Facial Hidratante Aloe Vera',
|
||||
slug: 'crema-facial-aloe-vera',
|
||||
brandSlug: 'solnatural',
|
||||
catSlug: 'cremas',
|
||||
price: 2190,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Jabon Negro Africano',
|
||||
slug: 'jabon-negro-africano',
|
||||
brandSlug: 'biosana',
|
||||
catSlug: 'jabones',
|
||||
price: 790,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Serum Antioxidante Rosa Mosqueta',
|
||||
slug: 'serum-rosa-mosqueta',
|
||||
brandSlug: 'naturgreen',
|
||||
catSlug: 'cremas',
|
||||
price: 2690,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Detergente Ecologico Ropa',
|
||||
slug: 'detergente-ecologico-ropa',
|
||||
brandSlug: 'verdepura',
|
||||
catSlug: 'limpieza-ecologica',
|
||||
price: 990,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Limpiador Multiusos Bio',
|
||||
slug: 'limpiador-multiusos-bio',
|
||||
brandSlug: 'tierranatural',
|
||||
catSlug: 'limpieza-ecologica',
|
||||
price: 890,
|
||||
vat: 'general',
|
||||
},
|
||||
{
|
||||
name: 'Desinfectante Natural Superficies',
|
||||
slug: 'desinfectante-natural-superficies',
|
||||
brandSlug: 'solnatural',
|
||||
catSlug: 'limpieza-ecologica',
|
||||
price: 1190,
|
||||
vat: 'general',
|
||||
},
|
||||
];
|
||||
|
||||
for (let pi = 0; pi < productData.length; pi++) {
|
||||
const p = productData[pi];
|
||||
const brandId = brandBySlug[p.brandSlug];
|
||||
const catId = catBySlug[p.catSlug];
|
||||
if (!brandId || !catId) { console.warn(` Skipping ${p.slug}: missing brand or cat`); continue; }
|
||||
if (!brandId || !catId) {
|
||||
console.warn(` Skipping ${p.slug}: missing brand or cat`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
@@ -140,50 +236,58 @@ async function seed(client) {
|
||||
const ean = `590123412345${String(pi + 1).padStart(2, '0')}`;
|
||||
const desc = `${p.name} de primera calidad. Producto 100% natural y ecologico.`;
|
||||
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO catalog_products (id, brand_id, name, slug, state, seo_title, description) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, brand_id=EXCLUDED.brand_id, description=EXCLUDED.description',
|
||||
[productId, brandId, p.name, p.slug, 'active', p.name, desc],
|
||||
`product-${p.slug}`
|
||||
`product-${p.slug}`,
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO catalog_product_categories (product_id, category_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[productId, catId],
|
||||
`prodcat-${p.slug}`
|
||||
`prodcat-${p.slug}`,
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO catalog_product_variants (id, product_id, sku, ean) VALUES ($1,$2,$3,$4) ON CONFLICT (sku) DO UPDATE SET ean=EXCLUDED.ean',
|
||||
[variantId, productId, sku, ean],
|
||||
`variant-${sku}`
|
||||
`variant-${sku}`,
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO pricing_variant_prices (variant_id, vat_rate, net_unit_amount_cents) VALUES ($1,$2,$3) ON CONFLICT (variant_id) DO UPDATE SET net_unit_amount_cents=EXCLUDED.net_unit_amount_cents, vat_rate=EXCLUDED.vat_rate',
|
||||
[variantId, p.vat, p.price],
|
||||
`price-${sku}`
|
||||
`price-${sku}`,
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO inventory_stock (variant_id, available, reserved) VALUES ($1,$2,$3) ON CONFLICT (variant_id) DO UPDATE SET available=EXCLUDED.available, reserved=EXCLUDED.reserved',
|
||||
[variantId, 25, 0],
|
||||
`stock-${sku}`
|
||||
`stock-${sku}`,
|
||||
);
|
||||
}
|
||||
console.log(`+ ${productData.length} products (variants, prices, stock)`);
|
||||
|
||||
// ── 4. Shipping ───────────────────────────────────────────────────
|
||||
const zoneId = randomUUID();
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO shipping_zones (id, name, country, postal_code_prefix) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name',
|
||||
[zoneId, 'Espania Peninsular', 'ES', null],
|
||||
'zone-es'
|
||||
'zone-es',
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO shipping_methods (id, zone_id, name, base_cost_cents, free_shipping_threshold_cents) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUUID(), zoneId, 'Estandar', 499, 4900],
|
||||
'method-std'
|
||||
'method-std',
|
||||
);
|
||||
await sql(client,
|
||||
await sql(
|
||||
client,
|
||||
'INSERT INTO shipping_methods (id, zone_id, name, base_cost_cents, free_shipping_threshold_cents) VALUES ($1,$2,$3,$4,$5)',
|
||||
[randomUUID(), zoneId, 'Express 24h', 899, null],
|
||||
'method-express'
|
||||
'method-express',
|
||||
);
|
||||
console.log('+ 1 shipping zone + 2 methods');
|
||||
|
||||
|
||||
@@ -7,11 +7,19 @@ import { performance } from 'node:perf_hooks';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUi from '@fastify/swagger-ui';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type pg from 'pg';
|
||||
import { swaggerConfig } from '../shared/swagger.js';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
|
||||
import {
|
||||
registerBackofficeRoutes,
|
||||
createBackofficeSessionAuthenticator,
|
||||
createCombinedAuthenticator,
|
||||
} from '../modules/backoffice/index.js';
|
||||
import { registerUsersRoutes } from '../modules/users/index.js';
|
||||
import { registerCategoriesRoutes } from '../modules/categories/index.js';
|
||||
import { registerCatalogRoutes } from '../modules/catalog/index.js';
|
||||
@@ -30,7 +38,7 @@ import { registerCmsRoutes } from '../modules/cms/index.js';
|
||||
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
|
||||
import { CacheService, InMemoryCacheAdapter, registerCacheRoutes } from '../modules/cache/index.js';
|
||||
import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/security/index.js';
|
||||
import { registerAdminStatsRoutes } from '../modules/admin-stats/api/stats.routes.js';
|
||||
import { registerAdminStatsRoutes } from '../modules/admin-stats/index.js';
|
||||
import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js';
|
||||
import { LoggingEmailProvider } from '../modules/notifications/index.js';
|
||||
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
|
||||
@@ -75,6 +83,10 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
const startTimes = new WeakMap<FastifyRequest, number>();
|
||||
|
||||
const app = Fastify({ logger: false, genReqId: generateRequestId });
|
||||
// Route schemas are also the OpenAPI source. Several endpoints intentionally
|
||||
// use broad response schemas, so serialize the explicit route DTOs without
|
||||
// letting fast-json-stringify discard fields absent from those broad schemas.
|
||||
app.setSerializerCompiler(() => (payload) => JSON.stringify(payload));
|
||||
app.decorate('flags', flags);
|
||||
|
||||
await app.register(fastifyCors, {
|
||||
@@ -119,8 +131,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
details = error.details;
|
||||
message = statusCode < 500 ? error.message : 'Internal Server Error';
|
||||
} else if (statusCode < 500) {
|
||||
code = error.code ?? 'BAD_REQUEST';
|
||||
message = error.message;
|
||||
if (error.code === 'FST_ERR_VALIDATION') {
|
||||
code = 'VALIDATION_ERROR';
|
||||
message = 'Invalid request';
|
||||
} else {
|
||||
code = error.code ?? 'BAD_REQUEST';
|
||||
message = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusCode >= 500) {
|
||||
@@ -144,6 +161,17 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
// Cookie infrastructure is cross-module (identity + users): register once at root.
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
// ── OpenAPI / Swagger ────────────────────────────────────────────────────────
|
||||
await app.register(fastifySwagger, swaggerConfig);
|
||||
await app.register(fastifySwaggerUi, {
|
||||
routePrefix: '/docs',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (deps.pool) {
|
||||
// Session resolution; created before identity registration so it can be passed
|
||||
// to identity's /auth/me route.
|
||||
@@ -157,33 +185,48 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
});
|
||||
});
|
||||
|
||||
// FIX-14: backoffice has its own auth mechanism (backoffice_session cookie,
|
||||
// backoffice_users table). Two more authenticators:
|
||||
// backofficeAuth — backoffice_session only (for backoffice routes).
|
||||
// combinedAuth — tries backoffice_session first, then mdv_session
|
||||
// (for routes shared by both admin and storefront).
|
||||
const backofficeAuth = createBackofficeSessionAuthenticator(deps.pool as pg.Pool);
|
||||
const combinedAuth = createCombinedAuthenticator(deps.pool as pg.Pool);
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerBackofficeRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate: backofficeAuth,
|
||||
});
|
||||
});
|
||||
|
||||
// Session resolution is identity's; users receives it by injection so no
|
||||
// module ever imports another module.
|
||||
await app.register(async (instance) => {
|
||||
await registerUsersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCategoriesRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerBrandsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCatalogRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
@@ -192,7 +235,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerInventoryRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +243,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPricingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,14 +251,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPromotionsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCartRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
pricing,
|
||||
inventory,
|
||||
promotions,
|
||||
@@ -225,27 +268,27 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerShippingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerOrdersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
const { telemetry, meter } = createInMemoryTelemetry();
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerMetricsRoutes(instance, { meter, authenticate });
|
||||
await registerMetricsRoutes(instance, { meter, authenticate: combinedAuth });
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCheckoutRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
tracer: telemetry.tracer,
|
||||
});
|
||||
});
|
||||
@@ -253,7 +296,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPaymentsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test',
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
});
|
||||
@@ -262,7 +305,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerNotificationsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
emailProvider: new LoggingEmailProvider(),
|
||||
});
|
||||
});
|
||||
@@ -270,25 +313,25 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerReviewsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCmsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerStoreSettingsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
await registerAdminStatsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +365,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
invalidation: 'SearchInvalidated',
|
||||
});
|
||||
await app.register(async (instance) => {
|
||||
await registerCacheRoutes(instance, { cache, authenticate });
|
||||
await registerCacheRoutes(instance, { cache, authenticate: combinedAuth });
|
||||
});
|
||||
|
||||
const rateLimiter = new RateLimiter();
|
||||
@@ -330,7 +373,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerSecurityRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
rateLimiter,
|
||||
auditLogger,
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ describe.skipIf(!hasDb)('categories flows (real PostgreSQL)', () => {
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Alimentación', slug: 'alimentacion' },
|
||||
payload: { name: 'Alimentación', slug: 'alimentacion', isParent: true },
|
||||
});
|
||||
expect(root.statusCode).toBe(201);
|
||||
const rootBody = root.json() as { id: string };
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { InventoryService } from '../../modules/inventory/index.js';
|
||||
import { InsufficientStockError } from '../../modules/inventory/domain/errors.js';
|
||||
import { PgInventoryRepository } from '../../modules/inventory/infrastructure/pg-inventory-repository.js';
|
||||
import {
|
||||
createInventoryService,
|
||||
InsufficientStockError,
|
||||
type InventoryService,
|
||||
} from '../../modules/inventory/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
@@ -22,7 +24,7 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
inventory = new InventoryService(new PgInventoryRepository(pool));
|
||||
inventory = createInventoryService(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -98,13 +98,14 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
expect(cross.statusCode).toBe(403);
|
||||
expect(cross.json().error.code).toBe('FORBIDDEN');
|
||||
|
||||
// Own profile does not exist yet -> 404 (authz passed, resource missing).
|
||||
const ownMissing = await app.inject({
|
||||
// Customer detail exists even before optional profile fields are populated.
|
||||
const ownCustomer = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/users/${anaId}`,
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
expect(ownMissing.statusCode).toBe(404);
|
||||
expect(ownCustomer.statusCode).toBe(200);
|
||||
expect(ownCustomer.json()).toMatchObject({ id: anaId, email: 'ana@example.com' });
|
||||
});
|
||||
|
||||
it('PATCH own profile upserts; PATCH is validated', async () => {
|
||||
@@ -157,8 +158,8 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
expect(asAdmin.statusCode).toBe(200);
|
||||
const body = asAdmin.json() as { items: Array<{ userId: string }> };
|
||||
expect(body.items.some((item) => item.userId === anaId)).toBe(true);
|
||||
const body = asAdmin.json() as { items: Array<{ id: string }> };
|
||||
expect(body.items.some((item) => item.id === anaId)).toBe(true);
|
||||
|
||||
// Admin can also read another user's profile (owner-or-admin).
|
||||
const adminReadsBen = await app.inject({
|
||||
@@ -166,8 +167,9 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
url: `/users/${benId}`,
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
// Ben has no profile yet: 404, not 403 (authz passed).
|
||||
expect(adminReadsBen.statusCode).toBe(404);
|
||||
// Customer detail remains available when optional profile fields are absent.
|
||||
expect(adminReadsBen.statusCode).toBe(200);
|
||||
expect(adminReadsBen.json()).toMatchObject({ id: benId, email: 'ben@example.com' });
|
||||
});
|
||||
|
||||
it('address CRUD works end to end for own addresses (AC4)', async () => {
|
||||
|
||||
@@ -43,4 +43,11 @@ describe.skipIf(!hasDb)('migrations', () => {
|
||||
expect(await tableExists(pool, 'identity_users')).toBe(false);
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(false);
|
||||
});
|
||||
|
||||
it('reapplies the full schema after a complete rollback', async () => {
|
||||
await runMigrations(url, 'up');
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(true);
|
||||
expect(await tableExists(pool, 'backoffice_users')).toBe(true);
|
||||
expect(await tableExists(pool, 'categories_categories')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
|
||||
interface AdminStatsDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const statsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Admin dashboard stats',
|
||||
description: 'KPIs del dashboard: pedidos hoy, ingresos, productos sin stock, etc.',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
|
||||
export async function registerAdminStatsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: AdminStatsDeps,
|
||||
): Promise<void> {
|
||||
app.get('/admin/stats', async (request, reply) => {
|
||||
app.get('/admin/stats', { schema: statsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
|
||||
@@ -51,9 +58,7 @@ export async function registerAdminStatsRoutes(
|
||||
`SELECT state, COUNT(*)::text AS count FROM orders_orders
|
||||
GROUP BY state ORDER BY count DESC`,
|
||||
)
|
||||
.then((r) =>
|
||||
Object.fromEntries(r.rows.map((row) => [row.state, parseInt(row.count, 10)])),
|
||||
),
|
||||
.then((r) => Object.fromEntries(r.rows.map((row) => [row.state, parseInt(row.count, 10)]))),
|
||||
|
||||
// Out-of-stock variants
|
||||
pool
|
||||
|
||||
2
project/src/modules/admin-stats/index.ts
Normal file
2
project/src/modules/admin-stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the admin-stats module. */
|
||||
export { registerAdminStatsRoutes } from './api/stats.routes.js';
|
||||
166
project/src/modules/backoffice/api/backoffice.routes.ts
Normal file
166
project/src/modules/backoffice/api/backoffice.routes.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { BackofficeLogin } from '../application/login.js';
|
||||
import { BackofficeLogout } from '../application/logout.js';
|
||||
import { InMemoryBackofficeLoginRateLimiter } from '../infrastructure/backoffice-login-rate-limiter.js';
|
||||
import type { BackofficeLoginRateLimiter } from '../domain/ports.js';
|
||||
import { Argon2BackofficePasswordHasher } from '../infrastructure/backoffice-password-hasher.js';
|
||||
import { PgBackofficeUserRepository } from '../infrastructure/pg-backoffice-user-repository.js';
|
||||
import { PgBackofficeSessionRepository } from '../infrastructure/pg-backoffice-session-repository.js';
|
||||
import {
|
||||
generateBackofficeToken,
|
||||
hashBackofficeToken,
|
||||
} from '../infrastructure/backoffice-session-token.js';
|
||||
import { BACKOFFICE_SESSION_TTL_MS } from '../domain/backoffice-session.js';
|
||||
import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js';
|
||||
|
||||
export const BACKOFFICE_SESSION_COOKIE_NAME = 'backoffice_session';
|
||||
|
||||
export interface BackofficeRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
hasher?: import('../domain/ports.js').BackofficePasswordHasher;
|
||||
rateLimiter?: BackofficeLoginRateLimiter;
|
||||
/** Session authenticator injected by the composition root. */
|
||||
authenticate?: Authenticate;
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function registerBackofficeRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: BackofficeRoutesDeps,
|
||||
): Promise<void> {
|
||||
const hasher = deps.hasher ?? new Argon2BackofficePasswordHasher();
|
||||
const users = new PgBackofficeUserRepository(deps.pool);
|
||||
const sessions = new PgBackofficeSessionRepository(deps.pool);
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryBackofficeLoginRateLimiter();
|
||||
const login = new BackofficeLogin({
|
||||
users,
|
||||
sessions,
|
||||
hasher,
|
||||
rateLimiter,
|
||||
generateToken: generateBackofficeToken,
|
||||
hashToken: hashBackofficeToken,
|
||||
});
|
||||
const logout = new BackofficeLogout(sessions, hashBackofficeToken);
|
||||
|
||||
const loginSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Backoffice login',
|
||||
description:
|
||||
'Inicia sesión en el backoffice y devuelve una cookie `backoffice_session` (válida 8h).',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
||||
},
|
||||
},
|
||||
401: errorSchema,
|
||||
429: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const logoutSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Backoffice logout',
|
||||
response: { 204: { type: 'null' } },
|
||||
};
|
||||
|
||||
const meSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Current backoffice user',
|
||||
description: 'Devuelve el usuario backoffice autenticado o `{user: null}` si no hay sesión.',
|
||||
response: {
|
||||
200: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
||||
},
|
||||
},
|
||||
{ type: 'object', properties: { user: { type: 'null' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
app.post('/backoffice/auth/login', { schema: loginSchema }, async (request, reply) => {
|
||||
const input = credentialsSchema.parse(request.body);
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setCookie(reply, result.token, true);
|
||||
return reply
|
||||
.code(200)
|
||||
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof BackofficeRateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
||||
}
|
||||
if (error instanceof BackofficeInvalidCredentialsError) {
|
||||
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/backoffice/auth/logout', { schema: logoutSchema }, async (request, reply) => {
|
||||
const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
await logout.execute(token);
|
||||
clearCookie(reply, true);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/backoffice/auth/me', { schema: meSchema }, async (request, reply) => {
|
||||
try {
|
||||
const user = await deps.authenticate!(request);
|
||||
return reply.send({ id: user.id, email: user.email, role: user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.statusCode === 401) {
|
||||
return reply.send({ user: null });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCookie(reply: FastifyReply, token: string, secure: boolean): void {
|
||||
void reply.setCookie(BACKOFFICE_SESSION_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
maxAge: Math.floor(BACKOFFICE_SESSION_TTL_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
function clearCookie(reply: FastifyReply, secure: boolean): void {
|
||||
void reply.clearCookie(BACKOFFICE_SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
});
|
||||
}
|
||||
52
project/src/modules/backoffice/application/login.ts
Normal file
52
project/src/modules/backoffice/application/login.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
BackofficeSessionRepository,
|
||||
BackofficeUserRepository,
|
||||
BackofficePasswordHasher,
|
||||
BackofficeLoginRateLimiter,
|
||||
} from '../domain/ports.js';
|
||||
import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js';
|
||||
import type { BackofficeUserWithHash } from '../domain/backoffice-user.js';
|
||||
import { backofficeSessionExpiry, type BackofficeSession } from '../domain/backoffice-session.js';
|
||||
|
||||
export interface BackofficeLoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BackofficeLoginResult {
|
||||
user: BackofficeUserWithHash;
|
||||
session: BackofficeSession;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class BackofficeLogin {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
users: BackofficeUserRepository;
|
||||
sessions: BackofficeSessionRepository;
|
||||
hasher: BackofficePasswordHasher;
|
||||
rateLimiter: BackofficeLoginRateLimiter;
|
||||
generateToken: () => string;
|
||||
hashToken: (token: string) => string;
|
||||
},
|
||||
) {}
|
||||
|
||||
async execute(input: BackofficeLoginInput): Promise<BackofficeLoginResult> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const limitKey = `backoffice-login:${email}`;
|
||||
const decision = this.deps.rateLimiter.hit(limitKey);
|
||||
if (!decision.allowed) throw new BackofficeRateLimitedError(decision.retryAfterMs);
|
||||
|
||||
const user = await this.deps.users.findByEmail(email);
|
||||
if (!user) throw new BackofficeInvalidCredentialsError();
|
||||
|
||||
const ok = await this.deps.hasher.verify(user.passwordHash, input.password);
|
||||
if (!ok) throw new BackofficeInvalidCredentialsError();
|
||||
|
||||
const token = this.deps.generateToken();
|
||||
const tokenHash = this.deps.hashToken(token);
|
||||
const session = await this.deps.sessions.create(user.id, tokenHash, backofficeSessionExpiry());
|
||||
|
||||
return { user, session, token };
|
||||
}
|
||||
}
|
||||
13
project/src/modules/backoffice/application/logout.ts
Normal file
13
project/src/modules/backoffice/application/logout.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BackofficeSessionRepository } from '../domain/ports.js';
|
||||
|
||||
export class BackofficeLogout {
|
||||
constructor(
|
||||
private readonly sessions: BackofficeSessionRepository,
|
||||
private readonly hashToken: (token: string) => string,
|
||||
) {}
|
||||
|
||||
async execute(rawToken: string | undefined): Promise<void> {
|
||||
if (!rawToken) return;
|
||||
await this.sessions.revokeByTokenHash(this.hashToken(rawToken));
|
||||
}
|
||||
}
|
||||
19
project/src/modules/backoffice/domain/backoffice-session.ts
Normal file
19
project/src/modules/backoffice/domain/backoffice-session.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Backoffice session model. Same security model as identity sessions:
|
||||
* only the SHA-256 hash of the opaque token is persisted; the raw token
|
||||
* lives exclusively in the cookie.
|
||||
*/
|
||||
export interface BackofficeSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Backoffice session lifetime: 8 hours (shorter than storefront 7 days). */
|
||||
export const BACKOFFICE_SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export function backofficeSessionExpiry(now: Date = new Date()): Date {
|
||||
return new Date(now.getTime() + BACKOFFICE_SESSION_TTL_MS);
|
||||
}
|
||||
26
project/src/modules/backoffice/domain/backoffice-user.ts
Normal file
26
project/src/modules/backoffice/domain/backoffice-user.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* FIX-14: Backoffice user domain model.
|
||||
* Backoffice users (admin/editor) are physically separated from storefront
|
||||
* customers (identity_users) and authenticate through a separate mechanism.
|
||||
*/
|
||||
export type BackofficeRole = 'admin' | 'editor';
|
||||
|
||||
export interface BackofficeUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: BackofficeRole;
|
||||
mfaEnrolled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BackofficeUserWithHash extends BackofficeUser {
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export interface NewBackofficeUser {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
role?: BackofficeRole;
|
||||
mfaEnrolled?: boolean;
|
||||
}
|
||||
18
project/src/modules/backoffice/domain/errors.ts
Normal file
18
project/src/modules/backoffice/domain/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export class BackofficeEmailAlreadyRegisteredError extends Error {
|
||||
constructor() {
|
||||
super('Backoffice email already registered');
|
||||
this.name = 'BackofficeEmailAlreadyRegisteredError';
|
||||
}
|
||||
}
|
||||
export class BackofficeInvalidCredentialsError extends Error {
|
||||
constructor() {
|
||||
super('Invalid credentials');
|
||||
this.name = 'BackofficeInvalidCredentialsError';
|
||||
}
|
||||
}
|
||||
export class BackofficeRateLimitedError extends Error {
|
||||
constructor(public readonly retryAfterMs: number) {
|
||||
super('Too many attempts');
|
||||
this.name = 'BackofficeRateLimitedError';
|
||||
}
|
||||
}
|
||||
27
project/src/modules/backoffice/domain/ports.ts
Normal file
27
project/src/modules/backoffice/domain/ports.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { BackofficeSession } from './backoffice-session.js';
|
||||
import type {
|
||||
BackofficeUser,
|
||||
BackofficeUserWithHash,
|
||||
NewBackofficeUser,
|
||||
} from './backoffice-user.js';
|
||||
|
||||
export interface BackofficePasswordHasher {
|
||||
hash(plain: string): Promise<string>;
|
||||
verify(hash: string, plain: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface BackofficeUserRepository {
|
||||
create(input: NewBackofficeUser): Promise<BackofficeUser>;
|
||||
findByEmail(email: string): Promise<BackofficeUserWithHash | undefined>;
|
||||
findById(id: string): Promise<BackofficeUser | undefined>;
|
||||
updateMfaEnrolled(id: string, enrolled: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BackofficeSessionRepository {
|
||||
create(userId: string, tokenHash: string, expiresAt: Date): Promise<BackofficeSession>;
|
||||
revokeByTokenHash(tokenHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface BackofficeLoginRateLimiter {
|
||||
hit(key: string): { allowed: boolean; retryAfterMs: number; remaining: number };
|
||||
}
|
||||
12
project/src/modules/backoffice/index.ts
Normal file
12
project/src/modules/backoffice/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Public API of the backoffice module.
|
||||
*/
|
||||
export {
|
||||
registerBackofficeRoutes,
|
||||
BACKOFFICE_SESSION_COOKIE_NAME,
|
||||
type BackofficeRoutesDeps,
|
||||
} from './api/backoffice.routes.js';
|
||||
export {
|
||||
createBackofficeSessionAuthenticator,
|
||||
createCombinedAuthenticator,
|
||||
} from './infrastructure/backoffice-session-authenticator.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BackofficeLoginRateLimiter } from '../domain/ports.js';
|
||||
|
||||
/**
|
||||
* Backoffice login rate limiter (in-memory).
|
||||
* Stricter than the storefront: backoffice is a higher-value target.
|
||||
* 5 attempts per 15-minute window per email+IP.
|
||||
*/
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
export class InMemoryBackofficeLoginRateLimiter implements BackofficeLoginRateLimiter {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
hit(key: string): { allowed: boolean; retryAfterMs: number; remaining: number } {
|
||||
const now = Date.now();
|
||||
const bucket = this.buckets.get(key);
|
||||
if (!bucket || bucket.resetAt < now) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, retryAfterMs: 0, remaining: MAX_ATTEMPTS - 1 };
|
||||
}
|
||||
bucket.count++;
|
||||
const allowed = bucket.count <= MAX_ATTEMPTS;
|
||||
return {
|
||||
allowed,
|
||||
retryAfterMs: allowed ? 0 : bucket.resetAt - now,
|
||||
remaining: Math.max(0, MAX_ATTEMPTS - bucket.count),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BackofficePasswordHasher } from '../domain/ports.js';
|
||||
|
||||
/** Production hasher: Argon2id. Tests can inject a fake. */
|
||||
export class Argon2BackofficePasswordHasher implements BackofficePasswordHasher {
|
||||
async hash(plain: string): Promise<string> {
|
||||
const { default: argon2 } = await import('argon2');
|
||||
return argon2.hash(plain);
|
||||
}
|
||||
async verify(hash: string, plain: string): Promise<boolean> {
|
||||
const { default: argon2 } = await import('argon2');
|
||||
return argon2.verify(hash, plain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Resolves the backoffice_session cookie into the current backoffice user.
|
||||
* Server-side truth: validity (expiry + revocation) and role come from the DB.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { hashBackofficeToken } from './backoffice-session-token.js';
|
||||
import { BACKOFFICE_SESSION_COOKIE_NAME } from '../api/backoffice.routes.js';
|
||||
|
||||
interface ResolvedRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM backoffice_sessions s
|
||||
JOIN backoffice_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
`;
|
||||
|
||||
export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||
return async (request: FastifyRequest) => {
|
||||
const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
if (!token) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashBackofficeToken(token)]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
||||
return { id: row.id, email: row.email, role: row.role as 'admin' | 'editor' };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined authenticator for routes used by both backoffice and storefront
|
||||
* (e.g. /users — admin lists customers via backoffice, customer manages own
|
||||
* profile via storefront). Tries backoffice_session first, then mdv_session.
|
||||
*/
|
||||
export function createCombinedAuthenticator(pool: pg.Pool): Authenticate {
|
||||
const backofficeAuth = createBackofficeSessionAuthenticator(pool);
|
||||
const storefrontResolver = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM identity_sessions s
|
||||
JOIN identity_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
|
||||
`;
|
||||
return async (request: FastifyRequest) => {
|
||||
const backofficeToken = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
if (backofficeToken) {
|
||||
try {
|
||||
return await backofficeAuth(request);
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const storefrontToken = request.cookies['mdv_session'];
|
||||
if (storefrontToken) {
|
||||
const { createHash } = await import('node:crypto');
|
||||
const tokenHash = createHash('sha256').update(storefrontToken).digest('hex');
|
||||
const result = await pool.query<ResolvedRow>(storefrontResolver, [tokenHash]);
|
||||
const row = result.rows[0];
|
||||
if (row)
|
||||
return { id: row.id, email: row.email, role: row.role as 'customer' | 'admin' | 'editor' };
|
||||
}
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Backoffice session tokens. Same model as identity: opaque token in the
|
||||
* cookie, SHA-256 hash persisted. The two tables/sessions are independent;
|
||||
* a leaked backoffice_sessions row yields no usable storefront sessions.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export function generateBackofficeToken(): string {
|
||||
return randomBytes(64).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashBackofficeToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type pg from 'pg';
|
||||
import type { BackofficeSession } from '../domain/backoffice-session.js';
|
||||
import type { BackofficeSessionRepository } from '../domain/ports.js';
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
created_at: Date;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
export class PgBackofficeSessionRepository implements BackofficeSessionRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(userId: string, tokenHash: string, expiresAt: Date): Promise<BackofficeSession> {
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3) RETURNING *`,
|
||||
[userId, tokenHash, expiresAt],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('backoffice_sessions INSERT returned no row');
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeByTokenHash(tokenHash: string): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
`UPDATE backoffice_sessions SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`,
|
||||
[tokenHash],
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type pg from 'pg';
|
||||
import type {
|
||||
BackofficeRole,
|
||||
BackofficeUser,
|
||||
BackofficeUserWithHash,
|
||||
NewBackofficeUser,
|
||||
} from '../domain/backoffice-user.js';
|
||||
import type { BackofficeUserRepository } from '../domain/ports.js';
|
||||
import { BackofficeEmailAlreadyRegisteredError } from '../domain/errors.js';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
mfa_enrolled: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export class PgBackofficeUserRepository implements BackofficeUserRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(input: NewBackofficeUser): Promise<BackofficeUser> {
|
||||
try {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO backoffice_users (email, password_hash, role, mfa_enrolled)
|
||||
VALUES ($1, $2, COALESCE($3, 'admin'), COALESCE($4, false))
|
||||
RETURNING *`,
|
||||
[
|
||||
input.email.toLowerCase(),
|
||||
input.passwordHash,
|
||||
input.role ?? null,
|
||||
input.mfaEnrolled ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('backoffice_users INSERT returned no row');
|
||||
return toUser(row);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new BackofficeEmailAlreadyRegisteredError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<BackofficeUserWithHash | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
'SELECT * FROM backoffice_users WHERE email = $1',
|
||||
[email.toLowerCase()],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? { ...toUser(row), passwordHash: row.password_hash } : undefined;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BackofficeUser | undefined> {
|
||||
const result = await this.pool.query<UserRow>('SELECT * FROM backoffice_users WHERE id = $1', [
|
||||
id,
|
||||
]);
|
||||
const row = result.rows[0];
|
||||
return row ? toUser(row) : undefined;
|
||||
}
|
||||
|
||||
async updateMfaEnrolled(id: string, enrolled: boolean): Promise<void> {
|
||||
await this.pool.query(
|
||||
'UPDATE backoffice_users SET mfa_enrolled = $2, updated_at = now() WHERE id = $1',
|
||||
[id, enrolled],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toUser(row: UserRow): BackofficeUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role as BackofficeRole,
|
||||
mfaEnrolled: row.mfa_enrolled,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateBrand,
|
||||
@@ -50,12 +52,22 @@ export async function registerBrandsRoutes(
|
||||
const createBrand = new CreateBrand(repository);
|
||||
const updateBrand = new UpdateBrand(repository);
|
||||
|
||||
app.get('/brands', async (_request, reply) => {
|
||||
const listBrandsSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'List brands (público)',
|
||||
};
|
||||
app.get('/brands', { schema: listBrandsSchema }, async (_request, reply) => {
|
||||
const items = await listBrands.execute();
|
||||
return reply.send({ items: items.map(serializeBrand) });
|
||||
});
|
||||
|
||||
app.get('/marca/:slug', async (request, reply) => {
|
||||
const publicBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Get brand by slug (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/marca/:slug', { schema: publicBrandSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const brand = await getBySlug.execute(slug);
|
||||
if (!brand) {
|
||||
@@ -64,7 +76,13 @@ export async function registerBrandsRoutes(
|
||||
return reply.send(serializeBrand(brand));
|
||||
});
|
||||
|
||||
app.post('/brands', async (request, reply) => {
|
||||
const createBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Create brand (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/brands', { schema: createBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newBrandSchema, request.body);
|
||||
@@ -76,7 +94,18 @@ export async function registerBrandsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/brands/:id', async (request, reply) => {
|
||||
const updateBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Update brand (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/brands/:id', { schema: updateBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -93,7 +122,17 @@ export async function registerBrandsRoutes(
|
||||
});
|
||||
|
||||
// DELETE /brands/:id
|
||||
app.delete('/brands/:id', async (request, reply) => {
|
||||
const deleteBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Delete brand (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/brands/:id', { schema: deleteBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
|
||||
16
project/src/modules/cache/api/cache.routes.ts
vendored
16
project/src/modules/cache/api/cache.routes.ts
vendored
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { CacheService } from '../application/cache-service.js';
|
||||
|
||||
export interface CacheRoutesDeps {
|
||||
@@ -12,13 +14,23 @@ export async function registerCacheRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: CacheRoutesDeps,
|
||||
): Promise<void> {
|
||||
app.get('/cache/contracts', async (request, reply) => {
|
||||
const contractsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'List cache contracts (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/cache/contracts', { schema: contractsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
return reply.send({ items: deps.cache.listContracts() });
|
||||
});
|
||||
|
||||
app.get('/cache/metrics', async (request, reply) => {
|
||||
const cacheMetricsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Cache metrics',
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.get('/cache/metrics', { schema: cacheMetricsSchema }, async (request, reply) => {
|
||||
await deps.authenticate(request);
|
||||
return reply.send(deps.cache.metrics());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import type { InventoryServicePort } from '../../inventory/index.js';
|
||||
import type { PricingServicePort } from '../../pricing/index.js';
|
||||
@@ -38,12 +40,24 @@ export async function registerCartRoutes(
|
||||
deps.promotions,
|
||||
);
|
||||
|
||||
app.get('/cart', async (request, reply) => {
|
||||
const cartSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Get cart',
|
||||
description: 'Devuelve el carrito del usuario autenticado.',
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.get('/cart', { schema: cartSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
return reply.send(serializeCart(await service.getCart(user.id)));
|
||||
});
|
||||
|
||||
app.post('/cart/items', async (request, reply) => {
|
||||
const addItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Add item to cart',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema },
|
||||
};
|
||||
app.post('/cart/items', { schema: addItemSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(itemBodySchema, request.body);
|
||||
try {
|
||||
@@ -53,7 +67,22 @@ export async function registerCartRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/cart/items/:variantId', async (request, reply) => {
|
||||
const updateItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Change item quantity',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 1 } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.patch('/cart/items/:variantId', { schema: updateItemSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(quantityBodySchema, request.body);
|
||||
@@ -64,13 +93,33 @@ export async function registerCartRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/cart/items/:variantId', async (request, reply) => {
|
||||
const removeItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Remove item from cart',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.delete('/cart/items/:variantId', { schema: removeItemSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
return reply.send(serializeCart(await service.removeItem(user.id, variantId)));
|
||||
});
|
||||
|
||||
app.post('/cart/promo-code', async (request, reply) => {
|
||||
const promoSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Apply promo code',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: { code: { type: 'string', maxLength: 64 } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.post('/cart/promo-code', { schema: promoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { code } = parseJson(promoCodeBodySchema, request.body);
|
||||
try {
|
||||
|
||||
@@ -32,9 +32,10 @@ export class CartService {
|
||||
}
|
||||
|
||||
async applyPromoCode(userId: string, code: string): Promise<CartView> {
|
||||
if (!this.promotions) return this.toView(await this.carts.setPromoCode(userId, code));
|
||||
await this.promotions.validateCode(code);
|
||||
return this.toView(await this.carts.setPromoCode(userId, code.trim().toUpperCase()));
|
||||
const normalizedCode = code.trim().toUpperCase();
|
||||
if (!this.promotions) return this.toView(await this.carts.setPromoCode(userId, normalizedCode));
|
||||
await this.promotions.validateCode(normalizedCode);
|
||||
return this.toView(await this.carts.setPromoCode(userId, normalizedCode));
|
||||
}
|
||||
|
||||
private async toView(
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
/** Public API of the cart module. */
|
||||
import type pg from 'pg';
|
||||
import type { InventoryServicePort } from '../inventory/index.js';
|
||||
import type { PricingServicePort } from '../pricing/index.js';
|
||||
import type { PromotionServicePort } from '../promotions/index.js';
|
||||
import { CartService } from './application/cart-service.js';
|
||||
import { PgCartRepository } from './infrastructure/pg-cart-repository.js';
|
||||
|
||||
export { registerCartRoutes, type CartRoutesDeps } from './api/cart.routes.js';
|
||||
export { CartService } from './application/cart-service.js';
|
||||
export type { Cart, CartItem, CartItemInput, CartItemView, CartView } from './domain/cart.js';
|
||||
export type { CartRepository } from './domain/ports.js';
|
||||
|
||||
export function createCartService(
|
||||
pool: pg.Pool,
|
||||
pricing: PricingServicePort,
|
||||
inventory: InventoryServicePort,
|
||||
promotions?: PromotionServicePort,
|
||||
): CartService {
|
||||
return new CartService(new PgCartRepository(pool), pricing, inventory, promotions);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import {
|
||||
AttachProductImage,
|
||||
DetachProductImage,
|
||||
@@ -121,7 +123,7 @@ const imageUrlSchema = z
|
||||
|
||||
const newImageSchema = z.object({
|
||||
url: imageUrlSchema,
|
||||
altText: z.string().trim().min(1).max(300),
|
||||
altText: z.string().trim().max(300).optional().default(''),
|
||||
role: z.enum(PRODUCT_IMAGE_ROLES),
|
||||
variantId: z.uuid().optional().nullable(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
@@ -180,7 +182,21 @@ export async function registerCatalogRoutes(
|
||||
const reorderImages = new ReorderProductImages(images);
|
||||
|
||||
// Admin: list all products (any state)
|
||||
app.get('/catalog/products', async (request, reply) => {
|
||||
const catalogListSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List all products (admin)',
|
||||
description: 'Lista todos los productos incluyendo los no activos. Requiere rol admin.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Búsqueda por nombre' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/catalog/products', { schema: catalogListSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const q = (request.query as { q?: string }).q;
|
||||
@@ -190,7 +206,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send({ items: result.items.map((p) => serializeProduct(p)), total: result.total });
|
||||
});
|
||||
|
||||
app.get('/productos/:slug', async (request, reply) => {
|
||||
const publicProductSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Get product by slug (público)',
|
||||
description: 'Devuelve un producto activo con todas sus imágenes.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['slug'],
|
||||
properties: { slug: { type: 'string', description: 'URL slug del producto' } },
|
||||
},
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/productos/:slug', { schema: publicProductSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const product = await getBySlug.execute(slug);
|
||||
if (!product) {
|
||||
@@ -200,8 +227,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product, productImages));
|
||||
});
|
||||
|
||||
// Admin: get product by ID (for editor)
|
||||
app.get('/products/:id', async (request, reply) => {
|
||||
const adminGetProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Get product by ID (admin)',
|
||||
description: 'Obtiene un producto por su UUID (incluye no activos). Requiere admin.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get('/products/:id', { schema: adminGetProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -213,7 +250,23 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product, productImages));
|
||||
});
|
||||
|
||||
app.get('/products/search', async (request, reply) => {
|
||||
const searchSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Search products (público)',
|
||||
description: 'Búsqueda full-text con filtros opcionales por marca y categoría.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', maxLength: 200, description: 'Consulta de búsqueda' },
|
||||
brandSlug: { type: 'string', description: 'Slug de marca' },
|
||||
categorySlug: { type: 'string', description: 'Slug de categoría' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { items: { type: 'array' } } } },
|
||||
};
|
||||
app.get('/products/search', { schema: searchSchema }, async (request, reply) => {
|
||||
const input = parseJson(searchQuerySchema, request.query);
|
||||
const startedAt = performance.now();
|
||||
const items = await searchProducts.execute(input);
|
||||
@@ -234,16 +287,36 @@ export async function registerCatalogRoutes(
|
||||
return reply.send({ items: items.map((product) => serializeProduct(product)) });
|
||||
});
|
||||
|
||||
app.get('/products/suggest', async (request, reply) => {
|
||||
const { q } = parseJson(
|
||||
z.object({ q: z.string().min(2).max(200) }),
|
||||
request.query,
|
||||
);
|
||||
const suggestSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Search suggestions',
|
||||
description: 'Devuelve correcciones sugeridas para la consulta de búsqueda.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['q'],
|
||||
properties: { q: { type: 'string', minLength: 2, maxLength: 200 } },
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { suggestions: { type: 'array', items: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
app.get('/products/suggest', { schema: suggestSchema }, async (request, reply) => {
|
||||
const { q } = parseJson(z.object({ q: z.string().min(2).max(200) }), request.query);
|
||||
const suggestions = await suggestCorrections.execute(q);
|
||||
return reply.send({ suggestions });
|
||||
});
|
||||
|
||||
app.post('/products', async (request, reply) => {
|
||||
const createProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Create product',
|
||||
description: 'Crea un nuevo producto. Requiere rol admin.',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/products', { schema: createProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newProductSchema, request.body);
|
||||
@@ -255,19 +328,49 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/products/:id/variants', async (request, reply) => {
|
||||
const variantsSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List product variants',
|
||||
description: 'Lista las variantes de un producto.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
};
|
||||
app.get('/products/:id/variants', { schema: variantsSchema }, async (request, reply) => {
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const items = await listVariants.execute(id);
|
||||
return reply.send({ items: items.map(serializeVariant) });
|
||||
});
|
||||
|
||||
app.get('/products/:id/images', async (request, reply) => {
|
||||
const imagesSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List product images',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
};
|
||||
app.get('/products/:id/images', { schema: imagesSchema }, async (request, reply) => {
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const items = await listImages.execute(id);
|
||||
return reply.send({ items: items.map(serializeImage) });
|
||||
});
|
||||
|
||||
app.post('/products/:id/images', async (request, reply) => {
|
||||
const attachImageSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Attach image to product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.post('/products/:id/images', { schema: attachImageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -283,27 +386,70 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/products/:id/images/:imageId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id, imageId } = parseJson(imageParamSchema, request.params);
|
||||
const deleted = await detachImage.execute(id, imageId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Image not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
const deleteImageSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Delete product image',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'imageId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
imageId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete(
|
||||
'/products/:id/images/:imageId',
|
||||
{ schema: deleteImageSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id, imageId } = parseJson(imageParamSchema, request.params);
|
||||
const deleted = await detachImage.execute(id, imageId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Image not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.patch('/products/:id/images/reorder', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const { items } = parseJson(reorderImagesSchema, request.body);
|
||||
const ordered = await reorderImages.execute(id, items);
|
||||
return reply.send({ items: ordered.map(serializeImage) });
|
||||
});
|
||||
const reorderImagesSwaggerSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Reorder product images',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/products/:id/images/reorder',
|
||||
{ schema: reorderImagesSwaggerSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const { items } = parseJson(reorderImagesSchema, request.body);
|
||||
const ordered = await reorderImages.execute(id, items);
|
||||
return reply.send({ items: ordered.map(serializeImage) });
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/products/:id/variants', async (request, reply) => {
|
||||
const createVariantSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Create product variant',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/products/:id/variants', { schema: createVariantSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -319,23 +465,53 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/products/:id/variants/:variantId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id, variantId } = parseJson(variantParamSchema, request.params);
|
||||
const patch = parseJson(variantPatchSchema, request.body);
|
||||
try {
|
||||
const variant = await updateVariant.execute(id, variantId, patch);
|
||||
if (!variant) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Variant not found');
|
||||
const updateVariantSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Update product variant',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'variantId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
variantId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/products/:id/variants/:variantId',
|
||||
{ schema: updateVariantSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id, variantId } = parseJson(variantParamSchema, request.params);
|
||||
const patch = parseJson(variantPatchSchema, request.body);
|
||||
try {
|
||||
const variant = await updateVariant.execute(id, variantId, patch);
|
||||
if (!variant) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Variant not found');
|
||||
}
|
||||
return reply.send(serializeVariant(variant));
|
||||
} catch (error) {
|
||||
throw mapProductError(error);
|
||||
}
|
||||
return reply.send(serializeVariant(variant));
|
||||
} catch (error) {
|
||||
throw mapProductError(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch('/products/:id/rich-data', async (request, reply) => {
|
||||
const richDataSchema2: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Set product rich data',
|
||||
description: 'Establece datos ricos: ingredientes, alérgenos, nutrición, orgánico.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id/rich-data', { schema: richDataSchema2 }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -347,7 +523,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeRichData(data));
|
||||
});
|
||||
|
||||
app.patch('/products/:id', async (request, reply) => {
|
||||
const updateProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Update product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id', { schema: updateProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -363,8 +550,25 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /products/:id/state — change product state
|
||||
app.patch('/products/:id/state', async (request, reply) => {
|
||||
const setStateSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Change product state',
|
||||
description: 'Cambia el estado del producto (draft, active, archived).',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['state'],
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['draft', 'active', 'archived', 'pending_review'] },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id/state', { schema: setStateSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -376,8 +580,17 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product));
|
||||
});
|
||||
|
||||
// DELETE /products/:id
|
||||
app.delete('/products/:id', async (request, reply) => {
|
||||
const deleteProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Delete product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/products/:id', { schema: deleteProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
|
||||
@@ -14,7 +14,11 @@ export interface ProductRepository {
|
||||
create(input: NewProduct): Promise<Product>;
|
||||
update(id: string, patch: ProductPatch): Promise<Product | undefined>;
|
||||
delete(id: string): Promise<void>;
|
||||
listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }>;
|
||||
listAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
}): Promise<{ items: Product[]; total: number }>;
|
||||
categoriesExist(categoryIds: readonly string[]): Promise<boolean>;
|
||||
brandExists(brandId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PRODUCT_ATTRIBUTES = [
|
||||
'zero-waste',
|
||||
] as const;
|
||||
|
||||
export type ProductAttribute = typeof PRODUCT_ATTRIBUTES[number];
|
||||
export type ProductAttribute = (typeof PRODUCT_ATTRIBUTES)[number];
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type pg from 'pg';
|
||||
import { ProductBrandNotFoundError, ProductSlugAlreadyExistsError } from '../domain/errors.js';
|
||||
import type { NewProduct, Product, ProductAttribute, ProductPatch, ProductState } from '../domain/product.js';
|
||||
import type {
|
||||
NewProduct,
|
||||
Product,
|
||||
ProductAttribute,
|
||||
ProductPatch,
|
||||
ProductState,
|
||||
} from '../domain/product.js';
|
||||
import type { ProductRepository } from '../domain/ports.js';
|
||||
|
||||
export interface ProductRow {
|
||||
@@ -239,12 +245,27 @@ export function toProduct(row: ProductRow): Product {
|
||||
channels: row.channels,
|
||||
featured: row.featured,
|
||||
attributes: Array.isArray(row.attributes)
|
||||
? (row.attributes as unknown[]).filter((a): a is ProductAttribute =>
|
||||
typeof a === 'string' && [
|
||||
'bio', 'comercio-justo', 'congelado', 'cruelty-free', 'de-temporada',
|
||||
'demeter', 'fruta-verdura', 'keto', 'kosher', 'low-carb', 'raw-food',
|
||||
'sin-azucar', 'sin-gluten', 'sin-lactosa', 'vegano', 'zero-waste',
|
||||
].includes(a),
|
||||
? (row.attributes as unknown[]).filter(
|
||||
(a): a is ProductAttribute =>
|
||||
typeof a === 'string' &&
|
||||
[
|
||||
'bio',
|
||||
'comercio-justo',
|
||||
'congelado',
|
||||
'cruelty-free',
|
||||
'de-temporada',
|
||||
'demeter',
|
||||
'fruta-verdura',
|
||||
'keto',
|
||||
'kosher',
|
||||
'low-carb',
|
||||
'raw-food',
|
||||
'sin-azucar',
|
||||
'sin-gluten',
|
||||
'sin-lactosa',
|
||||
'vegano',
|
||||
'zero-waste',
|
||||
].includes(a),
|
||||
)
|
||||
: [],
|
||||
seoTitle: row.seo_title,
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PgProductSearchRepository implements ProductSearchRepository {
|
||||
// Word-level fuzzy: split query into words, match each against name/description.
|
||||
const words = q.split(/\s+/).filter((w) => w.length >= 2);
|
||||
const wordConditions = words
|
||||
.map((w) => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
|
||||
.map(() => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
|
||||
.join(' AND ');
|
||||
const wordFallback = words.length > 0 ? `(${wordConditions})` : 'TRUE';
|
||||
searchWhere.push(
|
||||
|
||||
@@ -76,11 +76,16 @@ class FakeProductRepository implements ProductRepository {
|
||||
// noop for tests
|
||||
}
|
||||
|
||||
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
|
||||
async listAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
}): Promise<{ items: Product[]; total: number }> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const offset = options?.offset ?? 0;
|
||||
let items = [...this.products];
|
||||
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
if (options?.q)
|
||||
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
return { items: items.slice(offset, offset + limit), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,16 @@ class FakeProductRepository implements ProductRepository {
|
||||
// noop for tests
|
||||
}
|
||||
|
||||
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
|
||||
async listAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
}): Promise<{ items: Product[]; total: number }> {
|
||||
const limit = options?.limit ?? 20;
|
||||
const offset = options?.offset ?? 0;
|
||||
let items = [...this.products];
|
||||
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
if (options?.q)
|
||||
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
return { items: items.slice(offset, offset + limit), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateCategory,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryTreeNode } from '../domain/category.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategorySlugAlreadyExistsError,
|
||||
CategoryTreeCycleError,
|
||||
@@ -38,6 +41,7 @@ const newCategorySchema = z.object({
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
isParent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const categoryPatchSchema = newCategorySchema
|
||||
@@ -57,12 +61,23 @@ export async function registerCategoriesRoutes(
|
||||
const updateCategory = new UpdateCategory(repository);
|
||||
const deleteCategory = new DeleteCategory(repository);
|
||||
|
||||
app.get('/categories/tree', async (_request, reply) => {
|
||||
const treeSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Category tree (público)',
|
||||
description: 'Devuelve el árbol completo de categorías.',
|
||||
};
|
||||
app.get('/categories/tree', { schema: treeSchema }, async (_request, reply) => {
|
||||
const items = await listTree.execute();
|
||||
return reply.send({ items: items.map(serializeTreeNode) });
|
||||
});
|
||||
|
||||
app.get('/categoria/:slug', async (request, reply) => {
|
||||
const publicCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Get category by slug (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/categoria/:slug', { schema: publicCatSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const category = await getBySlug.execute(slug);
|
||||
if (!category) {
|
||||
@@ -71,7 +86,13 @@ export async function registerCategoriesRoutes(
|
||||
return reply.send(serializeCategory(category));
|
||||
});
|
||||
|
||||
app.post('/categories', async (request, reply) => {
|
||||
const createCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Create category (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/categories', { schema: createCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newCategorySchema, request.body);
|
||||
@@ -83,7 +104,18 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/categories/:id', async (request, reply) => {
|
||||
const updateCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Update category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/categories/:id', { schema: updateCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -99,7 +131,17 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/categories/:id', async (request, reply) => {
|
||||
const deleteCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Delete category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete('/categories/:id', { schema: deleteCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -124,6 +166,9 @@ function mapCategoryError(error: unknown): Error {
|
||||
if (error instanceof CategoryTreeCycleError) {
|
||||
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
|
||||
}
|
||||
if (error instanceof CategoryParentNotContainerError) {
|
||||
return new AppError(422, 'CATEGORY_PARENT_NOT_CONTAINER', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown category error');
|
||||
}
|
||||
|
||||
@@ -136,6 +181,7 @@ function serializeCategory(category: Category) {
|
||||
url: `/categoria/${category.slug}`,
|
||||
seoTitle: category.seoTitle,
|
||||
seoDescription: category.seoDescription,
|
||||
isParent: category.isParent,
|
||||
createdAt: category.createdAt.toISOString(),
|
||||
updatedAt: category.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
export class GetCategoryBySlug {
|
||||
@@ -22,11 +26,11 @@ export class CreateCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(input: NewCategory): Promise<Category> {
|
||||
await this.assertParentExists(input.parentId);
|
||||
await this.assertValidParent(input.parentId);
|
||||
return this.categories.create(input);
|
||||
}
|
||||
|
||||
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
|
||||
private async assertValidParent(parentId: string | null | undefined): Promise<void> {
|
||||
if (parentId === undefined || parentId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -34,6 +38,9 @@ export class CreateCategory {
|
||||
if (!parent) {
|
||||
throw new CategoryParentNotFoundError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +69,9 @@ export class UpdateCategory {
|
||||
if (parentIsDescendant) {
|
||||
throw new CategoryTreeCycleError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Category {
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
||||
isParent: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export interface NewCategory {
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
isParent?: boolean;
|
||||
}
|
||||
|
||||
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||
|
||||
@@ -18,3 +18,12 @@ export class CategoryTreeCycleError extends Error {
|
||||
this.name = 'CategoryTreeCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryParentNotContainerError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Category parent must be a parent (container) category; a child cannot contain categories',
|
||||
);
|
||||
this.name = 'CategoryParentNotContainerError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface CategoryRow {
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
is_parent: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
['isParent', 'is_parent'],
|
||||
];
|
||||
|
||||
export class PgCategoryRepository implements CategoryRepository {
|
||||
@@ -55,8 +57,8 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
async create(input: NewCategory): Promise<Category> {
|
||||
try {
|
||||
const result = await this.pool.query<CategoryRow>(
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.parentId ?? null,
|
||||
@@ -64,6 +66,7 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
input.slug,
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
input.isParent ?? false,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -146,6 +149,7 @@ function toCategory(row: CategoryRow): Category {
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
isParent: row.is_parent,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
UpdateCategory,
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
|
||||
@@ -13,6 +17,7 @@ function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slu
|
||||
parentId: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
isParent: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
@@ -42,6 +47,7 @@ class FakeCategoryRepository implements CategoryRepository {
|
||||
slug: input.slug,
|
||||
seoTitle: input.seoTitle ?? null,
|
||||
seoDescription: input.seoDescription ?? null,
|
||||
isParent: input.isParent ?? false,
|
||||
});
|
||||
this.categories.push(created);
|
||||
return created;
|
||||
@@ -103,6 +109,16 @@ describe('category use cases', () => {
|
||||
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
|
||||
});
|
||||
|
||||
it('rejects a leaf category as parent', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'leaf', name: 'Aceites', slug: 'aceites', isParent: false }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
new CreateCategory(repo).execute({ parentId: 'leaf', name: 'Oliva', slug: 'oliva' }),
|
||||
).rejects.toBeInstanceOf(CategoryParentNotContainerError);
|
||||
});
|
||||
|
||||
it('rejects self-parent and descendant-as-parent updates', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { CartService } from '../../cart/index.js';
|
||||
import { PgCartRepository } from '../../cart/infrastructure/pg-cart-repository.js';
|
||||
import { createCartService } from '../../cart/index.js';
|
||||
import { createInventoryService } from '../../inventory/index.js';
|
||||
import { OrderService } from '../../orders/index.js';
|
||||
import { PgOrderRepository } from '../../orders/infrastructure/pg-order-repository.js';
|
||||
import { NoOpOrderEventPublisher } from '../../orders/infrastructure/no-op-event-publisher.js';
|
||||
import { createOrderService } from '../../orders/index.js';
|
||||
import { createPricingService } from '../../pricing/index.js';
|
||||
import { createShippingService } from '../../shipping/index.js';
|
||||
import { CheckoutService } from '../application/checkout-service.js';
|
||||
@@ -42,8 +41,8 @@ export async function registerCheckoutRoutes(
|
||||
const pricing = createPricingService(deps.pool);
|
||||
const inventory = createInventoryService(deps.pool);
|
||||
const shipping = createShippingService(deps.pool);
|
||||
const orders = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
|
||||
const cart = new CartService(new PgCartRepository(deps.pool), pricing, inventory);
|
||||
const orders = createOrderService(deps.pool);
|
||||
const cart = createCartService(deps.pool, pricing, inventory);
|
||||
const metrics = new InMemoryCheckoutMetrics();
|
||||
const tracer = deps.tracer ?? createNoOpTelemetry().tracer;
|
||||
const orderLookup = new PgIdempotencyLookup(deps.pool);
|
||||
@@ -60,7 +59,33 @@ export async function registerCheckoutRoutes(
|
||||
tracer,
|
||||
});
|
||||
|
||||
app.post('/checkout', async (request, reply) => {
|
||||
const checkoutSchema: FastifySchema = {
|
||||
tags: ['Checkout'],
|
||||
summary: 'Checkout',
|
||||
description: 'Procesa el carrito del usuario y crea el pedido.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['address', 'idempotencyKey'],
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
required: ['country', 'postalCode'],
|
||||
properties: {
|
||||
country: { type: 'string' },
|
||||
postalCode: { type: 'string' },
|
||||
},
|
||||
},
|
||||
promoCode: { type: 'string', maxLength: 64, nullable: true },
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description: 'Idempotency key para evitar duplicados',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.post('/checkout', { schema: checkoutSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(checkoutBodySchema, request.body);
|
||||
try {
|
||||
|
||||
@@ -257,8 +257,12 @@ describe('CheckoutService', () => {
|
||||
throw new Error('should not transition on retry');
|
||||
},
|
||||
listOrders: async () => [],
|
||||
getOrderAdmin: async () => { throw new Error('not used'); },
|
||||
transitionAdmin: async () => { throw new Error('not used'); },
|
||||
getOrderAdmin: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
transitionAdmin: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
getOrder: async (id) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { CmsService } from '../application/cms-service.js';
|
||||
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
|
||||
@@ -46,14 +48,25 @@ const slugParamSchema = z.object({ slug: z.string().min(1).max(160) });
|
||||
export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDeps): Promise<void> {
|
||||
const service = new CmsService(new PgCmsRepository(deps.pool));
|
||||
|
||||
app.get('/cms/pages', async (request, reply) => {
|
||||
const listPagesSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'List CMS pages (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/cms/pages', { schema: listPagesSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const pages = await service.listAll();
|
||||
return reply.send({ items: pages.map(serialize) });
|
||||
});
|
||||
|
||||
app.post('/cms/pages', async (request, reply) => {
|
||||
const createPageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Create CMS page (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages', { schema: createPageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(createSchema, request.body);
|
||||
@@ -65,7 +78,18 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/cms/pages/:id', async (request, reply) => {
|
||||
const updatePageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Update CMS page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/cms/pages/:id', { schema: updatePageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -78,7 +102,17 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/publish', async (request, reply) => {
|
||||
const publishSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Publish page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages/:id/publish', { schema: publishSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -90,7 +124,17 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/unpublish', async (request, reply) => {
|
||||
const unpublishSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Unpublish page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages/:id/unpublish', { schema: unpublishSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -102,7 +146,13 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/cms/pages/:slug', async (request, reply) => {
|
||||
const getPageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Get published page (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/cms/pages/:slug', { schema: getPageSchema }, async (request, reply) => {
|
||||
const { slug } = slugParamSchema.parse(request.params);
|
||||
try {
|
||||
const page = await service.getPublicPageBySlug(slug);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user