40 KiB
ET-AUTH-003: Multi-tenancy Implementation
📋 Metadata
| Campo | Valor |
|---|---|
| ID | ET-AUTH-003 |
| Épica | MAI-001 - Fundamentos |
| Módulo | Autenticación y Multi-tenancy |
| Tipo | Especificación Técnica |
| Estado | 🚧 Planificado |
| Versión | 1.0 |
| Fecha creación | 2025-11-17 |
| Última actualización | 2025-11-17 |
| Esfuerzo estimado | 22h |
🔗 Referencias
Requerimiento Funcional
📄 RF-AUTH-003: Multi-tenancy por Constructora
Origen (GAMILIT)
♻️ Reutilización: 0% - Funcionalidad completamente nueva
- Justificación: GAMILIT es single-tenant, no requiere multi-tenancy
- Inspiración: Patterns de RLS y contexto de GAMILIT adaptados a multi-tenant
- Beneficio: Permite profesionales trabajando en múltiples constructoras
Documentos Relacionados
- 📄 RF-AUTH-001: Sistema de Roles
- 📄 RF-AUTH-002: Estados de Cuenta
- 📄 ET-AUTH-001: RBAC
- 📄 ET-AUTH-002: Estados de Cuenta
Implementación
🗄️ Database: Ver sección "Implementación de Base de Datos" 💻 Backend: Ver sección "Implementación Backend" 🎨 Frontend: Ver sección "Implementación Frontend"
Trazabilidad
🏗️ Arquitectura Multi-tenant
Modelo de Datos
┌────────────────────────────────────────────────────────────────────┐
│ ARQUITECTURA MULTI-TENANT │
└────────────────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ CONSTRUCTORAS │ Tenants (empresas)
│ (Tenants) │
├─────────────────────┤
│ id (PK) │
│ nombre │
│ rfc (UNIQUE) │
│ logo_url │
│ settings (JSONB) │
│ active │
└──────────┬──────────┘
│
│ 1:N
│
▼
┌──────────────────────────────┐
│ USER_CONSTRUCTORAS │ Many-to-Many con metadata
│ (User-Tenant Relationship) │
├──────────────────────────────┤
│ id (PK) │
│ user_id (FK) ────────────┐ │
│ constructora_id (FK) │ │
│ role │ │ Rol DIFERENTE por constructora
│ status │ │ Estado DIFERENTE por constructora
│ is_primary │ │
│ invited_by │ │
│ joined_at │ │
└───────────┬──────────────┘ │
│ │
│ N:1 │ N:1
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ CONSTRUCTORA │ │ PROFILES │ Usuario global
│ (Tenant Entity) │ │ (Users) │
└──────────────────┘ ├──────────────────┤
│ id (PK) │
│ email (UNIQUE) │
│ password_hash │
│ status (global) │ Estado global
└──────────────────┘
TODOS LOS DATOS DE NEGOCIO TIENEN constructora_id:
┌──────────────────┐
│ PROJECTS │
├──────────────────┤
│ id │
│ constructora_id │─────► Aislamiento por tenant
│ nombre │
│ ... │
└──────────────────┘
┌──────────────────┐
│ BUDGETS │
├──────────────────┤
│ id │
│ constructora_id │─────► Aislamiento por tenant
│ project_id │
│ ... │
└──────────────────┘
┌──────────────────┐
│ EMPLOYEES │
├──────────────────┤
│ id │
│ constructora_id │─────► Aislamiento por tenant
│ nombre │
│ ... │
└──────────────────┘
RLS POLICIES garantizan que queries automáticamente filtren por:
WHERE constructora_id = get_current_constructora_id()
🔧 Implementación de Base de Datos
1. Esquema Principal
-- apps/database/ddl/schemas/auth_management/tables/constructoras.sql
CREATE TABLE auth_management.constructoras (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Información básica
nombre VARCHAR(255) NOT NULL,
razon_social VARCHAR(500) NOT NULL,
rfc VARCHAR(13) NOT NULL UNIQUE,
-- Branding
logo_url VARCHAR(1000),
color_primary VARCHAR(7), -- Hex color #FF5733
color_secondary VARCHAR(7),
-- Configuración
settings JSONB DEFAULT '{}'::JSONB,
/*
settings: {
timezone: "America/Mexico_City",
currency: "MXN",
locale: "es-MX",
fiscalRegime: "601",
mainAddress: {
street: "...",
city: "...",
state: "...",
zipCode: "..."
},
billingConfig: {
cfdiUse: "G03",
paymentMethod: "PUE",
paymentForm: "03"
},
features: {
enableBiometric: true,
enableInventory: true,
maxProjects: 50
}
}
*/
-- Estado
active BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by UUID REFERENCES auth_management.profiles(id)
);
-- Índices
CREATE INDEX idx_constructoras_rfc ON auth_management.constructoras(rfc);
CREATE INDEX idx_constructoras_active ON auth_management.constructoras(active) WHERE active = TRUE;
-- Comentarios
COMMENT ON TABLE auth_management.constructoras IS
'Constructoras (tenants) - Cada empresa constructora en el sistema';
COMMENT ON COLUMN auth_management.constructoras.settings IS
'Configuración JSON específica de la constructora (timezone, moneda, features, etc.)';
2. Relación User-Constructora
-- apps/database/ddl/schemas/auth_management/tables/user-constructoras.sql
CREATE TABLE auth_management.user_constructoras (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Relaciones
user_id UUID NOT NULL REFERENCES auth_management.profiles(id) ON DELETE CASCADE,
constructora_id UUID NOT NULL REFERENCES auth_management.constructoras(id) ON DELETE CASCADE,
-- Rol y estado EN ESTA CONSTRUCTORA
role construction_role NOT NULL,
status user_status NOT NULL DEFAULT 'active',
-- Metadata de suspensión (si aplica)
suspended_at TIMESTAMP WITH TIME ZONE,
suspended_by UUID REFERENCES auth_management.profiles(id),
suspended_reason TEXT,
suspended_until TIMESTAMP WITH TIME ZONE,
-- Constructora principal
is_primary BOOLEAN DEFAULT FALSE,
-- Metadata de invitación
invited_by UUID REFERENCES auth_management.profiles(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
joined_at TIMESTAMP WITH TIME ZONE,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraints
UNIQUE(user_id, constructora_id)
);
-- Constraint: Solo una constructora principal por usuario
CREATE UNIQUE INDEX idx_user_primary_constructora
ON auth_management.user_constructoras(user_id)
WHERE is_primary = TRUE;
-- Índices para performance
CREATE INDEX idx_user_constructoras_user
ON auth_management.user_constructoras(user_id);
CREATE INDEX idx_user_constructoras_constructora
ON auth_management.user_constructoras(constructora_id);
CREATE INDEX idx_user_constructoras_active
ON auth_management.user_constructoras(user_id, constructora_id, status)
WHERE status = 'active';
CREATE INDEX idx_user_constructoras_role
ON auth_management.user_constructoras(constructora_id, role);
-- Comentarios
COMMENT ON TABLE auth_management.user_constructoras IS
'Relación many-to-many usuarios-constructoras con rol y estado por constructora';
COMMENT ON COLUMN auth_management.user_constructoras.is_primary IS
'Constructora principal del usuario (pre-seleccionada al login). Solo una puede ser true.';
3. Funciones de Contexto
get_current_constructora_id()
Propósito: Obtener constructora activa en contexto actual
-- apps/database/ddl/schemas/auth_management/functions/get-current-constructora-id.sql
CREATE OR REPLACE FUNCTION auth_management.get_current_constructora_id()
RETURNS UUID
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
BEGIN
-- Obtener de variable de sesión configurada por backend
RETURN NULLIF(current_setting('app.current_constructora_id', true), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION auth_management.get_current_constructora_id IS
'Retorna constructora activa del contexto actual (configurada por SetRlsContextInterceptor)';
user_has_access_to_constructora()
Propósito: Verificar si usuario tiene acceso activo a constructora
-- apps/database/ddl/schemas/auth_management/functions/user-has-access-to-constructora.sql
CREATE OR REPLACE FUNCTION auth_management.user_has_access_to_constructora(
p_user_id UUID,
p_constructora_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM auth_management.user_constructoras uc
INNER JOIN auth_management.constructoras c ON c.id = uc.constructora_id
WHERE uc.user_id = p_user_id
AND uc.constructora_id = p_constructora_id
AND uc.status = 'active'
AND c.active = TRUE
);
END;
$$;
COMMENT ON FUNCTION auth_management.user_has_access_to_constructora IS
'Verifica si usuario tiene acceso activo a una constructora específica';
get_user_active_constructoras()
Propósito: Obtener todas las constructoras activas del usuario
-- apps/database/ddl/schemas/auth_management/functions/get-user-active-constructoras.sql
CREATE OR REPLACE FUNCTION auth_management.get_user_active_constructoras(
p_user_id UUID
)
RETURNS TABLE (
constructora_id UUID,
nombre VARCHAR(255),
logo_url VARCHAR(1000),
role construction_role,
is_primary BOOLEAN
)
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT
c.id AS constructora_id,
c.nombre,
c.logo_url,
uc.role,
uc.is_primary
FROM auth_management.user_constructoras uc
INNER JOIN auth_management.constructoras c ON c.id = uc.constructora_id
WHERE uc.user_id = p_user_id
AND uc.status = 'active'
AND c.active = TRUE
ORDER BY uc.is_primary DESC, c.nombre ASC;
END;
$$;
COMMENT ON FUNCTION auth_management.get_user_active_constructoras IS
'Retorna todas las constructoras activas del usuario (para selector de constructora)';
4. RLS Policies Multi-tenant
Patrón estándar para TODAS las tablas de negocio:
-- apps/database/ddl/schemas/projects/tables/projects.sql
-- Habilitar RLS
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
-- Policy base: Constructora Isolation
CREATE POLICY "constructora_isolation_policy"
ON projects.projects
FOR ALL
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
)
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
);
-- Policy adicional: Role-based (si se necesita)
CREATE POLICY "directors_view_all"
ON projects.projects
FOR SELECT
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'director'
);
Ejemplo más complejo con proyectos:
-- apps/database/ddl/schemas/projects/tables/projects.sql
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
-- SELECT: Depende del rol
CREATE POLICY "projects_select_by_role"
ON projects.projects
FOR SELECT
TO authenticated
USING (
-- Siempre filtrar por constructora
constructora_id = auth_management.get_current_constructora_id()
AND (
-- Director y Engineer ven todos los proyectos
auth_management.user_has_any_role(ARRAY['director', 'engineer', 'finance'])
OR
-- Residente solo ve proyectos asignados
(
auth_management.get_current_user_role() = 'resident'
AND id IN (
SELECT project_id
FROM projects.project_team_assignments
WHERE user_id = auth_management.get_current_user_id()
AND role = 'resident'
AND active = TRUE
)
)
)
);
-- INSERT: Solo director y engineer
CREATE POLICY "projects_insert"
ON projects.projects
FOR INSERT
TO authenticated
WITH CHECK (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- UPDATE: Solo director y engineer
CREATE POLICY "projects_update"
ON projects.projects
FOR UPDATE
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.user_has_any_role(ARRAY['director', 'engineer'])
);
-- DELETE: Solo director
CREATE POLICY "projects_delete"
ON projects.projects
FOR DELETE
TO authenticated
USING (
constructora_id = auth_management.get_current_constructora_id()
AND auth_management.get_current_user_role() = 'director'
);
💻 Implementación Backend
1. Entities de TypeORM
Constructora Entity
// apps/backend/src/modules/auth/entities/constructora.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export interface ConstructoraSettings {
timezone?: string;
currency?: string;
locale?: string;
fiscalRegime?: string;
mainAddress?: {
street: string;
city: string;
state: string;
zipCode: string;
};
billingConfig?: {
cfdiUse: string;
paymentMethod: string;
paymentForm: string;
};
features?: {
enableBiometric?: boolean;
enableInventory?: boolean;
maxProjects?: number;
};
}
@Entity('constructoras', { schema: 'auth_management' })
export class Constructora {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
nombre: string;
@Column({ type: 'varchar', length: 500, name: 'razon_social' })
razonSocial: string;
@Column({ type: 'varchar', length: 13, unique: true })
rfc: string;
@Column({ type: 'varchar', length: 1000, nullable: true, name: 'logo_url' })
logoUrl: string;
@Column({ type: 'varchar', length: 7, nullable: true, name: 'color_primary' })
colorPrimary: string;
@Column({ type: 'varchar', length: 7, nullable: true, name: 'color_secondary' })
colorSecondary: string;
@Column({ type: 'jsonb', default: {} })
settings: ConstructoraSettings;
@Column({ type: 'boolean', default: true })
active: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string;
@OneToMany(() => UserConstructora, (uc) => uc.constructora)
users: UserConstructora[];
}
UserConstructora Entity
// apps/backend/src/modules/auth/entities/user-constructora.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Profile } from './profile.entity';
import { Constructora } from './constructora.entity';
import { ConstructionRole } from '../enums/construction-role.enum';
import { UserStatus } from '../enums/user-status.enum';
@Entity('user_constructoras', { schema: 'auth_management' })
export class UserConstructora {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'user_id' })
userId: string;
@Column({ type: 'uuid', name: 'constructora_id' })
constructoraId: string;
@Column({ type: 'enum', enum: ConstructionRole })
role: ConstructionRole;
@Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE })
status: UserStatus;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'suspended_at' })
suspendedAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'suspended_by' })
suspendedBy: string;
@Column({ type: 'text', nullable: true, name: 'suspended_reason' })
suspendedReason: string;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'suspended_until' })
suspendedUntil: Date;
@Column({ type: 'boolean', default: false, name: 'is_primary' })
isPrimary: boolean;
@Column({ type: 'uuid', nullable: true, name: 'invited_by' })
invitedBy: string;
@Column({ type: 'timestamp with time zone', default: () => 'NOW()', name: 'invited_at' })
invitedAt: Date;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'joined_at' })
joinedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => Profile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: Profile;
@ManyToOne(() => Constructora, (constructora) => constructora.users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'constructora_id' })
constructora: Constructora;
}
2. DTO's para Multi-tenancy
// apps/backend/src/modules/auth/dto/create-constructora.dto.ts
import { IsString, IsNotEmpty, Length, IsOptional, IsObject, Matches } from 'class-validator';
export class CreateConstructoraDto {
@IsString()
@IsNotEmpty()
@Length(3, 255)
nombre: string;
@IsString()
@IsNotEmpty()
@Length(5, 500)
razonSocial: string;
@IsString()
@IsNotEmpty()
@Length(13, 13)
@Matches(/^[A-Z&Ñ]{3,4}\d{6}[A-V1-9][A-Z0-9][0-9A]$/, {
message: 'RFC inválido para persona moral mexicana',
})
rfc: string;
@IsString()
@IsOptional()
logoUrl?: string;
@IsString()
@IsOptional()
@Matches(/^#[0-9A-Fa-f]{6}$/)
colorPrimary?: string;
@IsString()
@IsOptional()
@Matches(/^#[0-9A-Fa-f]{6}$/)
colorSecondary?: string;
@IsObject()
@IsOptional()
settings?: ConstructoraSettings;
}
// apps/backend/src/modules/auth/dto/switch-constructora.dto.ts
import { IsUUID } from 'class-validator';
export class SwitchConstructoraDto {
@IsUUID()
constructoraId: string;
}
// apps/backend/src/modules/auth/dto/invite-to-constructora.dto.ts
import { IsEmail, IsUUID, IsEnum } from 'class-validator';
import { ConstructionRole } from '../enums/construction-role.enum';
export class InviteToConstructoraDto {
@IsEmail()
email: string;
@IsUUID()
constructoraId: string;
@IsEnum(ConstructionRole)
role: ConstructionRole;
}
3. Service: Constructora Service
// apps/backend/src/modules/auth/services/constructora.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Constructora } from '../entities/constructora.entity';
import { UserConstructora } from '../entities/user-constructora.entity';
import { CreateConstructoraDto } from '../dto/create-constructora.dto';
import { InviteToConstructoraDto } from '../dto/invite-to-constructora.dto';
import { ConstructionRole } from '../enums/construction-role.enum';
import { UserStatus } from '../enums/user-status.enum';
@Injectable()
export class ConstructoraService {
constructor(
@InjectRepository(Constructora)
private readonly constructoraRepo: Repository<Constructora>,
@InjectRepository(UserConstructora)
private readonly userConstructoraRepo: Repository<UserConstructora>,
private readonly dataSource: DataSource,
) {}
/**
* Crear nueva constructora
*/
async create(dto: CreateConstructoraDto, createdBy: string): Promise<Constructora> {
// Validar RFC único
const existing = await this.constructoraRepo.findOne({ where: { rfc: dto.rfc } });
if (existing) {
throw new BadRequestException(`RFC ${dto.rfc} ya está registrado`);
}
// Crear constructora
const constructora = this.constructoraRepo.create({
...dto,
createdBy,
});
await this.constructoraRepo.save(constructora);
// Asociar creador como director
await this.userConstructoraRepo.save({
userId: createdBy,
constructoraId: constructora.id,
role: ConstructionRole.DIRECTOR,
status: UserStatus.ACTIVE,
isPrimary: false, // Usuario puede decidir después
joinedAt: new Date(),
});
return constructora;
}
/**
* Obtener constructoras activas del usuario
*/
async getUserActiveConstructoras(userId: string): Promise<any[]> {
const result = await this.dataSource.query(`
SELECT * FROM auth_management.get_user_active_constructoras($1)
`, [userId]);
return result;
}
/**
* Cambiar constructora primaria
*/
async setPrimaryConstructora(userId: string, constructoraId: string): Promise<void> {
// Validar que usuario tenga acceso
const access = await this.userConstructoraRepo.findOne({
where: {
userId,
constructoraId,
status: UserStatus.ACTIVE,
},
});
if (!access) {
throw new NotFoundException('No tienes acceso a esta constructora');
}
// Transacción: quitar primary de todas, asignar a nueva
await this.dataSource.transaction(async (manager) => {
// Quitar is_primary de todas las constructoras del usuario
await manager.update(
UserConstructora,
{ userId },
{ isPrimary: false }
);
// Asignar is_primary a la nueva
await manager.update(
UserConstructora,
{ userId, constructoraId },
{ isPrimary: true }
);
});
}
/**
* Invitar usuario a constructora
*/
async inviteToConstructora(dto: InviteToConstructoraDto, invitedBy: string): Promise<string> {
// Validar que invitador sea director en esa constructora
const inviterAccess = await this.userConstructoraRepo.findOne({
where: {
userId: invitedBy,
constructoraId: dto.constructoraId,
role: ConstructionRole.DIRECTOR,
status: UserStatus.ACTIVE,
},
});
if (!inviterAccess) {
throw new BadRequestException('Solo directores pueden invitar usuarios');
}
// Verificar si usuario ya existe
const existingUser = await this.profileRepo.findOne({ where: { email: dto.email } });
if (existingUser) {
// Usuario existente: asociar directamente a constructora
const existingAssociation = await this.userConstructoraRepo.findOne({
where: {
userId: existingUser.id,
constructoraId: dto.constructoraId,
},
});
if (existingAssociation) {
throw new BadRequestException('Usuario ya está asociado a esta constructora');
}
// Crear asociación
await this.userConstructoraRepo.save({
userId: existingUser.id,
constructoraId: dto.constructoraId,
role: dto.role,
status: UserStatus.ACTIVE,
invitedBy,
joinedAt: new Date(),
});
// Enviar notificación
await this.sendExistingUserInvitation(existingUser, dto.constructoraId, dto.role);
return 'Usuario existente asociado a constructora';
} else {
// Usuario nuevo: crear invitación
const invitation = await this.createInvitation(dto, invitedBy);
// Enviar email de invitación
await this.sendNewUserInvitation(dto.email, invitation.token, dto.constructoraId, dto.role);
return invitation.token;
}
}
/**
* Verificar acceso a constructora
*/
async hasAccessToConstructora(userId: string, constructoraId: string): Promise<boolean> {
const result = await this.dataSource.query(`
SELECT auth_management.user_has_access_to_constructora($1, $2) AS has_access
`, [userId, constructoraId]);
return result[0]?.has_access || false;
}
}
4. Interceptor: SetRlsContextInterceptor
Ya documentado en ET-AUTH-001, aquí un recordatorio:
// apps/backend/src/common/interceptors/set-rls-context.interceptor.ts
@Injectable()
export class SetRlsContextInterceptor implements NestInterceptor {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return next.handle();
}
// Configurar variables de sesión de PostgreSQL
return from(
this.dataSource.query(`
SELECT
set_config('app.current_user_id', $1, true),
set_config('app.current_constructora_id', $2, true),
set_config('app.current_user_role', $3, true)
`, [
user.id || '',
user.constructoraId || '',
user.role || '',
])
).pipe(
switchMap(() => next.handle())
);
}
}
🎨 Implementación Frontend
1. Types
// apps/frontend/src/types/constructora.types.ts
export interface Constructora {
id: string;
nombre: string;
razonSocial: string;
rfc: string;
logoUrl?: string;
colorPrimary?: string;
colorSecondary?: string;
settings: ConstructoraSettings;
active: boolean;
}
export interface ConstructoraSettings {
timezone?: string;
currency?: string;
locale?: string;
fiscalRegime?: string;
mainAddress?: Address;
billingConfig?: BillingConfig;
features?: Features;
}
export interface UserConstructoraAccess {
constructoraId: string;
nombre: string;
logoUrl?: string;
role: ConstructionRole;
isPrimary: boolean;
}
2. Zustand Store
// apps/frontend/src/stores/constructora-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Constructora, UserConstructoraAccess } from '@/types/constructora.types';
import api from '@/lib/api';
interface ConstructoraStore {
// State
currentConstructora: Constructora | null;
availableConstructoras: UserConstructoraAccess[];
// Actions
setCurrentConstructora: (constructora: Constructora) => void;
setAvailableConstructoras: (constructoras: UserConstructoraAccess[]) => void;
switchConstructora: (constructoraId: string) => Promise<void>;
fetchAvailableConstructoras: () => Promise<void>;
setPrimaryConstructora: (constructoraId: string) => Promise<void>;
}
export const useConstructoraStore = create<ConstructoraStore>()(
persist(
(set, get) => ({
currentConstructora: null,
availableConstructoras: [],
setCurrentConstructora: (constructora) => {
set({ currentConstructora: constructora });
},
setAvailableConstructoras: (constructoras) => {
set({ availableConstructoras: constructoras });
},
switchConstructora: async (constructoraId: string) => {
try {
// Request switch to backend
const response = await api.post('/auth/switch-constructora', {
constructoraId,
});
// Update token
const newToken = response.data.accessToken;
localStorage.setItem('accessToken', newToken);
// Find constructora in available list
const constructora = get().availableConstructoras.find(
(c) => c.constructoraId === constructoraId
);
if (constructora) {
// Fetch full constructora details
const detailsResponse = await api.get(`/constructoras/${constructoraId}`);
set({ currentConstructora: detailsResponse.data });
}
// Reload page to apply new context
window.location.reload();
} catch (error) {
console.error('Error switching constructora:', error);
throw error;
}
},
fetchAvailableConstructoras: async () => {
const response = await api.get('/auth/my-constructoras');
set({ availableConstructoras: response.data });
},
setPrimaryConstructora: async (constructoraId: string) => {
await api.patch('/user/set-primary-constructora', {
constructoraId,
});
// Update local state
const updated = get().availableConstructoras.map((c) => ({
...c,
isPrimary: c.constructoraId === constructoraId,
}));
set({ availableConstructoras: updated });
},
}),
{
name: 'constructora-storage',
partialize: (state) => ({
currentConstructora: state.currentConstructora,
availableConstructoras: state.availableConstructoras,
}),
}
)
);
3. Componente: Constructora Selector
// apps/frontend/src/components/auth/ConstructoraSelector.tsx
import React from 'react';
import { useConstructoraStore } from '@/stores/constructora-store';
import { UserConstructoraAccess } from '@/types/constructora.types';
import { Building2, Star, ChevronRight } from 'lucide-react';
interface ConstructoraSelectorProps {
onSelect?: (constructoraId: string) => void;
}
export const ConstructoraSelector: React.FC<ConstructoraSelectorProps> = ({
onSelect,
}) => {
const { availableConstructoras, switchConstructora } = useConstructoraStore();
const handleSelect = async (constructoraId: string) => {
try {
await switchConstructora(constructoraId);
onSelect?.(constructoraId);
} catch (error) {
console.error('Error selecting constructora:', error);
// Show error toast
}
};
if (availableConstructoras.length === 0) {
return (
<div className="text-center py-12">
<Building2 className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<p className="text-gray-600">No tienes acceso a ninguna constructora</p>
<p className="text-sm text-gray-500 mt-2">
Contacta a un administrador para que te invite
</p>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Selecciona una constructora
</h3>
<div className="grid gap-3">
{availableConstructoras.map((constructora) => (
<button
key={constructora.constructoraId}
onClick={() => handleSelect(constructora.constructoraId)}
className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all"
>
{/* Logo */}
{constructora.logoUrl ? (
<img
src={constructora.logoUrl}
alt={constructora.nombre}
className="w-12 h-12 rounded object-cover"
/>
) : (
<div className="w-12 h-12 rounded bg-gray-100 flex items-center justify-center">
<Building2 className="w-6 h-6 text-gray-400" />
</div>
)}
{/* Info */}
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900">
{constructora.nombre}
</h4>
{constructora.isPrimary && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full">
<Star className="w-3 h-3 fill-current" />
Principal
</span>
)}
</div>
<p className="text-sm text-gray-600 capitalize">
{constructora.role.replace('_', ' ')}
</p>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
))}
</div>
</div>
);
};
4. Componente: Constructora Switcher (Header)
// apps/frontend/src/components/layout/ConstructoraSwitcher.tsx
import React, { useState } from 'react';
import { useConstructoraStore } from '@/stores/constructora-store';
import { Building2, ChevronDown, Star, Check } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
export const ConstructoraSwitcher: React.FC = () => {
const { currentConstructora, availableConstructoras, switchConstructora, setPrimaryConstructora } =
useConstructoraStore();
const [isSwitching, setIsSwitching] = useState(false);
const handleSwitch = async (constructoraId: string) => {
if (currentConstructora?.id === constructoraId) return;
setIsSwitching(true);
try {
await switchConstructora(constructoraId);
} finally {
setIsSwitching(false);
}
};
const handleSetPrimary = async (e: React.MouseEvent, constructoraId: string) => {
e.stopPropagation();
await setPrimaryConstructora(constructoraId);
};
if (!currentConstructora) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
disabled={isSwitching}
>
{currentConstructora.logoUrl ? (
<img
src={currentConstructora.logoUrl}
alt={currentConstructora.nombre}
className="w-8 h-8 rounded object-cover"
/>
) : (
<div className="w-8 h-8 rounded bg-gray-200 flex items-center justify-center">
<Building2 className="w-4 h-4 text-gray-600" />
</div>
)}
<div className="text-left">
<div className="font-medium text-sm">{currentConstructora.nombre}</div>
<div className="text-xs text-gray-500">Cambiar constructora</div>
</div>
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">
TUS CONSTRUCTORAS
</div>
<DropdownMenuSeparator />
{availableConstructoras.map((constructora) => {
const isCurrent = constructora.constructoraId === currentConstructora.id;
return (
<DropdownMenuItem
key={constructora.constructoraId}
onClick={() => handleSwitch(constructora.constructoraId)}
className="flex items-center gap-3 px-3 py-2"
>
{/* Logo */}
{constructora.logoUrl ? (
<img
src={constructora.logoUrl}
alt={constructora.nombre}
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-gray-100 flex items-center justify-center">
<Building2 className="w-5 h-5 text-gray-400" />
</div>
)}
{/* Info */}
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="font-medium text-sm">{constructora.nombre}</div>
{isCurrent && <Check className="w-4 h-4 text-green-600" />}
</div>
<div className="text-xs text-gray-500 capitalize">
{constructora.role.replace('_', ' ')}
</div>
</div>
{/* Primary star */}
<button
onClick={(e) => handleSetPrimary(e, constructora.constructoraId)}
className={`p-1 rounded hover:bg-gray-200 ${
constructora.isPrimary ? 'text-yellow-500' : 'text-gray-300'
}`}
title={constructora.isPrimary ? 'Principal' : 'Marcar como principal'}
>
<Star className={`w-4 h-4 ${constructora.isPrimary ? 'fill-current' : ''}`} />
</button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
🧪 Testing
Database Functions
describe('Multi-tenancy Database Functions', () => {
it('get_user_active_constructoras should return only active constructoras', async () => {
const user = await createUser();
const constructoraA = await createConstructora({ nombre: 'A', active: true });
const constructoraB = await createConstructora({ nombre: 'B', active: false });
await assignToConstructora(user.id, constructoraA.id, 'engineer', 'active');
await assignToConstructora(user.id, constructoraB.id, 'director', 'active');
const result = await db.query(`
SELECT * FROM auth_management.get_user_active_constructoras($1)
`, [user.id]);
// Solo debe retornar constructora A (activa)
expect(result.rows).toHaveLength(1);
expect(result.rows[0].constructora_id).toBe(constructoraA.id);
});
it('user_has_access_to_constructora should validate status', async () => {
const user = await createUser();
const constructora = await createConstructora();
// Sin acceso
let hasAccess = await db.query(`
SELECT auth_management.user_has_access_to_constructora($1, $2) AS result
`, [user.id, constructora.id]);
expect(hasAccess.rows[0].result).toBe(false);
// Con acceso activo
await assignToConstructora(user.id, constructora.id, 'engineer', 'active');
hasAccess = await db.query(`
SELECT auth_management.user_has_access_to_constructora($1, $2) AS result
`, [user.id, constructora.id]);
expect(hasAccess.rows[0].result).toBe(true);
// Suspendido
await suspendInConstructora(user.id, constructora.id);
hasAccess = await db.query(`
SELECT auth_management.user_has_access_to_constructora($1, $2) AS result
`, [user.id, constructora.id]);
expect(hasAccess.rows[0].result).toBe(false);
});
});
📚 Referencias Adicionales
Documentos Relacionados
Recursos Técnicos
📅 Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0 | 2025-11-17 | Tech Team | Creación inicial - Implementación completa multi-tenancy |
Documento: MAI-001-fundamentos/especificaciones/ET-AUTH-003-multi-tenancy.md
Ruta absoluta: [RUTA-LEGACY-ELIMINADA]/docs/01-fase-alcance-inicial/MAI-001-fundamentos/especificaciones/ET-AUTH-003-multi-tenancy.md
Generado: 2025-11-17
Mantenedores: @tech-lead @backend-team @frontend-team @database-team