-- ============================================================= -- ARCHIVO: 13-storage.sql -- DESCRIPCION: Sistema de almacenamiento de archivos, carpetas, uploads -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- EPIC: SAAS-STORAGE (EPIC-SAAS-006) -- HISTORIAS: US-070, US-071, US-072 -- ============================================================= -- ===================== -- SCHEMA: storage -- ===================== CREATE SCHEMA IF NOT EXISTS storage; -- ===================== -- TABLA: storage.buckets -- Contenedores de almacenamiento -- ===================== CREATE TABLE IF NOT EXISTS storage.buckets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(100) NOT NULL UNIQUE, description TEXT, -- Tipo bucket_type VARCHAR(30) NOT NULL DEFAULT 'private', -- public: acceso público sin autenticación -- private: requiere autenticación -- protected: requiere token temporal -- Configuración max_file_size_mb INTEGER DEFAULT 50, allowed_mime_types TEXT[] DEFAULT '{}', -- Vacío = todos permitidos allowed_extensions TEXT[] DEFAULT '{}', -- Políticas auto_delete_days INTEGER, -- NULL = no auto-eliminar versioning_enabled BOOLEAN DEFAULT FALSE, max_versions INTEGER DEFAULT 5, -- Storage backend storage_provider VARCHAR(30) DEFAULT 'local', -- local, s3, gcs, azure storage_config JSONB DEFAULT '{}', -- Límites por tenant quota_per_tenant_gb INTEGER, -- Estado is_active BOOLEAN DEFAULT TRUE, is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- ===================== -- TABLA: storage.folders -- Estructura de carpetas virtuales -- ===================== CREATE TABLE IF NOT EXISTS storage.folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE, -- Jerarquía parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE, path TEXT NOT NULL, -- /documents/invoices/2026/ name VARCHAR(255) NOT NULL, depth INTEGER DEFAULT 0, -- Metadata description TEXT, color VARCHAR(7), -- Color hex para UI icon VARCHAR(50), -- Permisos is_private BOOLEAN DEFAULT FALSE, owner_id UUID REFERENCES auth.users(id), -- Estadísticas (actualizadas async) file_count INTEGER DEFAULT 0, total_size_bytes BIGINT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), UNIQUE(tenant_id, bucket_id, path) ); -- ===================== -- TABLA: storage.files -- Archivos almacenados -- ===================== CREATE TABLE IF NOT EXISTS storage.files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE, folder_id UUID REFERENCES storage.folders(id) ON DELETE SET NULL, -- Identificación name VARCHAR(255) NOT NULL, original_name VARCHAR(255) NOT NULL, path TEXT NOT NULL, -- Ruta completa en storage -- Tipo de archivo mime_type VARCHAR(100) NOT NULL, extension VARCHAR(20), category VARCHAR(30), -- image, document, video, audio, archive, other -- Tamaño size_bytes BIGINT NOT NULL, -- Hashes para integridad y deduplicación checksum_md5 VARCHAR(32), checksum_sha256 VARCHAR(64), -- Almacenamiento storage_key TEXT NOT NULL, -- Key en el backend de storage storage_url TEXT, -- URL directa (si aplica) cdn_url TEXT, -- URL de CDN (si aplica) -- Imagen (si aplica) width INTEGER, height INTEGER, thumbnail_url TEXT, thumbnails JSONB DEFAULT '{}', -- {small: url, medium: url, large: url} -- Metadata metadata JSONB DEFAULT '{}', tags TEXT[] DEFAULT '{}', alt_text TEXT, -- Versionamiento version INTEGER DEFAULT 1, parent_version_id UUID REFERENCES storage.files(id), is_latest BOOLEAN DEFAULT TRUE, -- Asociación con entidades entity_type VARCHAR(100), -- product, user, invoice, etc. entity_id UUID, -- Acceso is_public BOOLEAN DEFAULT FALSE, access_count INTEGER DEFAULT 0, last_accessed_at TIMESTAMPTZ, -- Estado status VARCHAR(20) DEFAULT 'active', -- active, processing, archived, deleted archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, -- Procesamiento processing_status VARCHAR(20), -- pending, processing, completed, failed processing_error TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, uploaded_by UUID REFERENCES auth.users(id), UNIQUE(tenant_id, bucket_id, path, version) ); -- ===================== -- TABLA: storage.file_access_tokens -- Tokens de acceso temporal a archivos -- ===================== CREATE TABLE IF NOT EXISTS storage.file_access_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Token token VARCHAR(255) NOT NULL UNIQUE, -- Permisos permissions TEXT[] DEFAULT '{read}', -- read, download, write -- Restricciones allowed_ips INET[], max_downloads INTEGER, download_count INTEGER DEFAULT 0, -- Validez expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, -- Metadata created_for VARCHAR(255), -- Email o nombre para quien se creó purpose TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id) ); -- ===================== -- TABLA: storage.uploads -- Uploads en progreso (multipart, resumable) -- ===================== CREATE TABLE IF NOT EXISTS storage.uploads ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE, folder_id UUID REFERENCES storage.folders(id), -- Archivo destino file_name VARCHAR(255) NOT NULL, mime_type VARCHAR(100), total_size_bytes BIGINT, -- Estado del upload status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, uploading, processing, completed, failed, cancelled -- Progreso uploaded_bytes BIGINT DEFAULT 0, upload_progress DECIMAL(5,2) DEFAULT 0, -- Chunks (para multipart) total_chunks INTEGER, completed_chunks INTEGER DEFAULT 0, chunk_size_bytes INTEGER, chunks_status JSONB DEFAULT '{}', -- {0: 'completed', 1: 'pending', ...} -- Metadata metadata JSONB DEFAULT '{}', -- Resultado file_id UUID REFERENCES storage.files(id), error_message TEXT, -- Tiempos started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, last_chunk_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id) ); -- ===================== -- TABLA: storage.file_shares -- Archivos compartidos -- ===================== CREATE TABLE IF NOT EXISTS storage.file_shares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Compartido con shared_with_user_id UUID REFERENCES auth.users(id), shared_with_email VARCHAR(255), shared_with_role VARCHAR(50), -- Permisos can_view BOOLEAN DEFAULT TRUE, can_download BOOLEAN DEFAULT TRUE, can_edit BOOLEAN DEFAULT FALSE, can_delete BOOLEAN DEFAULT FALSE, can_share BOOLEAN DEFAULT FALSE, -- Link público public_link VARCHAR(255) UNIQUE, public_link_password VARCHAR(255), -- Validez expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, -- Estadísticas view_count INTEGER DEFAULT 0, download_count INTEGER DEFAULT 0, last_accessed_at TIMESTAMPTZ, -- Notificaciones notify_on_access BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- ===================== -- TABLA: storage.tenant_usage -- Uso de storage por tenant -- ===================== CREATE TABLE IF NOT EXISTS storage.tenant_usage ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE, -- Uso actual file_count INTEGER DEFAULT 0, total_size_bytes BIGINT DEFAULT 0, -- Límites quota_bytes BIGINT, quota_file_count INTEGER, -- Uso por categoría usage_by_category JSONB DEFAULT '{}', -- {image: 1024000, document: 2048000, ...} -- Histórico mensual monthly_upload_bytes BIGINT DEFAULT 0, monthly_download_bytes BIGINT DEFAULT 0, month_year VARCHAR(7), -- 2026-01 updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, bucket_id, month_year) ); -- ===================== -- INDICES -- ===================== -- Indices para buckets CREATE INDEX IF NOT EXISTS idx_buckets_name ON storage.buckets(name); CREATE INDEX IF NOT EXISTS idx_buckets_active ON storage.buckets(is_active) WHERE is_active = TRUE; -- Indices para folders CREATE INDEX IF NOT EXISTS idx_folders_tenant ON storage.folders(tenant_id); CREATE INDEX IF NOT EXISTS idx_folders_bucket ON storage.folders(bucket_id); CREATE INDEX IF NOT EXISTS idx_folders_parent ON storage.folders(parent_id); CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders(path); -- Indices para files CREATE INDEX IF NOT EXISTS idx_files_tenant ON storage.files(tenant_id); CREATE INDEX IF NOT EXISTS idx_files_bucket ON storage.files(bucket_id); CREATE INDEX IF NOT EXISTS idx_files_folder ON storage.files(folder_id); CREATE INDEX IF NOT EXISTS idx_files_entity ON storage.files(entity_type, entity_id); CREATE INDEX IF NOT EXISTS idx_files_mime ON storage.files(mime_type); CREATE INDEX IF NOT EXISTS idx_files_category ON storage.files(category); CREATE INDEX IF NOT EXISTS idx_files_status ON storage.files(status); CREATE INDEX IF NOT EXISTS idx_files_checksum ON storage.files(checksum_sha256); CREATE INDEX IF NOT EXISTS idx_files_created ON storage.files(created_at DESC); CREATE INDEX IF NOT EXISTS idx_files_tags ON storage.files USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_files_latest ON storage.files(parent_version_id) WHERE is_latest = TRUE; -- Indices para file_access_tokens CREATE INDEX IF NOT EXISTS idx_access_tokens_file ON storage.file_access_tokens(file_id); CREATE INDEX IF NOT EXISTS idx_access_tokens_token ON storage.file_access_tokens(token); CREATE INDEX IF NOT EXISTS idx_access_tokens_valid ON storage.file_access_tokens(expires_at) WHERE revoked_at IS NULL; -- Indices para uploads CREATE INDEX IF NOT EXISTS idx_uploads_tenant ON storage.uploads(tenant_id); CREATE INDEX IF NOT EXISTS idx_uploads_status ON storage.uploads(status); CREATE INDEX IF NOT EXISTS idx_uploads_expires ON storage.uploads(expires_at) WHERE status = 'uploading'; -- Indices para file_shares CREATE INDEX IF NOT EXISTS idx_shares_file ON storage.file_shares(file_id); CREATE INDEX IF NOT EXISTS idx_shares_user ON storage.file_shares(shared_with_user_id); CREATE INDEX IF NOT EXISTS idx_shares_link ON storage.file_shares(public_link); -- Indices para tenant_usage CREATE INDEX IF NOT EXISTS idx_usage_tenant ON storage.tenant_usage(tenant_id); CREATE INDEX IF NOT EXISTS idx_usage_bucket ON storage.tenant_usage(bucket_id); -- ===================== -- RLS POLICIES -- ===================== -- Buckets son globales (lectura pública) ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY; CREATE POLICY public_read_buckets ON storage.buckets FOR SELECT USING (is_active = TRUE); -- Folders por tenant ALTER TABLE storage.folders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_folders ON storage.folders USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Files por tenant ALTER TABLE storage.files ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_files ON storage.files USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Access tokens por tenant ALTER TABLE storage.file_access_tokens ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_tokens ON storage.file_access_tokens USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Uploads por tenant ALTER TABLE storage.uploads ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_uploads ON storage.uploads USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- File shares por tenant ALTER TABLE storage.file_shares ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_shares ON storage.file_shares USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tenant usage por tenant ALTER TABLE storage.tenant_usage ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_usage ON storage.tenant_usage USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ===================== -- FUNCIONES -- ===================== -- Función para generar storage key único CREATE OR REPLACE FUNCTION storage.generate_storage_key( p_tenant_id UUID, p_bucket_name VARCHAR(100), p_file_name VARCHAR(255) ) RETURNS TEXT AS $$ BEGIN RETURN p_bucket_name || '/' || p_tenant_id::TEXT || '/' || TO_CHAR(CURRENT_DATE, 'YYYY/MM/DD') || '/' || gen_random_uuid()::TEXT || '/' || p_file_name; END; $$ LANGUAGE plpgsql; -- Función para determinar categoría por mime type CREATE OR REPLACE FUNCTION storage.get_file_category(p_mime_type VARCHAR(100)) RETURNS VARCHAR(30) AS $$ BEGIN RETURN CASE WHEN p_mime_type LIKE 'image/%' THEN 'image' WHEN p_mime_type LIKE 'video/%' THEN 'video' WHEN p_mime_type LIKE 'audio/%' THEN 'audio' WHEN p_mime_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv') THEN 'document' WHEN p_mime_type IN ('application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip') THEN 'archive' ELSE 'other' END; END; $$ LANGUAGE plpgsql IMMUTABLE; -- Función para crear archivo CREATE OR REPLACE FUNCTION storage.create_file( p_tenant_id UUID, p_bucket_id UUID, p_folder_id UUID, p_name VARCHAR(255), p_original_name VARCHAR(255), p_mime_type VARCHAR(100), p_size_bytes BIGINT, p_storage_key TEXT, p_uploaded_by UUID DEFAULT NULL, p_metadata JSONB DEFAULT '{}' ) RETURNS UUID AS $$ DECLARE v_file_id UUID; v_bucket RECORD; v_path TEXT; v_category VARCHAR(30); BEGIN -- Verificar bucket SELECT * INTO v_bucket FROM storage.buckets WHERE id = p_bucket_id; IF NOT FOUND THEN RAISE EXCEPTION 'Bucket not found'; END IF; -- Verificar tamaño IF v_bucket.max_file_size_mb IS NOT NULL AND p_size_bytes > v_bucket.max_file_size_mb * 1024 * 1024 THEN RAISE EXCEPTION 'File size exceeds bucket limit'; END IF; -- Obtener path de la carpeta IF p_folder_id IS NOT NULL THEN SELECT path INTO v_path FROM storage.folders WHERE id = p_folder_id; v_path := v_path || p_name; ELSE v_path := '/' || p_name; END IF; -- Determinar categoría v_category := storage.get_file_category(p_mime_type); -- Crear archivo INSERT INTO storage.files ( tenant_id, bucket_id, folder_id, name, original_name, path, mime_type, extension, category, size_bytes, storage_key, metadata, uploaded_by ) VALUES ( p_tenant_id, p_bucket_id, p_folder_id, p_name, p_original_name, v_path, p_mime_type, LOWER(SPLIT_PART(p_name, '.', -1)), v_category, p_size_bytes, p_storage_key, p_metadata, p_uploaded_by ) RETURNING id INTO v_file_id; -- Actualizar estadísticas de carpeta IF p_folder_id IS NOT NULL THEN UPDATE storage.folders SET file_count = file_count + 1, total_size_bytes = total_size_bytes + p_size_bytes, updated_at = CURRENT_TIMESTAMP WHERE id = p_folder_id; END IF; -- Actualizar uso del tenant INSERT INTO storage.tenant_usage (tenant_id, bucket_id, file_count, total_size_bytes, month_year) VALUES (p_tenant_id, p_bucket_id, 1, p_size_bytes, TO_CHAR(CURRENT_DATE, 'YYYY-MM')) ON CONFLICT (tenant_id, bucket_id, month_year) DO UPDATE SET file_count = storage.tenant_usage.file_count + 1, total_size_bytes = storage.tenant_usage.total_size_bytes + p_size_bytes, updated_at = CURRENT_TIMESTAMP; RETURN v_file_id; END; $$ LANGUAGE plpgsql; -- Función para crear token de acceso CREATE OR REPLACE FUNCTION storage.create_access_token( p_file_id UUID, p_expires_in_hours INTEGER DEFAULT 24, p_permissions TEXT[] DEFAULT '{read}', p_max_downloads INTEGER DEFAULT NULL, p_created_by UUID DEFAULT NULL ) RETURNS TEXT AS $$ DECLARE v_token TEXT; v_tenant_id UUID; BEGIN -- Obtener tenant del archivo SELECT tenant_id INTO v_tenant_id FROM storage.files WHERE id = p_file_id; IF NOT FOUND THEN RAISE EXCEPTION 'File not found'; END IF; -- Generar token v_token := 'sat_' || encode(gen_random_bytes(32), 'hex'); -- Crear registro INSERT INTO storage.file_access_tokens ( file_id, tenant_id, token, permissions, max_downloads, expires_at, created_by ) VALUES ( p_file_id, v_tenant_id, v_token, p_permissions, p_max_downloads, CURRENT_TIMESTAMP + (p_expires_in_hours || ' hours')::INTERVAL, p_created_by ); RETURN v_token; END; $$ LANGUAGE plpgsql; -- Función para validar token de acceso CREATE OR REPLACE FUNCTION storage.validate_access_token( p_token VARCHAR(255), p_permission VARCHAR(20) DEFAULT 'read' ) RETURNS TABLE ( is_valid BOOLEAN, file_id UUID, tenant_id UUID, error_message TEXT ) AS $$ DECLARE v_token RECORD; BEGIN SELECT * INTO v_token FROM storage.file_access_tokens WHERE token = p_token; IF NOT FOUND THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token not found'::TEXT; RETURN; END IF; IF v_token.revoked_at IS NOT NULL THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token revoked'::TEXT; RETURN; END IF; IF v_token.expires_at < CURRENT_TIMESTAMP THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token expired'::TEXT; RETURN; END IF; IF NOT (p_permission = ANY(v_token.permissions)) THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Permission denied'::TEXT; RETURN; END IF; IF v_token.max_downloads IS NOT NULL AND p_permission = 'download' AND v_token.download_count >= v_token.max_downloads THEN RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Download limit reached'::TEXT; RETURN; END IF; -- Incrementar contador si es download IF p_permission = 'download' THEN UPDATE storage.file_access_tokens SET download_count = download_count + 1 WHERE id = v_token.id; END IF; RETURN QUERY SELECT TRUE, v_token.file_id, v_token.tenant_id, NULL::TEXT; END; $$ LANGUAGE plpgsql; -- Función para obtener uso del tenant CREATE OR REPLACE FUNCTION storage.get_tenant_usage(p_tenant_id UUID) RETURNS TABLE ( total_files BIGINT, total_size_bytes BIGINT, total_size_mb DECIMAL, usage_by_bucket JSONB, usage_by_category JSONB ) AS $$ BEGIN RETURN QUERY SELECT COALESCE(SUM(tu.file_count), 0)::BIGINT as total_files, COALESCE(SUM(tu.total_size_bytes), 0)::BIGINT as total_size_bytes, ROUND(COALESCE(SUM(tu.total_size_bytes), 0)::DECIMAL / 1024 / 1024, 2) as total_size_mb, jsonb_object_agg(b.name, tu.total_size_bytes) as usage_by_bucket, COALESCE( (SELECT jsonb_object_agg(category, cat_size) FROM ( SELECT f.category, SUM(f.size_bytes) as cat_size FROM storage.files f WHERE f.tenant_id = p_tenant_id AND f.status = 'active' GROUP BY f.category ) cats), '{}'::JSONB ) as usage_by_category FROM storage.tenant_usage tu JOIN storage.buckets b ON b.id = tu.bucket_id WHERE tu.tenant_id = p_tenant_id AND tu.month_year = TO_CHAR(CURRENT_DATE, 'YYYY-MM'); END; $$ LANGUAGE plpgsql STABLE; -- Función para limpiar archivos expirados CREATE OR REPLACE FUNCTION storage.cleanup_expired_files() RETURNS INTEGER AS $$ DECLARE deleted_count INTEGER := 0; v_bucket RECORD; BEGIN -- Procesar cada bucket con auto_delete_days FOR v_bucket IN SELECT * FROM storage.buckets WHERE auto_delete_days IS NOT NULL LOOP UPDATE storage.files SET status = 'deleted', deleted_at = CURRENT_TIMESTAMP WHERE bucket_id = v_bucket.id AND status = 'active' AND created_at < CURRENT_TIMESTAMP - (v_bucket.auto_delete_days || ' days')::INTERVAL; deleted_count := deleted_count + ROW_COUNT; END LOOP; -- Limpiar tokens expirados DELETE FROM storage.file_access_tokens WHERE expires_at < CURRENT_TIMESTAMP - INTERVAL '7 days'; -- Limpiar uploads abandonados DELETE FROM storage.uploads WHERE status IN ('pending', 'uploading') AND expires_at < CURRENT_TIMESTAMP; RETURN deleted_count; END; $$ LANGUAGE plpgsql; -- ===================== -- TRIGGERS -- ===================== CREATE OR REPLACE FUNCTION storage.update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_buckets_updated_at BEFORE UPDATE ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp(); CREATE TRIGGER trg_folders_updated_at BEFORE UPDATE ON storage.folders FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp(); CREATE TRIGGER trg_files_updated_at BEFORE UPDATE ON storage.files FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp(); CREATE TRIGGER trg_shares_updated_at BEFORE UPDATE ON storage.file_shares FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp(); -- ===================== -- SEED DATA: Buckets del Sistema -- ===================== INSERT INTO storage.buckets (name, description, bucket_type, max_file_size_mb, allowed_mime_types, is_system) VALUES ('avatars', 'Avatares de usuarios', 'public', 5, '{image/jpeg,image/png,image/gif,image/webp}', TRUE), ('logos', 'Logos de empresas', 'public', 10, '{image/jpeg,image/png,image/svg+xml,image/webp}', TRUE), ('documents', 'Documentos generales', 'private', 50, '{}', TRUE), ('invoices', 'Facturas y comprobantes', 'private', 20, '{application/pdf,image/jpeg,image/png}', TRUE), ('products', 'Imágenes de productos', 'public', 10, '{image/jpeg,image/png,image/webp}', TRUE), ('attachments', 'Archivos adjuntos', 'private', 25, '{}', TRUE), ('exports', 'Exportaciones de datos', 'protected', 500, '{application/zip,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet}', TRUE), ('backups', 'Respaldos', 'private', 1000, '{application/zip,application/gzip}', TRUE) ON CONFLICT (name) DO NOTHING; -- ===================== -- COMENTARIOS -- ===================== COMMENT ON TABLE storage.buckets IS 'Contenedores de almacenamiento configurables'; COMMENT ON TABLE storage.folders IS 'Estructura de carpetas virtuales por tenant'; COMMENT ON TABLE storage.files IS 'Archivos almacenados con metadata y versionamiento'; COMMENT ON TABLE storage.file_access_tokens IS 'Tokens de acceso temporal a archivos'; COMMENT ON TABLE storage.uploads IS 'Uploads en progreso (multipart/resumable)'; COMMENT ON TABLE storage.file_shares IS 'Configuración de archivos compartidos'; COMMENT ON TABLE storage.tenant_usage IS 'Uso de storage por tenant y bucket'; COMMENT ON FUNCTION storage.create_file IS 'Crea un registro de archivo con validaciones'; COMMENT ON FUNCTION storage.create_access_token IS 'Genera un token de acceso temporal'; COMMENT ON FUNCTION storage.validate_access_token IS 'Valida un token de acceso'; COMMENT ON FUNCTION storage.get_tenant_usage IS 'Obtiene estadísticas de uso de storage';