feat(ADM-018): completed feature
This commit is contained in:
259
project/specs-admin/000-foundation/SPEC.md
Normal file
259
project/specs-admin/000-foundation/SPEC.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# 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<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
|
||||
|
||||
```typescript
|
||||
// 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`)
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user