# 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](../requerimientos/RF-AUTH-003-multi-tenancy.md) ### 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](../requerimientos/RF-AUTH-001-roles-construccion.md) - 📄 [RF-AUTH-002: Estados de Cuenta](../requerimientos/RF-AUTH-002-estados-cuenta.md) - 📄 [ET-AUTH-001: RBAC](./ET-AUTH-001-rbac.md) - 📄 [ET-AUTH-002: Estados de Cuenta](./ET-AUTH-002-estados-cuenta.md) ### 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 📊 [TRACEABILITY.yml](../implementacion/TRACEABILITY.yml#L15-L44) --- ## 🏗️ 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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:** ```sql -- 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:** ```sql -- 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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, @InjectRepository(UserConstructora) private readonly userConstructoraRepo: Repository, private readonly dataSource: DataSource, ) {} /** * Crear nueva constructora */ async create(dto: CreateConstructoraDto, createdBy: string): Promise { // 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 { 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 { // 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 { // 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 { 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: ```typescript // 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 { 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 ```typescript // 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 ```typescript // 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; fetchAvailableConstructoras: () => Promise; setPrimaryConstructora: (constructoraId: string) => Promise; } export const useConstructoraStore = create()( 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 ```tsx // 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 = ({ 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 (

No tienes acceso a ninguna constructora

Contacta a un administrador para que te invite

); } return (

Selecciona una constructora

{availableConstructoras.map((constructora) => ( ))}
); }; ``` --- ### 4. Componente: Constructora Switcher (Header) ```tsx // 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 (
TUS CONSTRUCTORAS
{availableConstructoras.map((constructora) => { const isCurrent = constructora.constructoraId === currentConstructora.id; return ( handleSwitch(constructora.constructoraId)} className="flex items-center gap-3 px-3 py-2" > {/* Logo */} {constructora.logoUrl ? ( {constructora.nombre} ) : (
)} {/* Info */}
{constructora.nombre}
{isCurrent && }
{constructora.role.replace('_', ' ')}
{/* Primary star */}
); })}
); }; ``` --- ## 🧪 Testing ### Database Functions ```typescript 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 - 📄 [RF-AUTH-003: Multi-tenancy](../requerimientos/RF-AUTH-003-multi-tenancy.md) - 📄 [ET-AUTH-001: RBAC](./ET-AUTH-001-rbac.md) - 📄 [ET-AUTH-002: Estados de Cuenta](./ET-AUTH-002-estados-cuenta.md) ### Recursos Técnicos - [PostgreSQL Row Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - [Multi-tenancy Patterns (Microsoft)](https://docs.microsoft.com/en-us/azure/architecture/patterns/category/data-management) - [Zustand State Management](https://zustand-demo.pmnd.rs/) --- ## 📅 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