Files
mercadodevida/project/specs-admin/000-foundation/SPEC.md
2026-08-17 22:23:10 +02:00

7.3 KiB

ADM-001 / ADM-002 / ADM-003 — Foundation Spec

Goal

Establecer el proyecto Next.js Admin, el API Client tipado, y el flujo de autenticación completo.

1. Proyecto apps/admin

Stack

  • Next.js 16 (App Router)
  • TypeScript (strict mode)
  • Tailwind CSS (extend del config existente en frontend/)
  • URL: http://localhost:3004 (evitar conflicto con frontend :3003 y backend :3000)

package.json

{
  "name": "@mercadodevida/admin",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --port 3004",
    "build": "next build",
    "start": "next start --port 3004",
    "lint": "next lint",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "typescript": "^5.6.0",
    "tailwindcss": "^4.0.0",
    "@tailwindcss/postcss": "^4.0.0",
    "eslint": "^9.0.0",
    "@eslint/eslintrc": "^3.0.0"
  }
}

Configuración compartida

  • tailwind.config.ts extiende los colores de marca del storefront
  • next.config.ts ignora TypeScript errors en build
  • .env.local: NEXT_PUBLIC_API_URL=http://127.0.0.1:3000

Estructura inicial

apps/admin/
├── app/
│   ├── (auth)/
│   │   └── login/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   └── page.tsx        # redirect to /admin/products
│   └── layout.tsx
├── lib/
│   ├── api-client.ts
│   └── permissions.ts
├── types/
│   └── index.ts
├── package.json
├── tsconfig.json
├── next.config.ts
└── tailwind.config.ts

2. API Client (lib/api-client.ts)

Interfaz

interface ApiClient {
  get<T>(path: string, params?: Record<string, string | number>): Promise<T>;
  post<T>(path: string, body?: unknown): Promise<T>;
  patch<T>(path: string, body?: unknown): Promise<T>;
  delete<T>(path: string): Promise<T>;
}

interface ApiError {
  statusCode: number;
  code: string;
  message: string;
}

Comportamiento

  1. Añade Cookie: session_token=<token> a cada request desde document.cookie
  2. POST/PATCH/DELETE van con credentials: include
  3. En error 401 → limpia cookie → window.location.href = '/login'
  4. En error 403 → lanza ForbiddenError
  5. En error 404 → lanza NotFoundError
  6. En error >= 500 → lanza ServerError
  7. Otros errores → mapea error.message del body

Feature adapters

// lib/api/products.ts
export const productsApi = {
  list: (params: ProductListParams) => client.get<Product[]>('/products/search', params),
  get: (id: string) => client.get<Product>(`/products/${id}`),
  create: (data: CreateProductInput) => client.post<Product>('/products', data),
  update: (id: string, data: UpdateProductInput) => client.patch<Product>(`/products/${id}`, data),
  updateVariants: (productId, variantId, data) =>
    client.patch(`/products/${productId}/variants/${variantId}`, data),
};

// lib/api/orders.ts
export const ordersApi = {
  list: (params?: OrderListParams) => client.get<Order[]>('/orders', params),
  get: (id: string) => client.get<Order>(`/orders/${id}/admin`),
  transition: (id: string, state: string) =>
    client.post<Order>(`/orders/${id}/transitions/admin`, { state }),
};

// lib/api/auth.ts
export const authApi = {
  login: (email, password) => client.post<{ user: User }>('/auth/login', { email, password }),
  logout: () => client.post('/auth/logout'),
  me: () => client.get<{ user: User } | { user: null }>('/auth/me'),
};

Tipos (types/index.ts)

// Productos
interface Product { id, name, slug, description, state, brandId, categoryIds, images, ... }
interface ProductVariant { id, productId, sku, ean, attributes }
interface VariantPrice { variantId, netUnitAmountCents, vatRate, currency }
interface StockAvailability { available, availableQuantity }

// Órdenes
interface Order {
  id, userId, state, currency,
  subtotalCents, discountCents, taxCents, totalCents,
  idempotencyKey, createdAt, updatedAt,
  items: OrderItem[]
}
interface OrderItem { id, productId, variantId, sku, ean, name, unitPriceCents, discountCents, taxCents, quantity }

// Usuarios/Clientes
interface User { id, email, role, createdAt }

// Errores
interface ApiError { statusCode, code, message }

3. Auth Flow ((auth)/login/page.tsx)

UX Login

  • Server Component con "use client" para el form
  • Campos: email + password
  • POST a /api/auth/login (Next.js API route proxy) → backend
  • Cookie de sesión viene del backend en Set-Cookie
  • Éxito: redirect a /admin/products
  • Error: mensaje de error inline

API Route proxy (app/api/auth/login/route.ts)

// Forward al backend, propagate Set-Cookie
export async function POST(req: Request) {
  const body = await req.json();
  const backendRes = await fetch('http://127.0.0.1:3000/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await backendRes.json();
  if (!backendRes.ok) return Response.json(data, { status: backendRes.status });
  const resp = Response.json(data);
  const setCookie = backendRes.headers.get('set-cookie');
  if (setCookie) resp.headers.set('Set-Cookie', setCookie);
  return resp;
}

Mismo patrón para /api/auth/logout y /api/auth/me.

Middleware de protección

// middleware.ts
export function middleware(req: NextRequest) {
  const session = req.cookies.get('session_token');
  if (!session && !req.nextUrl.pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
}

4. Permissions

// lib/permissions.ts
type Permission = 'products.read' | 'products.write' | 'orders.read' | 'orders.write' | ...;

export function can(role: Role, permission: Permission): boolean {
  if (role === 'admin') return true;
  // Future: granular permissions
  return false;
}

export const NAV_ITEMS = [
  { href: '/admin/products', label: 'Productos', icon: '📦', permission: 'products.read' },
  { href: '/admin/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
  // ...
];

5. Acceptance Criteria

ADM-001

  • apps/admin/ compila con npm run build
  • npm run dev levanta en puerto 3004
  • Tailwind usa los colores de marca de MercadoDeVida

ADM-002

  • ApiClient hace requests con cookie de sesión
  • Error 401 redirige a login
  • Todos los feature adapters tipados
  • can(role, permission) funciona

ADM-003

  • Login con credenciales válidas → /admin/products
  • Login con credenciales inválidas → mensaje de error
  • Recargar /admin/products con sesión válida → mantiene página
  • Recargar /admin/products sin sesión → /admin/login
  • Logout → /admin/login

6. Tests

Unit

  • api-client.test.ts: mock fetch, verificar request/response mapping
  • permissions.test.ts: can() para admin y customer

E2E (Playwright)

  • login-success.spec.ts: login → dashboard
  • login-failure.spec.ts: credenciales inválidas → error
  • session-persistence.spec.ts: recargar mantiene sesión
  • logout.spec.ts: logout → login