Files
Horux360/docs/plans/2026-01-22-fase1-implementation.md
Consultoria AS ed16c8cd39 docs: add Phase 1 implementation plan
Detailed step-by-step plan covering:
- Monorepo setup with Turborepo + pnpm
- Shared package with types and constants
- Express API with JWT authentication
- Prisma with PostgreSQL multi-tenant (schema per tenant)
- Next.js 14 frontend
- 4-theme system (Light, Vibrant, Corporate, Dark)
- Login/Register pages with auth store
- Demo data seed
- Docker Compose configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 01:41:07 +00:00

72 KiB

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

{
  "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

{
  "$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

packages:
  - "apps/*"
  - "packages/*"

Step 4: Crear .nvmrc

20

Step 5: Instalar dependencias

Run: pnpm install Expected: Dependencies installed successfully

Step 6: Commit

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

mkdir -p packages/shared/src/types packages/shared/src/constants

Step 2: Crear packages/shared/package.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

{
  "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

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

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

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

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

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

// 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

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

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

{
  "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

{
  "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

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

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

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

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

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

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

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

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

import { prisma } from '../config/database.js';

export async function createTenantSchema(schemaName: string): Promise<void> {
  // 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<void> {
  await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`);
}

export async function deleteTenantSchema(schemaName: string): Promise<void> {
  await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
}

Step 4: Commit

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

import bcrypt from 'bcryptjs';

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Step 2: Crear apps/api/src/utils/token.ts

import jwt from 'jsonwebtoken';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';

export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
  return jwt.sign(payload, env.JWT_SECRET, {
    expiresIn: env.JWT_EXPIRES_IN,
  });
}

export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): 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

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<LoginResponse> {
  // 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<LoginResponse> {
  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<void> {
  await prisma.refreshToken.deleteMany({
    where: { token },
  });
}

Step 4: Crear apps/api/src/controllers/auth.controller.ts

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

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

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

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

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

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

{
  "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

{
  "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

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

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

Step 6: Crear apps/web/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['@horux/shared'],
  experimental: {
    typedRoutes: true,
  },
};

module.exports = nextConfig;

Step 7: Crear apps/web/app/globals.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

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 (
    <html lang="es">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Step 9: Crear apps/web/app/page.tsx

import Link from 'next/link';

export default function Home() {
  return (
    <main className="min-h-screen bg-gradient-to-b from-background to-muted">
      <nav className="container mx-auto px-4 py-6 flex justify-between items-center">
        <div className="text-2xl font-bold text-primary">Horux360</div>
        <div className="flex gap-4">
          <Link
            href="/login"
            className="px-4 py-2 text-foreground hover:text-primary transition"
          >
            Iniciar Sesión
          </Link>
          <Link
            href="/register"
            className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition"
          >
            Comenzar Gratis
          </Link>
        </div>
      </nav>

      <section className="container mx-auto px-4 py-20 text-center">
        <h1 className="text-5xl font-bold mb-6">
          Controla tus finanzas con{' '}
          <span className="text-primary">inteligencia</span>
        </h1>
        <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
          La plataforma de análisis financiero para empresas mexicanas más moderna.
          Dashboard en tiempo real, control de IVA/ISR, y mucho más.
        </p>
        <Link
          href="/register"
          className="inline-block px-8 py-4 bg-primary text-primary-foreground rounded-lg text-lg font-semibold hover:opacity-90 transition"
        >
          Prueba gratis 14 días
        </Link>
      </section>

      <section className="container mx-auto px-4 py-16">
        <div className="grid md:grid-cols-4 gap-8">
          {[
            { 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) => (
            <div
              key={feature.title}
              className="p-6 bg-card rounded-xl border shadow-sm"
            >
              <h3 className="text-lg font-semibold mb-2">{feature.title}</h3>
              <p className="text-muted-foreground">{feature.desc}</p>
            </div>
          ))}
        </div>
      </section>
    </main>
  );
}

Step 10: Commit

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

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

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

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

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

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

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

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<ThemeState>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'horux-theme',
    }
  )
);

Step 8: Crear carpeta providers

mkdir -p apps/web/components/providers

Step 9: Crear apps/web/components/providers/theme-provider.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

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 (
    <html lang="es" suppressHydrationWarning>
      <body className={inter.className}>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Step 11: Commit

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

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<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

Step 2: Crear apps/web/components/ui/input.tsx

import * as React from 'react';
import { cn } from '@/lib/utils';

export interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Input.displayName = 'Input';

export { Input };

Step 3: Crear apps/web/components/ui/card.tsx

import * as React from 'react';
import { cn } from '@/lib/utils';

const Card = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      'rounded-lg border bg-card text-card-foreground shadow-sm',
      className
    )}
    {...props}
  />
));
Card.displayName = 'Card';

const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('flex flex-col space-y-1.5 p-6', className)}
    {...props}
  />
));
CardHeader.displayName = 'CardHeader';

const CardTitle = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn(
      'text-2xl font-semibold leading-none tracking-tight',
      className
    )}
    {...props}
  />
));
CardTitle.displayName = 'CardTitle';

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn('text-sm text-muted-foreground', className)}
    {...props}
  />
));
CardDescription.displayName = 'CardDescription';

const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';

const CardFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('flex items-center p-6 pt-0', className)}
    {...props}
  />
));
CardFooter.displayName = 'CardFooter';

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

Step 4: Crear apps/web/components/ui/label.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<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
    VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
  <LabelPrimitive.Root
    ref={ref}
    className={cn(labelVariants(), className)}
    {...props}
  />
));
Label.displayName = LabelPrimitive.Root.displayName;

export { Label };

Step 5: Commit

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

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

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

import { apiClient } from './client';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';

export async function login(data: LoginRequest): Promise<LoginResponse> {
  const response = await apiClient.post<LoginResponse>('/auth/login', data);
  return response.data;
}

export async function register(data: RegisterRequest): Promise<LoginResponse> {
  const response = await apiClient.post<LoginResponse>('/auth/register', data);
  return response.data;
}

export async function logout(): Promise<void> {
  const refreshToken = localStorage.getItem('refreshToken');
  await apiClient.post('/auth/logout', { refreshToken });
}

export async function getMe(): Promise<LoginResponse['user']> {
  const response = await apiClient.get('/auth/me');
  return response.data.user;
}

Step 4: Crear apps/web/stores/auth-store.ts

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<AuthState>()(
  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

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-background to-muted p-4">
      <div className="w-full max-w-md">{children}</div>
    </div>
  );
}

Step 6: Crear apps/web/app/(auth)/login/page.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<HTMLFormElement>) {
    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 (
    <Card>
      <CardHeader className="text-center">
        <CardTitle className="text-2xl">Iniciar Sesión</CardTitle>
        <CardDescription>
          Ingresa tus credenciales para acceder a tu cuenta
        </CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent className="space-y-4">
          {error && (
            <div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
              {error}
            </div>
          )}
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              name="email"
              type="email"
              placeholder="tu@email.com"
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Contraseña</Label>
            <Input
              id="password"
              name="password"
              type="password"
              placeholder="••••••••"
              required
            />
          </div>
        </CardContent>
        <CardFooter className="flex flex-col gap-4">
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
          </Button>
          <p className="text-sm text-muted-foreground text-center">
            ¿No tienes cuenta?{' '}
            <Link href="/register" className="text-primary hover:underline">
              Regístrate
            </Link>
          </p>
        </CardFooter>
      </form>
    </Card>
  );
}

Step 7: Crear apps/web/app/(auth)/register/page.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<HTMLFormElement>) {
    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 (
    <Card>
      <CardHeader className="text-center">
        <CardTitle className="text-2xl">Crear Cuenta</CardTitle>
        <CardDescription>
          Registra tu empresa y comienza tu prueba gratuita
        </CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent className="space-y-4">
          {error && (
            <div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
              {error}
            </div>
          )}

          <div className="space-y-2">
            <Label className="text-xs text-muted-foreground uppercase tracking-wide">
              Datos de la Empresa
            </Label>
            <Input
              name="empresaNombre"
              placeholder="Nombre de la empresa"
              required
            />
            <Input
              name="empresaRfc"
              placeholder="RFC (ej: ABC123456XY1)"
              maxLength={13}
              required
            />
          </div>

          <div className="space-y-2">
            <Label className="text-xs text-muted-foreground uppercase tracking-wide">
              Datos del Administrador
            </Label>
            <Input
              name="nombre"
              placeholder="Tu nombre completo"
              required
            />
            <Input
              name="email"
              type="email"
              placeholder="tu@email.com"
              required
            />
            <Input
              name="password"
              type="password"
              placeholder="Contraseña (mín. 8 caracteres)"
              minLength={8}
              required
            />
          </div>
        </CardContent>
        <CardFooter className="flex flex-col gap-4">
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? 'Creando cuenta...' : 'Crear Cuenta Gratis'}
          </Button>
          <p className="text-sm text-muted-foreground text-center">
            ¿Ya tienes cuenta?{' '}
            <Link href="/login" className="text-primary hover:underline">
              Inicia sesión
            </Link>
          </p>
        </CardFooter>
      </form>
    </Card>
  );
}

Step 8: Crear apps/web/.env.example

NEXT_PUBLIC_API_URL=http://localhost:4000/api

Step 9: Commit

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

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

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

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

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

git add -A
git commit -m "chore: complete Phase 1 - foundation setup"

Step 8: Push al repositorio

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.