# 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 ```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 ```typescript interface ApiClient { get(path: string, params?: Record): Promise; post(path: string, body?: unknown): Promise; patch(path: string, body?: unknown): Promise; delete(path: string): Promise; } interface ApiError { statusCode: number; code: string; message: string; } ``` ### Comportamiento 1. Añade `Cookie: session_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 ```typescript // lib/api/products.ts export const productsApi = { list: (params: ProductListParams) => client.get('/products/search', params), get: (id: string) => client.get(`/products/${id}`), create: (data: CreateProductInput) => client.post('/products', data), update: (id: string, data: UpdateProductInput) => client.patch(`/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('/orders', params), get: (id: string) => client.get(`/orders/${id}/admin`), transition: (id: string, state: string) => client.post(`/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`) ```typescript // 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`) ```typescript // 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 ```typescript // 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 ```typescript // 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