-- ===================================================== -- SCHEMA: projects -- PROPÓSITO: Gestión de proyectos, tareas, milestones -- MÓDULOS: MGN-011 (Proyectos Genéricos) -- FECHA: 2025-11-24 -- ===================================================== -- Crear schema CREATE SCHEMA IF NOT EXISTS projects; -- ===================================================== -- TYPES (ENUMs) -- ===================================================== CREATE TYPE projects.project_status AS ENUM ( 'draft', 'active', 'completed', 'cancelled', 'on_hold' ); CREATE TYPE projects.privacy_type AS ENUM ( 'public', 'private', 'followers' ); CREATE TYPE projects.task_status AS ENUM ( 'todo', 'in_progress', 'review', 'done', 'cancelled' ); CREATE TYPE projects.task_priority AS ENUM ( 'low', 'normal', 'high', 'urgent' ); -- COR-016: Tipos de recurrencia CREATE TYPE projects.recurrence_type AS ENUM ( 'daily', 'weekly', 'monthly', 'yearly', 'custom' ); CREATE TYPE projects.dependency_type AS ENUM ( 'finish_to_start', 'start_to_start', 'finish_to_finish', 'start_to_finish' ); CREATE TYPE projects.milestone_status AS ENUM ( 'pending', 'completed' ); -- ===================================================== -- TABLES -- ===================================================== -- Tabla: projects (Proyectos) CREATE TABLE projects.projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, -- Identificación name VARCHAR(255) NOT NULL, code VARCHAR(50), description TEXT, -- Responsables manager_id UUID REFERENCES auth.users(id), partner_id UUID REFERENCES core.partners(id), -- Cliente -- Analítica (1-1) analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Fechas date_start DATE, date_end DATE, -- Estado status projects.project_status NOT NULL DEFAULT 'draft', privacy projects.privacy_type NOT NULL DEFAULT 'public', -- Configuración allow_timesheets BOOLEAN DEFAULT TRUE, color VARCHAR(20), -- Color para UI -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), deleted_at TIMESTAMP, deleted_by UUID REFERENCES auth.users(id), CONSTRAINT uq_projects_code_company UNIQUE (company_id, code), CONSTRAINT chk_projects_dates CHECK (date_end IS NULL OR date_end >= date_start) ); -- Tabla: project_stages (Etapas de tareas) CREATE TABLE projects.project_stages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, project_id UUID REFERENCES projects.projects(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, sequence INTEGER NOT NULL DEFAULT 1, is_closed BOOLEAN DEFAULT FALSE, -- Etapa final fold BOOLEAN DEFAULT FALSE, -- Plegada en kanban -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_project_stages_sequence CHECK (sequence > 0) ); -- Tabla: tasks (Tareas) CREATE TABLE projects.tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, stage_id UUID REFERENCES projects.project_stages(id), -- Identificación name VARCHAR(255) NOT NULL, description TEXT, -- Asignación assigned_to UUID REFERENCES auth.users(id), partner_id UUID REFERENCES core.partners(id), -- Jerarquía parent_id UUID REFERENCES projects.tasks(id), -- Fechas date_start DATE, date_deadline DATE, -- Esfuerzo planned_hours DECIMAL(8, 2) DEFAULT 0, actual_hours DECIMAL(8, 2) DEFAULT 0, progress INTEGER DEFAULT 0, -- 0-100 -- Prioridad y estado priority projects.task_priority NOT NULL DEFAULT 'normal', status projects.task_status NOT NULL DEFAULT 'todo', -- Milestone milestone_id UUID REFERENCES projects.milestones(id), -- COR-016: Recurrencia is_recurring BOOLEAN DEFAULT FALSE, recurrence_type projects.recurrence_type, recurrence_interval INTEGER DEFAULT 1, -- Cada N dias/semanas/meses recurrence_weekdays INTEGER[] DEFAULT '{}', -- 0=Lunes, 6=Domingo recurrence_month_day INTEGER, -- Dia del mes (1-31) recurrence_end_type VARCHAR(20) DEFAULT 'never', -- never, count, date recurrence_count INTEGER, -- Numero de repeticiones recurrence_end_date DATE, -- Fecha fin de recurrencia recurrence_parent_id UUID REFERENCES projects.tasks(id), -- Tarea padre recurrente last_recurrence_date DATE, next_recurrence_date DATE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), deleted_at TIMESTAMP, deleted_by UUID REFERENCES auth.users(id), CONSTRAINT chk_tasks_no_self_parent CHECK (id != parent_id), CONSTRAINT chk_tasks_dates CHECK (date_deadline IS NULL OR date_deadline >= date_start), CONSTRAINT chk_tasks_planned_hours CHECK (planned_hours >= 0), CONSTRAINT chk_tasks_actual_hours CHECK (actual_hours >= 0), CONSTRAINT chk_tasks_progress CHECK (progress >= 0 AND progress <= 100), CONSTRAINT chk_tasks_recurrence_interval CHECK (recurrence_interval IS NULL OR recurrence_interval > 0), CONSTRAINT chk_tasks_recurrence_end_type CHECK (recurrence_end_type IN ('never', 'count', 'date')) ); -- Tabla: milestones (Hitos) CREATE TABLE projects.milestones ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, target_date DATE NOT NULL, status projects.milestone_status NOT NULL DEFAULT 'pending', -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), completed_at TIMESTAMP, completed_by UUID REFERENCES auth.users(id) ); -- Tabla: task_dependencies (Dependencias entre tareas) CREATE TABLE projects.task_dependencies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, depends_on_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, dependency_type projects.dependency_type NOT NULL DEFAULT 'finish_to_start', -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_task_dependencies UNIQUE (task_id, depends_on_id), CONSTRAINT chk_task_dependencies_no_self CHECK (task_id != depends_on_id) ); -- Tabla: task_tags (Etiquetas de tareas) CREATE TABLE projects.task_tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, color VARCHAR(20), -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_task_tags_name_tenant UNIQUE (tenant_id, name) ); -- Tabla: task_tag_assignments (Many-to-many) CREATE TABLE projects.task_tag_assignments ( task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES projects.task_tags(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (task_id, tag_id) ); -- COR-017: Tabla para asignacion multiple de usuarios CREATE TABLE projects.task_assignees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- Rol del usuario en la tarea role VARCHAR(50) DEFAULT 'assignee', -- assignee, reviewer, observer -- Control is_primary BOOLEAN DEFAULT FALSE, -- Usuario principal -- Auditoria assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, assigned_by UUID REFERENCES auth.users(id), CONSTRAINT uq_task_assignees UNIQUE (task_id, user_id) ); -- Indice para task_assignees CREATE INDEX idx_task_assignees_task_id ON projects.task_assignees(task_id); CREATE INDEX idx_task_assignees_user_id ON projects.task_assignees(user_id); CREATE INDEX idx_task_assignees_primary ON projects.task_assignees(task_id, is_primary) WHERE is_primary = TRUE; -- RLS para task_assignees ALTER TABLE projects.task_assignees ENABLE ROW LEVEL SECURITY; -- Politica basada en el task (hereda de la tarea) CREATE POLICY task_assignees_via_task ON projects.task_assignees USING ( task_id IN ( SELECT id FROM projects.tasks WHERE tenant_id = get_current_tenant_id() ) ); -- Tabla: timesheets (Registro de horas) CREATE TABLE projects.timesheets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, task_id UUID REFERENCES projects.tasks(id) ON DELETE SET NULL, project_id UUID NOT NULL REFERENCES projects.projects(id), employee_id UUID, -- FK a hr.employees (se crea después) user_id UUID REFERENCES auth.users(id), -- Fecha y horas date DATE NOT NULL, hours DECIMAL(8, 2) NOT NULL, -- Descripción description TEXT, -- Analítica analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica analytic_line_id UUID REFERENCES analytics.analytic_lines(id), -- Línea analítica generada -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT chk_timesheets_hours CHECK (hours > 0) ); -- Tabla: task_checklists (Checklists dentro de tareas) CREATE TABLE projects.task_checklists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, item_name VARCHAR(255) NOT NULL, is_completed BOOLEAN DEFAULT FALSE, sequence INTEGER DEFAULT 1, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, completed_by UUID REFERENCES auth.users(id) ); -- Tabla: project_templates (Plantillas de proyectos) CREATE TABLE projects.project_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, -- Template data (JSON con estructura de proyecto, tareas, etc.) template_data JSONB DEFAULT '{}', -- Control active BOOLEAN DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_project_templates_name_tenant UNIQUE (tenant_id, name) ); -- ===================================================== -- INDICES -- ===================================================== -- Projects CREATE INDEX idx_projects_tenant_id ON projects.projects(tenant_id); CREATE INDEX idx_projects_company_id ON projects.projects(company_id); CREATE INDEX idx_projects_manager_id ON projects.projects(manager_id); CREATE INDEX idx_projects_partner_id ON projects.projects(partner_id); CREATE INDEX idx_projects_analytic_account_id ON projects.projects(analytic_account_id); CREATE INDEX idx_projects_status ON projects.projects(status); -- Project Stages CREATE INDEX idx_project_stages_tenant_id ON projects.project_stages(tenant_id); CREATE INDEX idx_project_stages_project_id ON projects.project_stages(project_id); CREATE INDEX idx_project_stages_sequence ON projects.project_stages(sequence); -- Tasks CREATE INDEX idx_tasks_tenant_id ON projects.tasks(tenant_id); CREATE INDEX idx_tasks_project_id ON projects.tasks(project_id); CREATE INDEX idx_tasks_stage_id ON projects.tasks(stage_id); CREATE INDEX idx_tasks_assigned_to ON projects.tasks(assigned_to); CREATE INDEX idx_tasks_parent_id ON projects.tasks(parent_id); CREATE INDEX idx_tasks_milestone_id ON projects.tasks(milestone_id); CREATE INDEX idx_tasks_status ON projects.tasks(status); CREATE INDEX idx_tasks_priority ON projects.tasks(priority); CREATE INDEX idx_tasks_date_deadline ON projects.tasks(date_deadline); CREATE INDEX idx_tasks_is_recurring ON projects.tasks(is_recurring) WHERE is_recurring = TRUE; -- COR-016 CREATE INDEX idx_tasks_recurrence_parent ON projects.tasks(recurrence_parent_id); -- COR-016 CREATE INDEX idx_tasks_next_recurrence ON projects.tasks(next_recurrence_date); -- COR-016 -- Milestones CREATE INDEX idx_milestones_tenant_id ON projects.milestones(tenant_id); CREATE INDEX idx_milestones_project_id ON projects.milestones(project_id); CREATE INDEX idx_milestones_status ON projects.milestones(status); CREATE INDEX idx_milestones_target_date ON projects.milestones(target_date); -- Task Dependencies CREATE INDEX idx_task_dependencies_task_id ON projects.task_dependencies(task_id); CREATE INDEX idx_task_dependencies_depends_on_id ON projects.task_dependencies(depends_on_id); -- Timesheets CREATE INDEX idx_timesheets_tenant_id ON projects.timesheets(tenant_id); CREATE INDEX idx_timesheets_company_id ON projects.timesheets(company_id); CREATE INDEX idx_timesheets_task_id ON projects.timesheets(task_id); CREATE INDEX idx_timesheets_project_id ON projects.timesheets(project_id); CREATE INDEX idx_timesheets_employee_id ON projects.timesheets(employee_id); CREATE INDEX idx_timesheets_date ON projects.timesheets(date); CREATE INDEX idx_timesheets_analytic_account_id ON projects.timesheets(analytic_account_id) WHERE analytic_account_id IS NOT NULL; -- Task Checklists CREATE INDEX idx_task_checklists_task_id ON projects.task_checklists(task_id); -- Project Templates CREATE INDEX idx_project_templates_tenant_id ON projects.project_templates(tenant_id); CREATE INDEX idx_project_templates_active ON projects.project_templates(active) WHERE active = TRUE; -- ===================================================== -- FUNCTIONS -- ===================================================== -- Función: update_task_actual_hours CREATE OR REPLACE FUNCTION projects.update_task_actual_hours() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'DELETE' THEN UPDATE projects.tasks SET actual_hours = ( SELECT COALESCE(SUM(hours), 0) FROM projects.timesheets WHERE task_id = OLD.task_id ) WHERE id = OLD.task_id; ELSE UPDATE projects.tasks SET actual_hours = ( SELECT COALESCE(SUM(hours), 0) FROM projects.timesheets WHERE task_id = NEW.task_id ) WHERE id = NEW.task_id; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION projects.update_task_actual_hours IS 'Actualiza las horas reales de una tarea al cambiar timesheets'; -- Función: check_task_dependencies CREATE OR REPLACE FUNCTION projects.check_task_dependencies(p_task_id UUID) RETURNS BOOLEAN AS $$ DECLARE v_unfinished_count INTEGER; BEGIN SELECT COUNT(*) INTO v_unfinished_count FROM projects.task_dependencies td JOIN projects.tasks t ON td.depends_on_id = t.id WHERE td.task_id = p_task_id AND t.status != 'done'; RETURN v_unfinished_count = 0; END; $$ LANGUAGE plpgsql STABLE; COMMENT ON FUNCTION projects.check_task_dependencies IS 'Verifica si todas las dependencias de una tarea están completadas'; -- Función: prevent_circular_dependencies CREATE OR REPLACE FUNCTION projects.prevent_circular_dependencies() RETURNS TRIGGER AS $$ BEGIN -- Verificar si crear esta dependencia crea un ciclo IF EXISTS ( WITH RECURSIVE dep_chain AS ( SELECT task_id, depends_on_id FROM projects.task_dependencies WHERE task_id = NEW.depends_on_id UNION ALL SELECT td.task_id, td.depends_on_id FROM projects.task_dependencies td JOIN dep_chain dc ON td.task_id = dc.depends_on_id ) SELECT 1 FROM dep_chain WHERE depends_on_id = NEW.task_id ) THEN RAISE EXCEPTION 'Cannot create circular dependency'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION projects.prevent_circular_dependencies IS 'Previene la creación de dependencias circulares entre tareas'; -- COR-016: Funcion para crear tarea recurrente CREATE OR REPLACE FUNCTION projects.create_next_recurring_task(p_task_id UUID) RETURNS UUID AS $$ DECLARE v_task RECORD; v_new_task_id UUID; v_next_date DATE; v_occurrence_count INTEGER; BEGIN -- Obtener tarea original SELECT * INTO v_task FROM projects.tasks WHERE id = p_task_id; IF NOT FOUND THEN RAISE EXCEPTION 'Task % not found', p_task_id; END IF; IF NOT v_task.is_recurring THEN RAISE EXCEPTION 'Task % is not recurring', p_task_id; END IF; -- Calcular siguiente fecha v_next_date := COALESCE(v_task.next_recurrence_date, v_task.date_deadline, CURRENT_DATE); CASE v_task.recurrence_type WHEN 'daily' THEN v_next_date := v_next_date + (v_task.recurrence_interval || ' days')::INTERVAL; WHEN 'weekly' THEN v_next_date := v_next_date + (v_task.recurrence_interval * 7 || ' days')::INTERVAL; WHEN 'monthly' THEN v_next_date := v_next_date + (v_task.recurrence_interval || ' months')::INTERVAL; WHEN 'yearly' THEN v_next_date := v_next_date + (v_task.recurrence_interval || ' years')::INTERVAL; ELSE v_next_date := v_next_date + (v_task.recurrence_interval || ' days')::INTERVAL; END CASE; -- Verificar si debe crear nueva tarea IF v_task.recurrence_end_type = 'date' AND v_next_date > v_task.recurrence_end_date THEN RETURN NULL; -- Fin de recurrencia por fecha END IF; IF v_task.recurrence_end_type = 'count' THEN SELECT COUNT(*) INTO v_occurrence_count FROM projects.tasks WHERE recurrence_parent_id = COALESCE(v_task.recurrence_parent_id, v_task.id); IF v_occurrence_count >= v_task.recurrence_count THEN RETURN NULL; -- Fin de recurrencia por conteo END IF; END IF; -- Crear nueva tarea INSERT INTO projects.tasks ( tenant_id, project_id, stage_id, name, description, assigned_to, partner_id, parent_id, date_start, date_deadline, planned_hours, priority, status, milestone_id, is_recurring, recurrence_type, recurrence_interval, recurrence_weekdays, recurrence_month_day, recurrence_end_type, recurrence_count, recurrence_end_date, recurrence_parent_id, created_by ) VALUES ( v_task.tenant_id, v_task.project_id, v_task.stage_id, v_task.name, v_task.description, v_task.assigned_to, v_task.partner_id, v_task.parent_id, v_next_date, v_next_date, v_task.planned_hours, v_task.priority, 'todo', v_task.milestone_id, v_task.is_recurring, v_task.recurrence_type, v_task.recurrence_interval, v_task.recurrence_weekdays, v_task.recurrence_month_day, v_task.recurrence_end_type, v_task.recurrence_count, v_task.recurrence_end_date, COALESCE(v_task.recurrence_parent_id, v_task.id), v_task.created_by ) RETURNING id INTO v_new_task_id; -- Actualizar tarea original UPDATE projects.tasks SET last_recurrence_date = CURRENT_DATE, next_recurrence_date = v_next_date WHERE id = p_task_id; -- Copiar asignaciones multiples (COR-017) INSERT INTO projects.task_assignees (task_id, user_id, role, is_primary, assigned_by) SELECT v_new_task_id, user_id, role, is_primary, assigned_by FROM projects.task_assignees WHERE task_id = p_task_id; RETURN v_new_task_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION projects.create_next_recurring_task IS 'COR-016: Crea la siguiente ocurrencia de una tarea recurrente'; -- ===================================================== -- TRIGGERS -- ===================================================== CREATE TRIGGER trg_projects_updated_at BEFORE UPDATE ON projects.projects FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_tasks_updated_at BEFORE UPDATE ON projects.tasks FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_milestones_updated_at BEFORE UPDATE ON projects.milestones FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_timesheets_updated_at BEFORE UPDATE ON projects.timesheets FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_project_templates_updated_at BEFORE UPDATE ON projects.project_templates FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Trigger: Actualizar horas reales de tarea al cambiar timesheet CREATE TRIGGER trg_timesheets_update_task_hours AFTER INSERT OR UPDATE OR DELETE ON projects.timesheets FOR EACH ROW EXECUTE FUNCTION projects.update_task_actual_hours(); -- Trigger: Prevenir dependencias circulares CREATE TRIGGER trg_task_dependencies_prevent_circular BEFORE INSERT ON projects.task_dependencies FOR EACH ROW EXECUTE FUNCTION projects.prevent_circular_dependencies(); -- ===================================================== -- TRACKING AUTOMÁTICO (mail.thread pattern) -- ===================================================== -- Trigger: Tracking automático para proyectos CREATE TRIGGER track_project_changes AFTER INSERT OR UPDATE OR DELETE ON projects.projects FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); COMMENT ON TRIGGER track_project_changes ON projects.projects IS 'Registra automáticamente cambios en proyectos (estado, nombre, responsable, fechas)'; -- ===================================================== -- ROW LEVEL SECURITY (RLS) -- ===================================================== ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.project_stages ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.tasks ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.milestones ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.task_tags ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.timesheets ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.project_templates ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_projects ON projects.projects USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_project_stages ON projects.project_stages USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_tasks ON projects.tasks USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_milestones ON projects.milestones USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_task_tags ON projects.task_tags USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_timesheets ON projects.timesheets USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_project_templates ON projects.project_templates USING (tenant_id = get_current_tenant_id()); -- ===================================================== -- COMENTARIOS -- ===================================================== COMMENT ON SCHEMA projects IS 'Schema de gestión de proyectos, tareas y timesheets'; COMMENT ON TABLE projects.projects IS 'Proyectos genéricos con tracking de tareas'; COMMENT ON TABLE projects.project_stages IS 'Etapas/columnas para tablero Kanban de tareas'; COMMENT ON TABLE projects.tasks IS 'Tareas dentro de proyectos con jerarquía y dependencias'; COMMENT ON TABLE projects.milestones IS 'Hitos importantes en proyectos'; COMMENT ON TABLE projects.task_dependencies IS 'Dependencias entre tareas (precedencia)'; COMMENT ON TABLE projects.task_tags IS 'Etiquetas para categorizar tareas'; COMMENT ON TABLE projects.timesheets IS 'Registro de horas trabajadas en tareas'; COMMENT ON TABLE projects.task_checklists IS 'Checklists dentro de tareas'; COMMENT ON TABLE projects.project_templates IS 'Plantillas de proyectos para reutilización'; COMMENT ON TABLE projects.task_assignees IS 'COR-017: Asignacion multiple de usuarios a tareas'; -- ===================================================== -- COR-032: Project Updates -- Equivalente a project.update de Odoo -- ===================================================== CREATE TYPE projects.update_status AS ENUM ('on_track', 'at_risk', 'off_track', 'done'); CREATE TABLE projects.project_updates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, status projects.update_status DEFAULT 'on_track', progress INTEGER CHECK (progress >= 0 AND progress <= 100), date DATE NOT NULL DEFAULT CURRENT_DATE, description TEXT, user_id UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_project_updates_tenant ON projects.project_updates(tenant_id); CREATE INDEX idx_project_updates_project ON projects.project_updates(project_id); CREATE INDEX idx_project_updates_date ON projects.project_updates(date DESC); -- RLS ALTER TABLE projects.project_updates ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_project_updates ON projects.project_updates USING (tenant_id = get_current_tenant_id()); COMMENT ON TABLE projects.project_updates IS 'COR-032: Project updates - Equivalent to project.update'; -- ===================================================== -- COR-056: Project Collaborators -- Equivalente a project.collaborator de Odoo -- ===================================================== CREATE TABLE projects.collaborators ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, partner_id UUID REFERENCES core.partners(id) ON DELETE CASCADE, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Permisos can_read BOOLEAN DEFAULT TRUE, can_write BOOLEAN DEFAULT FALSE, -- Auditoria created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, invited_by UUID REFERENCES auth.users(id), CONSTRAINT chk_collaborator_partner_or_user CHECK ( (partner_id IS NOT NULL AND user_id IS NULL) OR (partner_id IS NULL AND user_id IS NOT NULL) ) ); CREATE INDEX idx_project_collaborators_project ON projects.collaborators(project_id); CREATE INDEX idx_project_collaborators_partner ON projects.collaborators(partner_id); CREATE INDEX idx_project_collaborators_user ON projects.collaborators(user_id); -- RLS basado en proyecto ALTER TABLE projects.collaborators ENABLE ROW LEVEL SECURITY; CREATE POLICY collaborators_via_project ON projects.collaborators USING ( project_id IN ( SELECT id FROM projects.projects WHERE tenant_id = get_current_tenant_id() ) ); COMMENT ON TABLE projects.collaborators IS 'COR-056: Project collaborators for external access'; -- ===================================================== -- COR-057: Project Additional Fields -- Campos adicionales para alinear con Odoo -- ===================================================== -- Agregar campos a projects ALTER TABLE projects.projects ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10, ADD COLUMN IF NOT EXISTS favorite BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS is_favorite BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS tag_ids UUID[] DEFAULT '{}', ADD COLUMN IF NOT EXISTS last_update_status VARCHAR(20), -- on_track, at_risk, off_track ADD COLUMN IF NOT EXISTS last_update_color INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS task_count INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS open_task_count INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS closed_task_count INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS rating_percentage DECIMAL(5,2) DEFAULT 0, ADD COLUMN IF NOT EXISTS rating_count INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS alias_name VARCHAR(100), -- Email alias ADD COLUMN IF NOT EXISTS alias_model VARCHAR(100) DEFAULT 'project.task'; -- Agregar campos a tasks ALTER TABLE projects.tasks ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10, ADD COLUMN IF NOT EXISTS color INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS kanban_state VARCHAR(20) DEFAULT 'normal', -- normal, blocked, done ADD COLUMN IF NOT EXISTS legend_blocked VARCHAR(255), ADD COLUMN IF NOT EXISTS legend_done VARCHAR(255), ADD COLUMN IF NOT EXISTS legend_normal VARCHAR(255), ADD COLUMN IF NOT EXISTS working_hours_open DECIMAL(10,2) DEFAULT 0, ADD COLUMN IF NOT EXISTS working_hours_close DECIMAL(10,2) DEFAULT 0, ADD COLUMN IF NOT EXISTS working_days_open DECIMAL(10,2) DEFAULT 0, ADD COLUMN IF NOT EXISTS working_days_close DECIMAL(10,2) DEFAULT 0, ADD COLUMN IF NOT EXISTS rating_ids UUID[] DEFAULT '{}', ADD COLUMN IF NOT EXISTS email_cc VARCHAR(255), ADD COLUMN IF NOT EXISTS displayed_image_id UUID; CREATE INDEX idx_projects_sequence ON projects.projects(sequence); CREATE INDEX idx_projects_favorite ON projects.projects(is_favorite) WHERE is_favorite = TRUE; CREATE INDEX idx_tasks_sequence ON projects.tasks(project_id, sequence); CREATE INDEX idx_tasks_kanban_state ON projects.tasks(kanban_state); -- ===================================================== -- COR-058: Task Compute Functions -- Funciones para calcular campos de tareas -- ===================================================== -- Funcion para actualizar conteo de tareas en proyecto CREATE OR REPLACE FUNCTION projects.update_project_task_count() RETURNS TRIGGER AS $$ DECLARE v_project_id UUID; BEGIN IF TG_OP = 'DELETE' THEN v_project_id := OLD.project_id; ELSE v_project_id := NEW.project_id; END IF; UPDATE projects.projects SET task_count = ( SELECT COUNT(*) FROM projects.tasks WHERE project_id = v_project_id AND deleted_at IS NULL ), open_task_count = ( SELECT COUNT(*) FROM projects.tasks WHERE project_id = v_project_id AND status NOT IN ('done', 'cancelled') AND deleted_at IS NULL ), closed_task_count = ( SELECT COUNT(*) FROM projects.tasks WHERE project_id = v_project_id AND status IN ('done', 'cancelled') AND deleted_at IS NULL ) WHERE id = v_project_id; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_tasks_update_project_count AFTER INSERT OR UPDATE OR DELETE ON projects.tasks FOR EACH ROW EXECUTE FUNCTION projects.update_project_task_count(); COMMENT ON FUNCTION projects.update_project_task_count IS 'COR-058: Update task counts in project'; -- ===================================================== -- COR-059: Project Rating -- Soporte basico para ratings de proyectos/tareas -- ===================================================== CREATE TABLE projects.ratings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Modelo y recurso evaluado res_model VARCHAR(100) NOT NULL, res_id UUID NOT NULL, -- Rating (1-5 estrellas o 1-10) rating DECIMAL(3,1) NOT NULL CHECK (rating >= 0 AND rating <= 10), -- Comentarios feedback TEXT, -- Partner que evalua partner_id UUID REFERENCES core.partners(id), -- Auditoria create_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_published BOOLEAN DEFAULT TRUE, -- Estado consumed BOOLEAN DEFAULT FALSE ); CREATE INDEX idx_project_ratings_tenant ON projects.ratings(tenant_id); CREATE INDEX idx_project_ratings_model_id ON projects.ratings(res_model, res_id); CREATE INDEX idx_project_ratings_partner ON projects.ratings(partner_id); -- RLS ALTER TABLE projects.ratings ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_project_ratings ON projects.ratings USING (tenant_id = get_current_tenant_id()); COMMENT ON TABLE projects.ratings IS 'COR-059: Project and task ratings'; -- ===================================================== -- COR-060: Burndown Chart Data -- Datos para graficos de burndown -- ===================================================== CREATE TABLE projects.burndown_chart_data ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, date DATE NOT NULL, -- Metricas total_tasks INTEGER DEFAULT 0, completed_tasks INTEGER DEFAULT 0, remaining_tasks INTEGER DEFAULT 0, total_hours DECIMAL(10,2) DEFAULT 0, completed_hours DECIMAL(10,2) DEFAULT 0, remaining_hours DECIMAL(10,2) DEFAULT 0, -- Auditoria created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_id, date) ); CREATE INDEX idx_burndown_project_date ON projects.burndown_chart_data(project_id, date DESC); COMMENT ON TABLE projects.burndown_chart_data IS 'COR-060: Burndown chart historical data'; -- Funcion para generar snapshot de burndown CREATE OR REPLACE FUNCTION projects.generate_burndown_snapshot(p_project_id UUID) RETURNS UUID AS $$ DECLARE v_snapshot_id UUID; v_total_tasks INTEGER; v_completed_tasks INTEGER; v_total_hours DECIMAL; v_completed_hours DECIMAL; BEGIN -- Calcular metricas SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'done'), COALESCE(SUM(planned_hours), 0), COALESCE(SUM(actual_hours), 0) INTO v_total_tasks, v_completed_tasks, v_total_hours, v_completed_hours FROM projects.tasks WHERE project_id = p_project_id AND deleted_at IS NULL; -- Insertar o actualizar snapshot INSERT INTO projects.burndown_chart_data ( project_id, date, total_tasks, completed_tasks, remaining_tasks, total_hours, completed_hours, remaining_hours ) VALUES ( p_project_id, CURRENT_DATE, v_total_tasks, v_completed_tasks, v_total_tasks - v_completed_tasks, v_total_hours, v_completed_hours, v_total_hours - v_completed_hours ) ON CONFLICT (project_id, date) DO UPDATE SET total_tasks = EXCLUDED.total_tasks, completed_tasks = EXCLUDED.completed_tasks, remaining_tasks = EXCLUDED.remaining_tasks, total_hours = EXCLUDED.total_hours, completed_hours = EXCLUDED.completed_hours, remaining_hours = EXCLUDED.remaining_hours RETURNING id INTO v_snapshot_id; RETURN v_snapshot_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION projects.generate_burndown_snapshot IS 'COR-060: Generate daily burndown chart snapshot'; -- ===================================================== -- FIN DEL SCHEMA PROJECTS -- =====================================================