erp-core-database-v2/ddl/13-storage.sql
rckrdmrd 5043a640e4 refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:32 -06:00

737 lines
25 KiB
PL/PgSQL

-- =============================================================
-- 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';