- 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>
737 lines
25 KiB
PL/PgSQL
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';
|