# Sprint 4 — Etapa 3: Migración de actividades, gateways y recursos globales ## Contexto InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind. Etapas 1 y 2 completadas: auth con Google, rutas protegidas, procesos/grupos/settings en Supabase. Esta etapa migra actividades, gateways y recursos de Dexie a Supabase. El cambio más significativo: **recursos pasan de ser por-proceso a un catálogo global del equipo**. Al terminar esta etapa, Dexie solo retiene **simulaciones** (Etapa 4). --- ## Prerequisitos — verificar antes de empezar - Scripts 001-004 ejecutados en Supabase (de Etapas 1 y 2) - `src/auth/AuthContext.tsx` con `useAuth()` funcionando - `src/persistence/supabase/` con process-repo, group-repo, settings-repo --- ## Alcance estricto — qué entra en esta etapa 1. **Scripts SQL** para `resources`, `activities`, `activity_resource_assignments`, `gateways` 2. **Nuevos repos Supabase** para las cuatro entidades 3. **Actualizar `process-store.ts`** para usar repos Supabase 4. **Actualizar `useReportData.ts`** para cargar recursos desde Supabase 5. **Actualizar `ResourcesPanel.tsx`** mínimamente: crear recursos sin `processId` 6. **Actualizar `process-repo.ts` delete**: eliminar limpieza Dexie de activities/gateways/resources (pasan a CASCADE en Supabase); conservar limpieza Dexie solo para simulaciones 7. **Modificar tipo `Resource`** en `src/domain/types.ts`: hacer `processId` opcional (`processId?: UUID`) ## Qué NO entra en esta etapa - Migración de simulaciones → Etapa 4 - Eliminar Dexie del proyecto → Etapa 4 - UX de catálogo global de recursos (selector, panel dedicado) → Etapa 7 - Tabla `user_groups`, `process_access` → Etapa 5 - Avatar / "Actualizado por" en UI → Etapa 6 - Cambiar ningún otro tipo en `src/domain/types.ts` --- ## Scripts SQL — crear en `supabase/migrations/` ### `005_create_resources.sql` ```sql -- Catálogo global de recursos — sin process_id CREATE TABLE IF NOT EXISTS public.resources ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, type text NOT NULL CHECK (type IN ('role','person','system','equipment','supply')), cost_per_hour numeric NOT NULL DEFAULT 0, created_by uuid REFERENCES public.users(id), updated_by uuid REFERENCES public.users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE public.resources ENABLE ROW LEVEL SECURITY; -- Todos los autenticados pueden leer y escribir recursos (catálogo compartido) CREATE POLICY "all_resources" ON public.resources FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL); ``` ### `006_create_activities.sql` ```sql CREATE TABLE IF NOT EXISTS public.activities ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE, bpmn_element_id text NOT NULL, name text NOT NULL DEFAULT '', type text NOT NULL DEFAULT 'task' CHECK (type IN ('task','subprocess')), direct_cost_fixed numeric NOT NULL DEFAULT 0, execution_time_minutes numeric NOT NULL DEFAULT 0, automatable boolean NOT NULL DEFAULT false, automated_cost_fixed numeric NOT NULL DEFAULT 0, automated_time_minutes numeric NOT NULL DEFAULT 0, created_by uuid REFERENCES public.users(id), updated_by uuid REFERENCES public.users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS activities_process_bpmn_idx ON public.activities(process_id, bpmn_element_id); ALTER TABLE public.activities ENABLE ROW LEVEL SECURITY; CREATE POLICY "all_activities" ON public.activities FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL); ``` ### `007_create_activity_resource_assignments.sql` ```sql -- Reemplaza los arrays embebidos assignedResources / automatedAssignedResources CREATE TABLE IF NOT EXISTS public.activity_resource_assignments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES public.activities(id) ON DELETE CASCADE, resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE, utilization_minutes numeric NOT NULL DEFAULT 0, units numeric NOT NULL DEFAULT 1, scenario text NOT NULL DEFAULT 'actual' CHECK (scenario IN ('actual','automated')) ); ALTER TABLE public.activity_resource_assignments ENABLE ROW LEVEL SECURITY; CREATE POLICY "all_assignments" ON public.activity_resource_assignments FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL); ``` ### `008_create_gateways.sql` ```sql CREATE TABLE IF NOT EXISTS public.gateways ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE, bpmn_element_id text NOT NULL, gateway_type text NOT NULL CHECK (gateway_type IN ('exclusive','parallel','inclusive','event-based')), branches jsonb NOT NULL DEFAULT '[]', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS gateways_process_bpmn_idx ON public.gateways(process_id, bpmn_element_id); ALTER TABLE public.gateways ENABLE ROW LEVEL SECURITY; CREATE POLICY "all_gateways" ON public.gateways FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL); ``` **Ejecutar en orden (005 → 006 → 007 → 008) en Supabase SQL Editor antes de continuar. Parar si alguno falla.** --- ## Modificación de tipo TypeScript — `src/domain/types.ts` Hacer `processId` opcional en la interfaz `Resource`: ```typescript // ANTES: export interface Resource { id: UUID processId: UUID // ← campo requerido name: string type: ResourceType costPerHour: number } // DESPUÉS: export interface Resource { id: UUID processId?: UUID // ← opcional (backward compat con tests Dexie existentes) name: string type: ResourceType costPerHour: number } ``` **Solo este cambio en `types.ts`**. No modificar ninguna otra interfaz. --- ## Nuevos repositorios — `src/persistence/supabase/` ### `resource-repo.ts` Catálogo global — no filtra por proceso. ```typescript export const supabaseResourceRepo = { async save(resource: Resource, userId: string): Promise // upsert: { id, name, type, cost_per_hour, updated_by: userId, updated_at: now() } // en insert: también created_by: userId // NO escribir processId en la DB async getAll(): Promise // SELECT * FROM resources ORDER BY name // from_db: { id, name, type: row.type, costPerHour: row.cost_per_hour } // processId NO se popula en el objeto retornado (queda undefined) async delete(resourceId: string): Promise // DELETE FROM resources WHERE id = resourceId } ``` ### `activity-repo.ts` Las actividades tienen asignaciones embebidas en el tipo TypeScript pero normalizadas en la DB. ```typescript export const supabaseActivityRepo = { async save(activity: Activity, userId: string): Promise // 1. Upsert en activities (sin los arrays de recursos) // 2. DELETE FROM activity_resource_assignments WHERE activity_id = activity.id // 3. INSERT assignments de activity.assignedResources (scenario='actual') // 4. INSERT assignments de activity.automatedAssignedResources (scenario='automated') async saveMany(activities: Activity[], userId: string): Promise // Loop de save() — performance no crítica para import async getByProcess(processId: string): Promise // SELECT activities.*, activity_resource_assignments(*) WHERE process_id = processId // Reconstruir Activity con: // assignedResources: assignments.filter(a => a.scenario === 'actual').map(toAssignment) // automatedAssignedResources: assignments.filter(a => a.scenario === 'automated').map(toAssignment) async getByBpmnElementId(processId: string, bpmnElementId: string): Promise // SELECT con WHERE process_id = ? AND bpmn_element_id = ? + join assignments async deleteByProcess(processId: string): Promise // DELETE WHERE process_id = processId (assignments se eliminan por CASCADE) } ``` **Mapeo Activity TypeScript ↔ PostgreSQL:** | TypeScript | PostgreSQL | |---|---| | `id` | `id` | | `processId` | `process_id` | | `bpmnElementId` | `bpmn_element_id` | | `name` | `name` | | `type` | `type` | | `directCostFixed` | `direct_cost_fixed` | | `executionTimeMinutes` | `execution_time_minutes` | | `automatable` | `automatable` | | `automatedCostFixed` | `automated_cost_fixed` | | `automatedTimeMinutes` | `automated_time_minutes` | | `assignedResources` | via `activity_resource_assignments` WHERE scenario='actual' | | `automatedAssignedResources` | via `activity_resource_assignments` WHERE scenario='automated' | ### `gateway-repo.ts` ```typescript export const supabaseGatewayRepo = { async save(gateway: GatewayConfig): Promise // upsert: { id, process_id, bpmn_element_id, gateway_type, branches: JSON.stringify(gateway.branches) } async saveMany(gateways: GatewayConfig[]): Promise // Loop de save() async getByProcess(processId: string): Promise // SELECT WHERE process_id = processId // from_db: branches: JSON.parse(row.branches) as GatewayBranch[] async deleteByProcess(processId: string): Promise // DELETE WHERE process_id (CASCADE ya lo maneja, pero mantener para explicitez) } ``` **Mapeo GatewayConfig:** | TypeScript | PostgreSQL | |---|---| | `id` | `id` | | `processId` | `process_id` | | `bpmnElementId` | `bpmn_element_id` | | `gatewayType` | `gateway_type` | | `branches` (array) | `branches` (jsonb) — `JSON.stringify` / `JSON.parse` | --- ## Actualización de stores y componentes ### `src/store/process-store.ts` Reemplazar todas las llamadas a repos Dexie con los nuevos repos Supabase. **`loadProcess`**: cambiar `resourceRepo.getByProcess(processId)` → `supabaseResourceRepo.getAll()` Los recursos se cargan globalmente. El store los tiene todos disponibles para el workspace. **`setCurrentProcess`**: igual — cargar recursos con `getAll()` **`updateActivity`**: `activityRepo.save(activity)` → `supabaseActivityRepo.save(activity, userId)` **`updateGateway`**: `gatewayRepo.save(gateway)` → `supabaseGatewayRepo.save(gateway)` (gateways no tienen userId — no hay created_by/updated_by en el schema de gateways) **`addResource` / `updateResource`**: `resourceRepo.save(resource)` → `supabaseResourceRepo.save(resource, userId)` **Al crear un recurso, NO incluir `processId`** en el objeto que se pasa al repo. **`deleteResource`**: `resourceRepo.delete(resourceId)` → `supabaseResourceRepo.delete(resourceId)` ### `src/persistence/supabase/process-repo.ts` — actualizar `delete` Después de Etapa 3, activities/gateways/resources tienen CASCADE en Supabase. Actualizar el método `delete` para que limpie Dexie solo de simulaciones: ```typescript async delete(id: string): Promise { // Supabase CASCADE elimina automáticamente: activities, gateways, activity_resource_assignments await supabase.from('processes').delete().eq('id', id) // TODO Sprint 4 Etapa 4: eliminar limpieza Dexie cuando simulaciones migren a Supabase await db.transaction('rw', [db.simulations], async () => { await db.simulations.where('processId').equals(id).delete() }) // Nota: activities, gateways, resources ya NO están en Dexie — no limpiar } ``` ### `src/features/report/useReportData.ts` Cambiar `resourceRepo.getByProcess(processId)` → `supabaseResourceRepo.getAll()` ### `src/features/workspace/ResourcesPanel.tsx` Al crear un nuevo recurso, **no incluir `processId`** en el objeto: ```typescript // ANTES: const newResource: Resource = { id: crypto.randomUUID(), processId: currentProcess.id, // ← eliminar esta línea name: ..., type: ..., costPerHour: ..., } // DESPUÉS: const newResource: Resource = { id: crypto.randomUUID(), name: ..., type: ..., costPerHour: ..., } ``` No hacer otros cambios de UX en ResourcesPanel — el rediseño completo (selección desde catálogo) es Etapa 7. ### `src/features/import/ImportPage.tsx` Si usa `activityRepo.saveMany()` o `gatewayRepo.saveMany()`, actualizar a los repos Supabase. Asegurarse de que el `userId` se pasa correctamente (el componente tiene acceso via `useAuth()`). --- ## Criterios de validación - [ ] `npm run build` sin errores TypeScript - [ ] `npm run test` — 561 tests siguen verdes - [ ] Importar un BPMN → el proceso se carga correctamente en el workspace - [ ] Editar el costo de una actividad → persiste después de recargar la página - [ ] Editar la probabilidad de un gateway → persiste después de recargar - [ ] Crear un recurso → aparece en Supabase tabla `resources` sin `process_id` - [ ] El mismo recurso es visible en todos los procesos (catálogo global) - [ ] Eliminar un proceso → sus actividades y gateways desaparecen de Supabase (CASCADE) - [ ] El recurso creado NO desaparece al eliminar el proceso (es global) - [ ] En Supabase tabla `activities`: aparecen las actividades con `process_id` correcto - [ ] En Supabase tabla `activity_resource_assignments`: aparecen las asignaciones con `scenario` - [ ] Validación visual del director: workspace funcional (canvas + panel de actividad) --- ## Reporte esperado al completar 1. Lista de archivos creados/modificados 2. Resultado de `npm run test` (número de tests, pass/fail) 3. Resultado de `npm run build` 4. Screenshot del workspace con actividades cargadas 5. Screenshot de Supabase tabla `resources` (sin columna `process_id`) 6. Screenshot de Supabase tabla `activity_resource_assignments` con al menos una fila 7. Cualquier decisión de iniciativa fuera del scope (clasificar nivel 1/2/3) --- ## Notas arquitectónicas ### Los tests de Dexie siguen verdes `tests/integration/indexeddb-persistence.test.ts` usa el `resourceRepo` de Dexie con `processId`. Esos tests deben seguir pasando sin cambios — el `resourceRepo` Dexie todavía existe en `repositories.ts`, simplemente ya no es usado por la producción. No borrar ni modificar ese archivo en esta etapa. ### Patrón userId establecido El patrón de Etapa 2 aplica acá: repos reciben `userId` como parámetro, stores lo reciben de los componentes via `useAuth()`. Los tests de componentes que llaman acciones de escritura deben mockear `useAuth`. Gateways no tienen `created_by`/`updated_by` en el schema — no necesitan `userId` en el repo. ### Catálogo global — impacto en UX Al cargar el workspace, ahora se cargan TODOS los recursos del equipo (no solo los del proceso). El `ResourcesPanel` los mostrará todos. Esto es el comportamiento correcto del catálogo global — el rediseño visual de ese panel es Etapa 7.