# Fase 1: Fundación - Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Establecer la base del proyecto Horux360 con monorepo, autenticación JWT, sistema multi-tenant, y sistema de 4 temas visuales. **Architecture:** Monorepo con Turborepo conteniendo frontend Next.js 14 y backend Express. PostgreSQL con Prisma como ORM. Multi-tenant implementado con schemas separados por empresa. Sistema de temas con 4 layouts diferentes. **Tech Stack:** Next.js 14, Express, TypeScript, Tailwind CSS, Prisma, PostgreSQL, JWT, Zustand, Radix UI --- ## Task 1: Inicializar Monorepo con Turborepo **Files:** - Create: `package.json` - Create: `turbo.json` - Create: `pnpm-workspace.yaml` - Create: `.nvmrc` **Step 1: Crear package.json raíz** ```json { "name": "horux360", "private": true, "scripts": { "dev": "turbo run dev", "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test", "db:generate": "turbo run db:generate", "db:push": "turbo run db:push", "db:migrate": "turbo run db:migrate", "db:seed": "turbo run db:seed" }, "devDependencies": { "turbo": "^2.3.0", "typescript": "^5.3.0" }, "packageManager": "pnpm@9.0.0", "engines": { "node": ">=20.0.0" } } ``` **Step 2: Crear turbo.json** ```json { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "dev": { "cache": false, "persistent": true }, "lint": {}, "test": {}, "db:generate": { "cache": false }, "db:push": { "cache": false }, "db:migrate": { "cache": false }, "db:seed": { "cache": false } } } ``` **Step 3: Crear pnpm-workspace.yaml** ```yaml packages: - "apps/*" - "packages/*" ``` **Step 4: Crear .nvmrc** ``` 20 ``` **Step 5: Instalar dependencias** Run: `pnpm install` Expected: Dependencies installed successfully **Step 6: Commit** ```bash git add package.json turbo.json pnpm-workspace.yaml .nvmrc pnpm-lock.yaml git commit -m "chore: initialize monorepo with Turborepo and pnpm" ``` --- ## Task 2: Crear Package Shared (Tipos compartidos) **Files:** - Create: `packages/shared/package.json` - Create: `packages/shared/tsconfig.json` - Create: `packages/shared/src/index.ts` - Create: `packages/shared/src/types/auth.ts` - Create: `packages/shared/src/types/tenant.ts` - Create: `packages/shared/src/types/user.ts` - Create: `packages/shared/src/constants/plans.ts` - Create: `packages/shared/src/constants/roles.ts` **Step 1: Crear estructura de carpetas** ```bash mkdir -p packages/shared/src/types packages/shared/src/constants ``` **Step 2: Crear packages/shared/package.json** ```json { "name": "@horux/shared", "version": "0.0.1", "private": true, "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "lint": "eslint src/", "typecheck": "tsc --noEmit" }, "devDependencies": { "typescript": "^5.3.0" } } ``` **Step 3: Crear packages/shared/tsconfig.json** ```json { "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` **Step 4: Crear packages/shared/src/types/auth.ts** ```typescript export interface LoginRequest { email: string; password: string; } export interface LoginResponse { accessToken: string; refreshToken: string; user: UserInfo; } export interface RegisterRequest { empresa: { nombre: string; rfc: string; }; usuario: { nombre: string; email: string; password: string; }; } export interface UserInfo { id: string; email: string; nombre: string; role: Role; tenantId: string; tenantName: string; } export interface JWTPayload { userId: string; email: string; role: Role; tenantId: string; schemaName: string; iat?: number; exp?: number; } export type Role = 'admin' | 'contador' | 'visor'; ``` **Step 5: Crear packages/shared/src/types/tenant.ts** ```typescript import type { Plan } from '../constants/plans'; export interface Tenant { id: string; nombre: string; rfc: string; plan: Plan; schemaName: string; cfdiLimit: number; usersLimit: number; active: boolean; createdAt: Date; expiresAt: Date | null; } export interface TenantUsage { cfdiCount: number; cfdiLimit: number; usersCount: number; usersLimit: number; plan: Plan; } ``` **Step 6: Crear packages/shared/src/types/user.ts** ```typescript import type { Role } from './auth'; export interface User { id: string; tenantId: string; email: string; nombre: string; role: Role; active: boolean; lastLogin: Date | null; createdAt: Date; } export interface CreateUserRequest { email: string; nombre: string; role: Role; password: string; } export interface UpdateUserRequest { nombre?: string; role?: Role; active?: boolean; } ``` **Step 7: Crear packages/shared/src/constants/plans.ts** ```typescript export const PLANS = { starter: { name: 'Starter', cfdiLimit: 100, usersLimit: 1, features: ['dashboard', 'cfdi_basic', 'iva_isr'], }, business: { name: 'Business', cfdiLimit: 500, usersLimit: 3, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario'], }, professional: { name: 'Professional', cfdiLimit: 2000, usersLimit: 10, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat'], }, enterprise: { name: 'Enterprise', cfdiLimit: -1, // unlimited usersLimit: -1, // unlimited features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'api', 'multi_empresa'], }, } as const; export type Plan = keyof typeof PLANS; export function getPlanLimits(plan: Plan) { return PLANS[plan]; } export function hasFeature(plan: Plan, feature: string): boolean { return PLANS[plan].features.includes(feature); } ``` **Step 8: Crear packages/shared/src/constants/roles.ts** ```typescript export const ROLES = { admin: { name: 'Administrador', permissions: ['read', 'write', 'delete', 'manage_users', 'manage_settings'], }, contador: { name: 'Contador', permissions: ['read', 'write'], }, visor: { name: 'Visor', permissions: ['read'], }, } as const; export type Role = keyof typeof ROLES; export function hasPermission(role: Role, permission: string): boolean { return ROLES[role].permissions.includes(permission as any); } ``` **Step 9: Crear packages/shared/src/index.ts** ```typescript // Types export * from './types/auth'; export * from './types/tenant'; export * from './types/user'; // Constants export * from './constants/plans'; export * from './constants/roles'; ``` **Step 10: Commit** ```bash git add packages/shared git commit -m "feat: add shared package with types and constants" ``` --- ## Task 3: Crear Backend Express Base **Files:** - Create: `apps/api/package.json` - Create: `apps/api/tsconfig.json` - Create: `apps/api/src/index.ts` - Create: `apps/api/src/app.ts` - Create: `apps/api/src/config/env.ts` - Create: `apps/api/.env.example` **Step 1: Crear estructura de carpetas** ```bash mkdir -p apps/api/src/config apps/api/src/routes apps/api/src/controllers apps/api/src/services apps/api/src/middlewares apps/api/src/utils ``` **Step 2: Crear apps/api/package.json** ```json { "name": "@horux/api", "version": "0.0.1", "private": true, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/", "typecheck": "tsc --noEmit", "db:generate": "prisma generate", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", "db:seed": "tsx prisma/seed.ts" }, "dependencies": { "@horux/shared": "workspace:*", "@prisma/client": "^5.22.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "express": "^4.21.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "zod": "^3.23.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.0.0", "prisma": "^5.22.0", "tsx": "^4.19.0", "typescript": "^5.3.0" } } ``` **Step 3: Crear apps/api/tsconfig.json** ```json { "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", "declaration": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` **Step 4: Crear apps/api/src/config/env.ts** ```typescript import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.string().default('4000'), DATABASE_URL: z.string(), JWT_SECRET: z.string().min(32), JWT_EXPIRES_IN: z.string().default('15m'), JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), CORS_ORIGIN: z.string().default('http://localhost:3000'), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors); process.exit(1); } export const env = parsed.data; ``` **Step 5: Crear apps/api/src/app.ts** ```typescript import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import { env } from './config/env.js'; import { errorMiddleware } from './middlewares/error.middleware.js'; const app = express(); // Security app.use(helmet()); app.use(cors({ origin: env.CORS_ORIGIN, credentials: true, })); // Body parsing app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // API Routes (to be added) // app.use('/api/auth', authRoutes); // app.use('/api/tenant', tenantRoutes); // Error handling app.use(errorMiddleware); export { app }; ``` **Step 6: Crear apps/api/src/index.ts** ```typescript import { app } from './app.js'; import { env } from './config/env.js'; const PORT = parseInt(env.PORT, 10); app.listen(PORT, () => { console.log(`🚀 API Server running on http://localhost:${PORT}`); console.log(`📊 Environment: ${env.NODE_ENV}`); }); ``` **Step 7: Crear apps/api/src/middlewares/error.middleware.ts** ```typescript import type { Request, Response, NextFunction } from 'express'; export class AppError extends Error { constructor( public statusCode: number, public message: string, public isOperational = true ) { super(message); Object.setPrototypeOf(this, AppError.prototype); } } export function errorMiddleware( err: Error, req: Request, res: Response, next: NextFunction ) { if (err instanceof AppError) { return res.status(err.statusCode).json({ status: 'error', message: err.message, }); } console.error('Unhandled error:', err); return res.status(500).json({ status: 'error', message: 'Internal server error', }); } ``` **Step 8: Crear apps/api/.env.example** ```env NODE_ENV=development PORT=4000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public" JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long JWT_EXPIRES_IN=15m JWT_REFRESH_EXPIRES_IN=7d CORS_ORIGIN=http://localhost:3000 ``` **Step 9: Commit** ```bash git add apps/api git commit -m "feat: add Express API base structure" ``` --- ## Task 4: Configurar Prisma con Schema Multi-tenant **Files:** - Create: `apps/api/prisma/schema.prisma` - Create: `apps/api/src/config/database.ts` - Create: `apps/api/src/utils/schema-manager.ts` **Step 1: Crear apps/api/prisma/schema.prisma** ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ============================================ // PUBLIC SCHEMA - Shared across all tenants // ============================================ model Tenant { id String @id @default(uuid()) nombre String rfc String @unique plan Plan @default(starter) schemaName String @unique @map("schema_name") cfdiLimit Int @map("cfdi_limit") usersLimit Int @map("users_limit") active Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime? @map("expires_at") users User[] @@map("tenants") } model User { id String @id @default(uuid()) tenantId String @map("tenant_id") email String @unique passwordHash String @map("password_hash") nombre String role Role @default(visor) active Boolean @default(true) lastLogin DateTime? @map("last_login") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) @@map("users") } model RefreshToken { id String @id @default(uuid()) userId String @map("user_id") token String @unique expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") @@map("refresh_tokens") } enum Plan { starter business professional enterprise } enum Role { admin contador visor } ``` **Step 2: Crear apps/api/src/config/database.ts** ```typescript import { PrismaClient } from '@prisma/client'; declare global { var prisma: PrismaClient | undefined; } export const prisma = globalThis.prisma || new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }); if (process.env.NODE_ENV !== 'production') { globalThis.prisma = prisma; } ``` **Step 3: Crear apps/api/src/utils/schema-manager.ts** ```typescript import { prisma } from '../config/database.js'; export async function createTenantSchema(schemaName: string): Promise { // Create the schema await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`); // Create tables in the tenant schema await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), uuid_fiscal VARCHAR(36) UNIQUE NOT NULL, tipo VARCHAR(20) NOT NULL, serie VARCHAR(25), folio VARCHAR(40), fecha_emision TIMESTAMP NOT NULL, fecha_timbrado TIMESTAMP NOT NULL, rfc_emisor VARCHAR(13) NOT NULL, nombre_emisor VARCHAR(300) NOT NULL, rfc_receptor VARCHAR(13) NOT NULL, nombre_receptor VARCHAR(300) NOT NULL, subtotal DECIMAL(18,2) NOT NULL, descuento DECIMAL(18,2) DEFAULT 0, iva DECIMAL(18,2) DEFAULT 0, isr_retenido DECIMAL(18,2) DEFAULT 0, iva_retenido DECIMAL(18,2) DEFAULT 0, total DECIMAL(18,2) NOT NULL, moneda VARCHAR(3) DEFAULT 'MXN', tipo_cambio DECIMAL(10,4) DEFAULT 1, metodo_pago VARCHAR(3), forma_pago VARCHAR(2), uso_cfdi VARCHAR(4), estado VARCHAR(20) DEFAULT 'vigente', xml_url TEXT, pdf_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" ( id SERIAL PRIMARY KEY, año INTEGER NOT NULL, mes INTEGER NOT NULL, iva_trasladado DECIMAL(18,2) NOT NULL, iva_acreditable DECIMAL(18,2) NOT NULL, iva_retenido DECIMAL(18,2) DEFAULT 0, resultado DECIMAL(18,2) NOT NULL, acumulado DECIMAL(18,2) NOT NULL, estado VARCHAR(20) DEFAULT 'pendiente', fecha_declaracion TIMESTAMP, UNIQUE(año, mes) ) `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."isr_mensual" ( id SERIAL PRIMARY KEY, año INTEGER NOT NULL, mes INTEGER NOT NULL, ingresos_acumulados DECIMAL(18,2) NOT NULL, deducciones DECIMAL(18,2) NOT NULL, base_gravable DECIMAL(18,2) NOT NULL, isr_causado DECIMAL(18,2) NOT NULL, isr_retenido DECIMAL(18,2) NOT NULL, isr_a_pagar DECIMAL(18,2) NOT NULL, estado VARCHAR(20) DEFAULT 'pendiente', fecha_declaracion TIMESTAMP, UNIQUE(año, mes) ) `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" ( id SERIAL PRIMARY KEY, tipo VARCHAR(50) NOT NULL, titulo VARCHAR(200) NOT NULL, mensaje TEXT NOT NULL, prioridad VARCHAR(20) DEFAULT 'media', fecha_vencimiento TIMESTAMP, leida BOOLEAN DEFAULT FALSE, resuelta BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" ( id SERIAL PRIMARY KEY, titulo VARCHAR(200) NOT NULL, descripcion TEXT, tipo VARCHAR(50) NOT NULL, fecha_limite TIMESTAMP NOT NULL, recurrencia VARCHAR(20) DEFAULT 'unica', completado BOOLEAN DEFAULT FALSE, notas TEXT ) `); } export async function setTenantSchema(schemaName: string): Promise { await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`); } export async function deleteTenantSchema(schemaName: string): Promise { await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); } ``` **Step 4: Commit** ```bash git add apps/api/prisma apps/api/src/config/database.ts apps/api/src/utils/schema-manager.ts git commit -m "feat: add Prisma schema with multi-tenant support" ``` --- ## Task 5: Implementar Autenticación JWT **Files:** - Create: `apps/api/src/utils/password.ts` - Create: `apps/api/src/utils/token.ts` - Create: `apps/api/src/services/auth.service.ts` - Create: `apps/api/src/controllers/auth.controller.ts` - Create: `apps/api/src/routes/auth.routes.ts` - Create: `apps/api/src/middlewares/auth.middleware.ts` **Step 1: Crear apps/api/src/utils/password.ts** ```typescript import bcrypt from 'bcryptjs'; const SALT_ROUNDS = 12; export async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } export async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } ``` **Step 2: Crear apps/api/src/utils/token.ts** ```typescript import jwt from 'jsonwebtoken'; import type { JWTPayload } from '@horux/shared'; import { env } from '../config/env.js'; export function generateAccessToken(payload: Omit): string { return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN, }); } export function generateRefreshToken(payload: Omit): string { return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRES_IN, }); } export function verifyToken(token: string): JWTPayload { return jwt.verify(token, env.JWT_SECRET) as JWTPayload; } export function decodeToken(token: string): JWTPayload | null { try { return jwt.decode(token) as JWTPayload; } catch { return null; } } ``` **Step 3: Crear apps/api/src/services/auth.service.ts** ```typescript import { prisma } from '../config/database.js'; import { hashPassword, verifyPassword } from '../utils/password.js'; import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js'; import { createTenantSchema } from '../utils/schema-manager.js'; import { AppError } from '../middlewares/error.middleware.js'; import { PLANS } from '@horux/shared'; import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared'; export async function register(data: RegisterRequest): Promise { // Check if email already exists const existingUser = await prisma.user.findUnique({ where: { email: data.usuario.email }, }); if (existingUser) { throw new AppError(400, 'El email ya está registrado'); } // Check if RFC already exists const existingTenant = await prisma.tenant.findUnique({ where: { rfc: data.empresa.rfc }, }); if (existingTenant) { throw new AppError(400, 'El RFC ya está registrado'); } // Generate schema name from RFC const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; // Create tenant const tenant = await prisma.tenant.create({ data: { nombre: data.empresa.nombre, rfc: data.empresa.rfc.toUpperCase(), plan: 'starter', schemaName, cfdiLimit: PLANS.starter.cfdiLimit, usersLimit: PLANS.starter.usersLimit, }, }); // Create tenant schema await createTenantSchema(schemaName); // Create admin user const passwordHash = await hashPassword(data.usuario.password); const user = await prisma.user.create({ data: { tenantId: tenant.id, email: data.usuario.email.toLowerCase(), passwordHash, nombre: data.usuario.nombre, role: 'admin', }, }); // Generate tokens const tokenPayload = { userId: user.id, email: user.email, role: user.role, tenantId: tenant.id, schemaName: tenant.schemaName, }; const accessToken = generateAccessToken(tokenPayload); const refreshToken = generateRefreshToken(tokenPayload); // Store refresh token await prisma.refreshToken.create({ data: { userId: user.id, token: refreshToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }, }); return { accessToken, refreshToken, user: { id: user.id, email: user.email, nombre: user.nombre, role: user.role, tenantId: tenant.id, tenantName: tenant.nombre, }, }; } export async function login(data: LoginRequest): Promise { const user = await prisma.user.findUnique({ where: { email: data.email.toLowerCase() }, include: { tenant: true }, }); if (!user) { throw new AppError(401, 'Credenciales inválidas'); } if (!user.active) { throw new AppError(401, 'Usuario desactivado'); } if (!user.tenant.active) { throw new AppError(401, 'Empresa desactivada'); } const isValidPassword = await verifyPassword(data.password, user.passwordHash); if (!isValidPassword) { throw new AppError(401, 'Credenciales inválidas'); } // Update last login await prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() }, }); // Generate tokens const tokenPayload = { userId: user.id, email: user.email, role: user.role, tenantId: user.tenantId, schemaName: user.tenant.schemaName, }; const accessToken = generateAccessToken(tokenPayload); const refreshToken = generateRefreshToken(tokenPayload); // Store refresh token await prisma.refreshToken.create({ data: { userId: user.id, token: refreshToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); return { accessToken, refreshToken, user: { id: user.id, email: user.email, nombre: user.nombre, role: user.role, tenantId: user.tenantId, tenantName: user.tenant.nombre, }, }; } export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> { const storedToken = await prisma.refreshToken.findUnique({ where: { token }, }); if (!storedToken) { throw new AppError(401, 'Token inválido'); } if (storedToken.expiresAt < new Date()) { await prisma.refreshToken.delete({ where: { id: storedToken.id } }); throw new AppError(401, 'Token expirado'); } // Verify and decode token const payload = verifyToken(token); const user = await prisma.user.findUnique({ where: { id: payload.userId }, include: { tenant: true }, }); if (!user || !user.active) { throw new AppError(401, 'Usuario no encontrado o desactivado'); } // Delete old token await prisma.refreshToken.delete({ where: { id: storedToken.id } }); // Generate new tokens const newTokenPayload = { userId: user.id, email: user.email, role: user.role, tenantId: user.tenantId, schemaName: user.tenant.schemaName, }; const accessToken = generateAccessToken(newTokenPayload); const refreshToken = generateRefreshToken(newTokenPayload); // Store new refresh token await prisma.refreshToken.create({ data: { userId: user.id, token: refreshToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); return { accessToken, refreshToken }; } export async function logout(token: string): Promise { await prisma.refreshToken.deleteMany({ where: { token }, }); } ``` **Step 4: Crear apps/api/src/controllers/auth.controller.ts** ```typescript import type { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import * as authService from '../services/auth.service.js'; import { AppError } from '../middlewares/error.middleware.js'; const registerSchema = z.object({ empresa: z.object({ nombre: z.string().min(2, 'Nombre de empresa requerido'), rfc: z.string().min(12).max(13, 'RFC inválido'), }), usuario: z.object({ nombre: z.string().min(2, 'Nombre requerido'), email: z.string().email('Email inválido'), password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), }), }); const loginSchema = z.object({ email: z.string().email('Email inválido'), password: z.string().min(1, 'Contraseña requerida'), }); export async function register(req: Request, res: Response, next: NextFunction) { try { const data = registerSchema.parse(req.body); const result = await authService.register(data); res.status(201).json(result); } catch (error) { if (error instanceof z.ZodError) { return next(new AppError(400, error.errors[0].message)); } next(error); } } export async function login(req: Request, res: Response, next: NextFunction) { try { const data = loginSchema.parse(req.body); const result = await authService.login(data); res.json(result); } catch (error) { if (error instanceof z.ZodError) { return next(new AppError(400, error.errors[0].message)); } next(error); } } export async function refresh(req: Request, res: Response, next: NextFunction) { try { const { refreshToken } = req.body; if (!refreshToken) { throw new AppError(400, 'Refresh token requerido'); } const tokens = await authService.refreshTokens(refreshToken); res.json(tokens); } catch (error) { next(error); } } export async function logout(req: Request, res: Response, next: NextFunction) { try { const { refreshToken } = req.body; if (refreshToken) { await authService.logout(refreshToken); } res.json({ message: 'Sesión cerrada exitosamente' }); } catch (error) { next(error); } } export async function me(req: Request, res: Response, next: NextFunction) { try { res.json({ user: req.user }); } catch (error) { next(error); } } ``` **Step 5: Crear apps/api/src/middlewares/auth.middleware.ts** ```typescript import type { Request, Response, NextFunction } from 'express'; import { verifyToken } from '../utils/token.js'; import { AppError } from './error.middleware.js'; import type { JWTPayload, Role } from '@horux/shared'; declare global { namespace Express { interface Request { user?: JWTPayload; } } } export function authenticate(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return next(new AppError(401, 'Token no proporcionado')); } const token = authHeader.split(' ')[1]; try { const payload = verifyToken(token); req.user = payload; next(); } catch (error) { next(new AppError(401, 'Token inválido o expirado')); } } export function authorize(...roles: Role[]) { return (req: Request, res: Response, next: NextFunction) => { if (!req.user) { return next(new AppError(401, 'No autenticado')); } if (roles.length > 0 && !roles.includes(req.user.role)) { return next(new AppError(403, 'No autorizado')); } next(); }; } ``` **Step 6: Crear apps/api/src/routes/auth.routes.ts** ```typescript import { Router } from 'express'; import * as authController from '../controllers/auth.controller.js'; import { authenticate } from '../middlewares/auth.middleware.js'; const router = Router(); router.post('/register', authController.register); router.post('/login', authController.login); router.post('/refresh', authController.refresh); router.post('/logout', authController.logout); router.get('/me', authenticate, authController.me); export { router as authRoutes }; ``` **Step 7: Actualizar apps/api/src/app.ts para incluir rutas** ```typescript import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import { env } from './config/env.js'; import { errorMiddleware } from './middlewares/error.middleware.js'; import { authRoutes } from './routes/auth.routes.js'; const app = express(); // Security app.use(helmet()); app.use(cors({ origin: env.CORS_ORIGIN, credentials: true, })); // Body parsing app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // API Routes app.use('/api/auth', authRoutes); // Error handling app.use(errorMiddleware); export { app }; ``` **Step 8: Commit** ```bash git add apps/api/src git commit -m "feat: implement JWT authentication system" ``` --- ## Task 6: Crear Frontend Next.js Base **Files:** - Create: `apps/web/package.json` - Create: `apps/web/tsconfig.json` - Create: `apps/web/tailwind.config.ts` - Create: `apps/web/postcss.config.js` - Create: `apps/web/next.config.js` - Create: `apps/web/app/layout.tsx` - Create: `apps/web/app/globals.css` - Create: `apps/web/app/page.tsx` **Step 1: Crear estructura de carpetas** ```bash mkdir -p apps/web/app apps/web/components/ui apps/web/lib apps/web/stores apps/web/themes ``` **Step 2: Crear apps/web/package.json** ```json { "name": "@horux/web", "version": "0.0.1", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@horux/shared": "workspace:*", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.0", "@radix-ui/react-tooltip": "^1.1.0", "@tanstack/react-query": "^5.60.0", "axios": "^1.7.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.460.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.53.0", "tailwind-merge": "^2.5.0", "zod": "^3.23.0", "zustand": "^5.0.0" }, "devDependencies": { "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.3.0" } } ``` **Step 3: Crear apps/web/tsconfig.json** ```json { "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "ES2022"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ``` **Step 4: Crear apps/web/tailwind.config.ts** ```typescript import type { Config } from 'tailwindcss'; const config: Config = { darkMode: ['class'], content: [ './app/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))', }, secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))', }, destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))', }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))', }, accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))', }, card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))', }, success: { DEFAULT: 'hsl(var(--success))', foreground: 'hsl(var(--success-foreground))', }, }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, }, }, plugins: [], }; export default config; ``` **Step 5: Crear apps/web/postcss.config.js** ```javascript module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ``` **Step 6: Crear apps/web/next.config.js** ```javascript /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ['@horux/shared'], experimental: { typedRoutes: true, }, }; module.exports = nextConfig; ``` **Step 7: Crear apps/web/app/globals.css** ```css @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --primary: 221.2 83.2% 53.3%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --success: 142.1 76.2% 36.3%; --success-foreground: 355.7 100% 97.3%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; --radius: 0.5rem; } .dark { --background: 0 0% 3.9%; --foreground: 0 0% 98%; --card: 0 0% 3.9%; --card-foreground: 0 0% 98%; --primary: 187.2 85.7% 53.3%; --primary-foreground: 222.2 84% 4.9%; --secondary: 0 0% 14.9%; --secondary-foreground: 0 0% 98%; --muted: 0 0% 14.9%; --muted-foreground: 0 0% 63.9%; --accent: 0 0% 14.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; --success: 142.1 70.6% 45.3%; --success-foreground: 144.9 80.4% 10%; --border: 0 0% 14.9%; --input: 0 0% 14.9%; --ring: 187.2 85.7% 53.3%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` **Step 8: Crear apps/web/app/layout.tsx** ```tsx import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'Horux360 - Análisis Financiero', description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ``` **Step 9: Crear apps/web/app/page.tsx** ```tsx import Link from 'next/link'; export default function Home() { return (

Controla tus finanzas con{' '} inteligencia

La plataforma de análisis financiero para empresas mexicanas más moderna. Dashboard en tiempo real, control de IVA/ISR, y mucho más.

Prueba gratis 14 días
{[ { title: 'Dashboard', desc: 'KPIs en tiempo real' }, { title: 'Control IVA/ISR', desc: 'Cálculo automático' }, { title: 'Conciliación', desc: 'Bancaria inteligente' }, { title: 'Alertas', desc: 'Fiscales proactivas' }, ].map((feature) => (

{feature.title}

{feature.desc}

))}
); } ``` **Step 10: Commit** ```bash git add apps/web git commit -m "feat: add Next.js frontend base structure" ``` --- ## Task 7: Implementar Sistema de Temas **Files:** - Create: `apps/web/themes/index.ts` - Create: `apps/web/themes/light.ts` - Create: `apps/web/themes/vibrant.ts` - Create: `apps/web/themes/corporate.ts` - Create: `apps/web/themes/dark.ts` - Create: `apps/web/stores/theme-store.ts` - Create: `apps/web/components/providers/theme-provider.tsx` - Create: `apps/web/lib/utils.ts` **Step 1: Crear apps/web/lib/utils.ts** ```typescript import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ``` **Step 2: Crear apps/web/themes/light.ts** ```typescript export const lightTheme = { name: 'light' as const, label: 'Light', layout: 'sidebar-fixed', cssVars: { '--background': '0 0% 100%', '--foreground': '222.2 84% 4.9%', '--card': '0 0% 100%', '--card-foreground': '222.2 84% 4.9%', '--primary': '221.2 83.2% 53.3%', '--primary-foreground': '210 40% 98%', '--secondary': '210 40% 96.1%', '--secondary-foreground': '222.2 47.4% 11.2%', '--muted': '210 40% 96.1%', '--muted-foreground': '215.4 16.3% 46.9%', '--accent': '210 40% 96.1%', '--accent-foreground': '222.2 47.4% 11.2%', '--destructive': '0 84.2% 60.2%', '--destructive-foreground': '210 40% 98%', '--success': '142.1 76.2% 36.3%', '--success-foreground': '355.7 100% 97.3%', '--border': '214.3 31.8% 91.4%', '--input': '214.3 31.8% 91.4%', '--ring': '221.2 83.2% 53.3%', '--radius': '0.5rem', }, sidebar: { width: '240px', collapsible: false, }, }; ``` **Step 3: Crear apps/web/themes/vibrant.ts** ```typescript export const vibrantTheme = { name: 'vibrant' as const, label: 'Vibrant', layout: 'sidebar-collapsible', cssVars: { '--background': '270 50% 98%', '--foreground': '263.4 84% 6.7%', '--card': '0 0% 100%', '--card-foreground': '263.4 84% 6.7%', '--primary': '262.1 83.3% 57.8%', '--primary-foreground': '210 40% 98%', '--secondary': '187 85.7% 53.3%', '--secondary-foreground': '222.2 47.4% 11.2%', '--muted': '270 30% 94%', '--muted-foreground': '263.4 25% 40%', '--accent': '24.6 95% 53.1%', '--accent-foreground': '0 0% 100%', '--destructive': '0 84.2% 60.2%', '--destructive-foreground': '210 40% 98%', '--success': '142.1 76.2% 36.3%', '--success-foreground': '355.7 100% 97.3%', '--border': '270 30% 88%', '--input': '270 30% 88%', '--ring': '262.1 83.3% 57.8%', '--radius': '1rem', }, sidebar: { width: '280px', collapsible: true, }, }; ``` **Step 4: Crear apps/web/themes/corporate.ts** ```typescript export const corporateTheme = { name: 'corporate' as const, label: 'Corporate', layout: 'multi-panel', cssVars: { '--background': '210 20% 96%', '--foreground': '210 50% 10%', '--card': '0 0% 100%', '--card-foreground': '210 50% 10%', '--primary': '210 100% 25%', '--primary-foreground': '0 0% 100%', '--secondary': '210 20% 92%', '--secondary-foreground': '210 50% 10%', '--muted': '210 20% 92%', '--muted-foreground': '210 15% 45%', '--accent': '43 96% 56%', '--accent-foreground': '210 50% 10%', '--destructive': '0 84.2% 60.2%', '--destructive-foreground': '210 40% 98%', '--success': '142.1 76.2% 36.3%', '--success-foreground': '355.7 100% 97.3%', '--border': '210 20% 85%', '--input': '210 20% 85%', '--ring': '210 100% 25%', '--radius': '0.25rem', }, sidebar: { width: '200px', collapsible: false, }, density: 'compact', }; ``` **Step 5: Crear apps/web/themes/dark.ts** ```typescript export const darkTheme = { name: 'dark' as const, label: 'Dark', layout: 'minimal-floating', cssVars: { '--background': '0 0% 3.9%', '--foreground': '0 0% 98%', '--card': '0 0% 6%', '--card-foreground': '0 0% 98%', '--primary': '187.2 85.7% 53.3%', '--primary-foreground': '0 0% 3.9%', '--secondary': '0 0% 12%', '--secondary-foreground': '0 0% 98%', '--muted': '0 0% 12%', '--muted-foreground': '0 0% 63.9%', '--accent': '142.1 70.6% 45.3%', '--accent-foreground': '0 0% 3.9%', '--destructive': '0 62.8% 30.6%', '--destructive-foreground': '0 0% 98%', '--success': '142.1 70.6% 45.3%', '--success-foreground': '144.9 80.4% 10%', '--border': '0 0% 14.9%', '--input': '0 0% 14.9%', '--ring': '187.2 85.7% 53.3%', '--radius': '0.75rem', }, sidebar: { width: '64px', collapsible: false, iconsOnly: true, }, effects: { blur: '10px', glow: '0 0 20px rgba(34,211,238,0.3)', }, }; ``` **Step 6: Crear apps/web/themes/index.ts** ```typescript import { lightTheme } from './light'; import { vibrantTheme } from './vibrant'; import { corporateTheme } from './corporate'; import { darkTheme } from './dark'; export const themes = { light: lightTheme, vibrant: vibrantTheme, corporate: corporateTheme, dark: darkTheme, } as const; export type ThemeName = keyof typeof themes; export type Theme = (typeof themes)[ThemeName]; export { lightTheme, vibrantTheme, corporateTheme, darkTheme }; ``` **Step 7: Crear apps/web/stores/theme-store.ts** ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { ThemeName } from '@/themes'; interface ThemeState { theme: ThemeName; setTheme: (theme: ThemeName) => void; } export const useThemeStore = create()( persist( (set) => ({ theme: 'light', setTheme: (theme) => set({ theme }), }), { name: 'horux-theme', } ) ); ``` **Step 8: Crear carpeta providers** ```bash mkdir -p apps/web/components/providers ``` **Step 9: Crear apps/web/components/providers/theme-provider.tsx** ```tsx 'use client'; import { useEffect } from 'react'; import { useThemeStore } from '@/stores/theme-store'; import { themes } from '@/themes'; export function ThemeProvider({ children }: { children: React.ReactNode }) { const { theme } = useThemeStore(); useEffect(() => { const selectedTheme = themes[theme]; const root = document.documentElement; // Apply CSS variables Object.entries(selectedTheme.cssVars).forEach(([key, value]) => { root.style.setProperty(key, value); }); // Apply dark class for dark theme if (theme === 'dark') { root.classList.add('dark'); } else { root.classList.remove('dark'); } }, [theme]); return <>{children}; } ``` **Step 10: Actualizar apps/web/app/layout.tsx** ```tsx import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { ThemeProvider } from '@/components/providers/theme-provider'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'Horux360 - Análisis Financiero', description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ``` **Step 11: Commit** ```bash git add apps/web/themes apps/web/stores apps/web/components/providers apps/web/lib apps/web/app/layout.tsx git commit -m "feat: implement 4-theme system with Zustand persistence" ``` --- ## Task 8: Crear Componentes UI Base **Files:** - Create: `apps/web/components/ui/button.tsx` - Create: `apps/web/components/ui/input.tsx` - Create: `apps/web/components/ui/card.tsx` - Create: `apps/web/components/ui/label.tsx` **Step 1: Crear apps/web/components/ui/button.tsx** ```tsx import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', success: 'bg-success text-success-foreground hover:bg-success/90', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( ); } ); Button.displayName = 'Button'; export { Button, buttonVariants }; ``` **Step 2: Crear apps/web/components/ui/input.tsx** ```tsx import * as React from 'react'; import { cn } from '@/lib/utils'; export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( ({ className, type, ...props }, ref) => { return ( ); } ); Input.displayName = 'Input'; export { Input }; ``` **Step 3: Crear apps/web/components/ui/card.tsx** ```tsx import * as React from 'react'; import { cn } from '@/lib/utils'; const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); Card.displayName = 'Card'; const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardHeader.displayName = 'CardHeader'; const CardTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardTitle.displayName = 'CardTitle'; const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardDescription.displayName = 'CardDescription'; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)); CardContent.displayName = 'CardContent'; const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardFooter.displayName = 'CardFooter'; export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; ``` **Step 4: Crear apps/web/components/ui/label.tsx** ```tsx 'use client'; import * as React from 'react'; import * as LabelPrimitive from '@radix-ui/react-label'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const labelVariants = cva( 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' ); const Label = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, ...props }, ref) => ( )); Label.displayName = LabelPrimitive.Root.displayName; export { Label }; ``` **Step 5: Commit** ```bash git add apps/web/components/ui git commit -m "feat: add base UI components (Button, Input, Card, Label)" ``` --- ## Task 9: Crear Páginas de Login y Registro **Files:** - Create: `apps/web/app/(auth)/layout.tsx` - Create: `apps/web/app/(auth)/login/page.tsx` - Create: `apps/web/app/(auth)/register/page.tsx` - Create: `apps/web/lib/api/client.ts` - Create: `apps/web/lib/api/auth.ts` - Create: `apps/web/stores/auth-store.ts` **Step 1: Crear estructura de carpetas** ```bash mkdir -p "apps/web/app/(auth)/login" "apps/web/app/(auth)/register" apps/web/lib/api ``` **Step 2: Crear apps/web/lib/api/client.ts** ```typescript import axios from 'axios'; export const apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', headers: { 'Content-Type': 'application/json', }, }); apiClient.interceptors.request.use((config) => { if (typeof window !== 'undefined') { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } } return config; }); apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const refreshToken = localStorage.getItem('refreshToken'); if (refreshToken) { const response = await axios.post( `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`, { refreshToken } ); const { accessToken, refreshToken: newRefreshToken } = response.data; localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', newRefreshToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; return apiClient(originalRequest); } } catch { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); window.location.href = '/login'; } } return Promise.reject(error); } ); ``` **Step 3: Crear apps/web/lib/api/auth.ts** ```typescript import { apiClient } from './client'; import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared'; export async function login(data: LoginRequest): Promise { const response = await apiClient.post('/auth/login', data); return response.data; } export async function register(data: RegisterRequest): Promise { const response = await apiClient.post('/auth/register', data); return response.data; } export async function logout(): Promise { const refreshToken = localStorage.getItem('refreshToken'); await apiClient.post('/auth/logout', { refreshToken }); } export async function getMe(): Promise { const response = await apiClient.get('/auth/me'); return response.data.user; } ``` **Step 4: Crear apps/web/stores/auth-store.ts** ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { UserInfo } from '@horux/shared'; interface AuthState { user: UserInfo | null; isAuthenticated: boolean; setUser: (user: UserInfo | null) => void; setTokens: (accessToken: string, refreshToken: string) => void; logout: () => void; } export const useAuthStore = create()( persist( (set) => ({ user: null, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: !!user }), setTokens: (accessToken, refreshToken) => { localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', refreshToken); }, logout: () => { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null, isAuthenticated: false }); }, }), { name: 'horux-auth', partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }), } ) ); ``` **Step 5: Crear apps/web/app/(auth)/layout.tsx** ```tsx export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` **Step 6: Crear apps/web/app/(auth)/login/page.tsx** ```tsx 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { login } from '@/lib/api/auth'; import { useAuthStore } from '@/stores/auth-store'; export default function LoginPage() { const router = useRouter(); const { setUser, setTokens } = useAuthStore(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setIsLoading(true); setError(''); const formData = new FormData(e.currentTarget); const email = formData.get('email') as string; const password = formData.get('password') as string; try { const response = await login({ email, password }); setTokens(response.accessToken, response.refreshToken); setUser(response.user); router.push('/dashboard'); } catch (err: any) { setError(err.response?.data?.message || 'Error al iniciar sesión'); } finally { setIsLoading(false); } } return ( Iniciar Sesión Ingresa tus credenciales para acceder a tu cuenta
{error && (
{error}
)}

¿No tienes cuenta?{' '} Regístrate

); } ``` **Step 7: Crear apps/web/app/(auth)/register/page.tsx** ```tsx 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { register } from '@/lib/api/auth'; import { useAuthStore } from '@/stores/auth-store'; export default function RegisterPage() { const router = useRouter(); const { setUser, setTokens } = useAuthStore(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setIsLoading(true); setError(''); const formData = new FormData(e.currentTarget); try { const response = await register({ empresa: { nombre: formData.get('empresaNombre') as string, rfc: formData.get('empresaRfc') as string, }, usuario: { nombre: formData.get('nombre') as string, email: formData.get('email') as string, password: formData.get('password') as string, }, }); setTokens(response.accessToken, response.refreshToken); setUser(response.user); router.push('/dashboard'); } catch (err: any) { setError(err.response?.data?.message || 'Error al registrarse'); } finally { setIsLoading(false); } } return ( Crear Cuenta Registra tu empresa y comienza tu prueba gratuita
{error && (
{error}
)}

¿Ya tienes cuenta?{' '} Inicia sesión

); } ``` **Step 8: Crear apps/web/.env.example** ```env NEXT_PUBLIC_API_URL=http://localhost:4000/api ``` **Step 9: Commit** ```bash git add apps/web/app apps/web/lib apps/web/stores apps/web/.env.example git commit -m "feat: add login and register pages with auth store" ``` --- ## Task 10: Crear Seed de Datos Demo y Docker Compose **Files:** - Create: `apps/api/prisma/seed.ts` - Create: `docker-compose.yml` - Update: `apps/api/.env.example` **Step 1: Crear apps/api/prisma/seed.ts** ```typescript import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); async function main() { console.log('🌱 Seeding database...'); // Create demo tenant const schemaName = 'tenant_ede123456ab1'; const tenant = await prisma.tenant.upsert({ where: { rfc: 'EDE123456AB1' }, update: {}, create: { nombre: 'Empresa Demo SA de CV', rfc: 'EDE123456AB1', plan: 'professional', schemaName, cfdiLimit: 2000, usersLimit: 10, }, }); console.log('✅ Tenant created:', tenant.nombre); // Create demo users const passwordHash = await bcrypt.hash('demo123', 12); const users = [ { email: 'admin@demo.com', nombre: 'Admin Demo', role: 'admin' as const }, { email: 'contador@demo.com', nombre: 'Contador Demo', role: 'contador' as const }, { email: 'visor@demo.com', nombre: 'Visor Demo', role: 'visor' as const }, ]; for (const userData of users) { const user = await prisma.user.upsert({ where: { email: userData.email }, update: {}, create: { tenantId: tenant.id, email: userData.email, passwordHash, nombre: userData.nombre, role: userData.role, }, }); console.log(`✅ User created: ${user.email} (${user.role})`); } // Create tenant schema await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`); // Create tables in tenant schema await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), uuid_fiscal VARCHAR(36) UNIQUE NOT NULL, tipo VARCHAR(20) NOT NULL, serie VARCHAR(25), folio VARCHAR(40), fecha_emision TIMESTAMP NOT NULL, fecha_timbrado TIMESTAMP NOT NULL, rfc_emisor VARCHAR(13) NOT NULL, nombre_emisor VARCHAR(300) NOT NULL, rfc_receptor VARCHAR(13) NOT NULL, nombre_receptor VARCHAR(300) NOT NULL, subtotal DECIMAL(18,2) NOT NULL, descuento DECIMAL(18,2) DEFAULT 0, iva DECIMAL(18,2) DEFAULT 0, isr_retenido DECIMAL(18,2) DEFAULT 0, iva_retenido DECIMAL(18,2) DEFAULT 0, total DECIMAL(18,2) NOT NULL, moneda VARCHAR(3) DEFAULT 'MXN', tipo_cambio DECIMAL(10,4) DEFAULT 1, metodo_pago VARCHAR(3), forma_pago VARCHAR(2), uso_cfdi VARCHAR(4), estado VARCHAR(20) DEFAULT 'vigente', xml_url TEXT, pdf_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Insert demo CFDIs const cfdiTypes = ['ingreso', 'egreso']; const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000']; const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123']; for (let i = 0; i < 50; i++) { const tipo = cfdiTypes[i % 2]; const rfcIndex = i % 4; const subtotal = Math.floor(Math.random() * 50000) + 1000; const iva = subtotal * 0.16; const total = subtotal + iva; await prisma.$executeRawUnsafe(` INSERT INTO "${schemaName}"."cfdis" (uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, subtotal, iva, total, estado) VALUES ( '${crypto.randomUUID()}', '${tipo}', 'A', '${1000 + i}', NOW() - INTERVAL '${Math.floor(Math.random() * 180)} days', NOW() - INTERVAL '${Math.floor(Math.random() * 180)} days', '${tipo === 'ingreso' ? 'EDE123456AB1' : rfcs[rfcIndex]}', '${tipo === 'ingreso' ? 'Empresa Demo SA de CV' : nombres[rfcIndex]}', '${tipo === 'egreso' ? 'EDE123456AB1' : rfcs[rfcIndex]}', '${tipo === 'egreso' ? 'Empresa Demo SA de CV' : nombres[rfcIndex]}', ${subtotal}, ${iva}, ${total}, 'vigente' ) ON CONFLICT (uuid_fiscal) DO NOTHING `); } console.log('✅ Demo CFDIs created'); // Create IVA monthly records const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio']; let acumulado = 0; for (let mes = 1; mes <= 6; mes++) { const trasladado = Math.floor(Math.random() * 100000) + 150000; const acreditable = Math.floor(Math.random() * 80000) + 120000; const resultado = trasladado - acreditable; acumulado += resultado; await prisma.$executeRawUnsafe(` INSERT INTO "${schemaName}"."iva_mensual" (año, mes, iva_trasladado, iva_acreditable, resultado, acumulado, estado) VALUES (2024, ${mes}, ${trasladado}, ${acreditable}, ${resultado}, ${acumulado}, '${mes <= 4 ? 'declarado' : 'pendiente'}') ON CONFLICT (año, mes) DO NOTHING `); } console.log('✅ IVA monthly records created'); // Create alerts await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" ( id SERIAL PRIMARY KEY, tipo VARCHAR(50) NOT NULL, titulo VARCHAR(200) NOT NULL, mensaje TEXT NOT NULL, prioridad VARCHAR(20) DEFAULT 'media', fecha_vencimiento TIMESTAMP, leida BOOLEAN DEFAULT FALSE, resuelta BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); await prisma.$executeRawUnsafe(` INSERT INTO "${schemaName}"."alertas" (tipo, titulo, mensaje, prioridad, fecha_vencimiento) VALUES ('iva_favor', 'IVA a Favor Disponible', 'Tienes $43,582.40 de IVA a favor acumulado', 'media', NULL), ('declaracion', 'Declaración Mensual', 'La declaración mensual de IVA/ISR vence el 17 de febrero', 'alta', NOW() + INTERVAL '5 days'), ('discrepancia', 'CFDI con Discrepancia', 'Se encontraron 12 facturas con discrepancias', 'alta', NULL) ON CONFLICT DO NOTHING `); console.log('✅ Alerts created'); console.log('🎉 Seed completed successfully!'); console.log('\n📝 Demo credentials:'); console.log(' Admin: admin@demo.com / demo123'); console.log(' Contador: contador@demo.com / demo123'); console.log(' Visor: visor@demo.com / demo123'); } main() .catch((e) => { console.error('Error seeding database:', e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); }); ``` **Step 2: Crear docker-compose.yml** ```yaml version: '3.8' services: postgres: image: postgres:16-alpine container_name: horux360-db environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: horux360 ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 api: build: context: . dockerfile: apps/api/Dockerfile container_name: horux360-api environment: NODE_ENV: development PORT: 4000 DATABASE_URL: postgresql://postgres:postgres@postgres:5432/horux360?schema=public JWT_SECRET: your-super-secret-jwt-key-min-32-chars-long-for-development JWT_EXPIRES_IN: 15m JWT_REFRESH_EXPIRES_IN: 7d CORS_ORIGIN: http://localhost:3000 ports: - "4000:4000" depends_on: postgres: condition: service_healthy web: build: context: . dockerfile: apps/web/Dockerfile container_name: horux360-web environment: NEXT_PUBLIC_API_URL: http://localhost:4000/api ports: - "3000:3000" depends_on: - api volumes: postgres_data: ``` **Step 3: Actualizar apps/api/.env.example** ```env NODE_ENV=development PORT=4000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public" JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long-for-development JWT_EXPIRES_IN=15m JWT_REFRESH_EXPIRES_IN=7d CORS_ORIGIN=http://localhost:3000 ``` **Step 4: Commit** ```bash git add apps/api/prisma/seed.ts docker-compose.yml apps/api/.env.example git commit -m "feat: add database seed with demo data and Docker Compose setup" ``` --- ## Task 11: Final - Instalar Dependencias y Verificar **Step 1: Instalar todas las dependencias** Run: `pnpm install` Expected: All dependencies installed **Step 2: Generar cliente Prisma** Run: `cd apps/api && pnpm db:generate` Expected: Prisma client generated **Step 3: Iniciar base de datos con Docker** Run: `docker-compose up -d postgres` Expected: PostgreSQL running on port 5432 **Step 4: Ejecutar migraciones** Run: `cd apps/api && pnpm db:push` Expected: Database schema created **Step 5: Ejecutar seed** Run: `cd apps/api && pnpm db:seed` Expected: Demo data inserted **Step 6: Iniciar desarrollo** Run: `pnpm dev` Expected: API on http://localhost:4000, Web on http://localhost:3000 **Step 7: Commit final** ```bash git add -A git commit -m "chore: complete Phase 1 - foundation setup" ``` **Step 8: Push al repositorio** ```bash git push origin main ``` --- ## Summary Phase 1 establishes: - Monorepo with Turborepo + pnpm - Shared package with types and constants - Express API with JWT auth - Prisma with PostgreSQL multi-tenant - Next.js 14 frontend - 4-theme system (Light, Vibrant, Corporate, Dark) - Login/Register pages - Demo data seed - Docker Compose for development **Next Phase (2):** Dashboard with KPIs, CFDI management, IVA/ISR control.