diff --git a/docs/plans/2026-01-22-fase1-implementation.md b/docs/plans/2026-01-22-fase1-implementation.md new file mode 100644 index 0000000..f90f763 --- /dev/null +++ b/docs/plans/2026-01-22-fase1-implementation.md @@ -0,0 +1,2862 @@ +# 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.