This commit is contained in:
205
sprints/sprint-8/ETAPA_1_PROMPT.md
Normal file
205
sprints/sprint-8/ETAPA_1_PROMPT.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Sprint 8 — Etapa 1: Migración DB (aditiva)
|
||||||
|
|
||||||
|
> **Tipo:** Base de datos — migración aditiva
|
||||||
|
> **Rama:** main
|
||||||
|
> **Estimación:** 1-2 horas
|
||||||
|
> **Dependencias:** Etapa 0 completada ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Crear la infraestructura de datos para el modelo `organizations → areas → processes` sin romper nada de lo que ya funciona. Esta migración es **completamente aditiva**: crea lo nuevo, no elimina lo viejo. Las columnas `client` y `group_id` de `processes`, y la tabla `process_groups`, se eliminan en una migration de limpieza posterior (después de que Etapas 3-4 migren la UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto del schema actual
|
||||||
|
|
||||||
|
Leer las migraciones en `supabase/migrations/` antes de escribir cualquier SQL. Schema relevante al momento de esta etapa:
|
||||||
|
|
||||||
|
**`processes` — columnas existentes relevantes:**
|
||||||
|
- `client text NOT NULL DEFAULT ''` — el campo libre "clientName" (se elimina más adelante)
|
||||||
|
- `group_id uuid REFERENCES public.process_groups(id) ON DELETE SET NULL` — agrupador viejo (se elimina más adelante)
|
||||||
|
- `org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL` — ← este es el correcto, agregado en migración 014
|
||||||
|
|
||||||
|
**`process_groups` — tabla existente:**
|
||||||
|
- `id, name, created_by, created_at, updated_at` — no tiene FKs entrantes salvo `processes.group_id`
|
||||||
|
|
||||||
|
**`organizations` — tabla existente desde migración 014:**
|
||||||
|
- InQuality existe con `is_provider = true`
|
||||||
|
- Otras orgs creadas automáticamente desde `processes.client` en la migración 014
|
||||||
|
|
||||||
|
**Funciones SECURITY DEFINER disponibles (migración 015):**
|
||||||
|
- `public.is_inquality()` → true si el usuario pertenece a InQuality
|
||||||
|
- `public.current_org()` → org_id del usuario actual
|
||||||
|
- `public.current_platform_role()` → rol del usuario actual
|
||||||
|
- `public.is_platform_admin()` → true si es platform_admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance estricto — Migration 017
|
||||||
|
|
||||||
|
Crear el archivo `supabase/migrations/017_create_areas.sql`.
|
||||||
|
|
||||||
|
### Paso 1: Crear tabla `areas`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Nueva tabla areas: reemplazará process_groups como agrupador normalizado por org
|
||||||
|
CREATE TABLE IF NOT EXISTS public.areas (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
org_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.areas ENABLE ROW LEVEL SECURITY;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 2: Agregar `area_id` a `processes`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- area_id reemplazará group_id como agrupador normalizado dentro de una org
|
||||||
|
-- Nullable: procesos sin área asignada son válidos ("Sin área")
|
||||||
|
ALTER TABLE public.processes
|
||||||
|
ADD COLUMN IF NOT EXISTS area_id uuid REFERENCES public.areas(id) ON DELETE SET NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 3: RLS para `areas`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- SELECT: InQuality ve todas las áreas; cliente ve solo las de su org
|
||||||
|
CREATE POLICY "areas_select" ON public.areas
|
||||||
|
FOR SELECT USING (
|
||||||
|
public.is_inquality()
|
||||||
|
OR org_id = public.current_org()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT / UPDATE / DELETE: solo platform_admin
|
||||||
|
CREATE POLICY "areas_insert" ON public.areas
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "areas_update" ON public.areas
|
||||||
|
FOR UPDATE USING (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "areas_delete" ON public.areas
|
||||||
|
FOR DELETE USING (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 4: Migración de datos — asignar procesos sin org a InQuality
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Procesos sin org_id son datos de prueba de InQuality.
|
||||||
|
-- Asignarlos a la org de InQuality (is_provider = true) para limpiar los NULLs.
|
||||||
|
UPDATE public.processes
|
||||||
|
SET org_id = (
|
||||||
|
SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE org_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lo que esta migración NO hace (intencional)
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ NO dropea la columna `client` de processes
|
||||||
|
❌ NO dropea la columna `group_id` de processes
|
||||||
|
❌ NO dropea la tabla `process_groups`
|
||||||
|
❌ NO modifica ninguna política RLS existente
|
||||||
|
```
|
||||||
|
|
||||||
|
Estas eliminaciones ocurren en una migration posterior, después de que la UI deje de usar esas columnas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificación post-migración
|
||||||
|
|
||||||
|
Después de aplicar la migración en Supabase (SQL Editor o `supabase db push`), verificar con estas queries:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Verificar que la tabla areas existe con RLS activa
|
||||||
|
SELECT schemaname, tablename, rowsecurity
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'areas';
|
||||||
|
-- Esperado: rowsecurity = true
|
||||||
|
|
||||||
|
-- 2. Verificar columna area_id en processes
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'processes'
|
||||||
|
AND column_name = 'area_id';
|
||||||
|
-- Esperado: una fila, data_type = 'uuid', is_nullable = 'YES'
|
||||||
|
|
||||||
|
-- 3. Verificar que no quedan procesos sin org_id
|
||||||
|
SELECT COUNT(*) as sin_org FROM public.processes WHERE org_id IS NULL;
|
||||||
|
-- Esperado: 0
|
||||||
|
|
||||||
|
-- 4. Verificar políticas de areas
|
||||||
|
SELECT policyname, cmd FROM pg_policies
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'areas';
|
||||||
|
-- Esperado: 4 políticas (areas_select, areas_insert, areas_update, areas_delete)
|
||||||
|
|
||||||
|
-- 5. Verificar que process_groups sigue existiendo (no se eliminó)
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'process_groups';
|
||||||
|
-- Esperado: 1 fila (la tabla sigue ahí — se elimina más adelante)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] Archivo `supabase/migrations/017_create_areas.sql` creado
|
||||||
|
- [ ] Migración aplicada en Supabase (reportar resultado del SQL Editor o `supabase db push`)
|
||||||
|
- [ ] Las 5 queries de verificación retornan los valores esperados
|
||||||
|
- [ ] `npm run build` sin errores (no hay cambios en TypeScript — build debe ser idéntico al de Etapa 0)
|
||||||
|
- [ ] `npm run test` sin regresiones (592 tests, sin cambios)
|
||||||
|
- [ ] `process_groups` sigue existiendo en la DB (verificar con query 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restricciones explícitas
|
||||||
|
|
||||||
|
- **NO** modificar ningún archivo TypeScript en esta etapa — solo SQL
|
||||||
|
- **NO** dropear `process_groups`, `client`, ni `group_id` — eso viene después
|
||||||
|
- **NO** tocar las políticas RLS existentes en `processes`, `organizations`, ni otras tablas
|
||||||
|
- **NO** crear datos de prueba en `areas` — la tabla arranca vacía
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos autorizados a modificar/crear
|
||||||
|
|
||||||
|
```
|
||||||
|
simulador-web/
|
||||||
|
└── supabase/
|
||||||
|
└── migrations/
|
||||||
|
└── 017_create_areas.sql ← único archivo a crear
|
||||||
|
```
|
||||||
|
|
||||||
|
Ningún archivo TypeScript debe cambiar en esta etapa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit esperado
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(db): migración 017 — tabla areas, area_id en processes, RLS, bulk-assign org
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporte esperado
|
||||||
|
|
||||||
|
Al finalizar, reportar:
|
||||||
|
1. Contenido exacto del archivo `017_create_areas.sql`
|
||||||
|
2. Resultado de aplicar la migración (output de Supabase)
|
||||||
|
3. Resultado de las 5 queries de verificación (con los valores reales)
|
||||||
|
4. Resultado de `npm run build` y `npm run test`
|
||||||
|
5. Confirmar que `process_groups` sigue existiendo (query 5)
|
||||||
262
sprints/sprint-8/ETAPA_2_PROMPT.md
Normal file
262
sprints/sprint-8/ETAPA_2_PROMPT.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Sprint 8 — Etapa 2: Repositorios y tipos de dominio
|
||||||
|
|
||||||
|
> **Tipo:** Backend TypeScript — dominio + repos
|
||||||
|
> **Rama:** main
|
||||||
|
> **Estimación:** 1.5-2 horas
|
||||||
|
> **Dependencias:** Etapa 1 completada ✅ (tabla `areas` y columna `area_id` en `processes` ya existen en DB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Extender la capa de dominio y repositorios para soportar el nuevo campo `area_id`. Esto es **no destructivo**: se agregan los campos nuevos sin eliminar los viejos (`clientName`, `groupId`), que todavía son usados por la UI hasta que Etapas 3 y 4 los remplacen.
|
||||||
|
|
||||||
|
Al terminar esta etapa, el dominio conoce `areaId/areaName`, el repositorio lee y escribe `area_id` en Supabase, y el código de UI existente sigue funcionando sin cambios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto del código actual
|
||||||
|
|
||||||
|
Leer estos archivos antes de modificar nada:
|
||||||
|
|
||||||
|
- `src/domain/types.ts` — `Process` tiene `clientName: string`, `groupId: UUID | null`, `orgId`, `orgName`. Sin `areaId` aún.
|
||||||
|
- `src/persistence/supabase/process-repo.ts` — `fromRow()` extrae `clientName` y `groupId`; `toRow()` escribe `client` y `group_id`; `getById()` hace JOIN con `organizations!org_id(name)`.
|
||||||
|
- `src/persistence/supabase/group-repo.ts` — repositorio del agrupador viejo `process_groups`. **No tocar** — sigue siendo usado por la Settings page.
|
||||||
|
- `src/domain/schemas.ts` — leer el contenido actual antes de modificar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
### 1. Actualizar `src/domain/types.ts`
|
||||||
|
|
||||||
|
**A. Agregar interface `Area`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Area {
|
||||||
|
id: string
|
||||||
|
orgId: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
createdAt: number // timestamp ms, igual que Process.createdAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Agregar después de la interface `ProcessGroup` existente (o en una sección nueva de tipos de organización).
|
||||||
|
|
||||||
|
**B. Extender interface `Process`:**
|
||||||
|
|
||||||
|
Agregar `areaId` y `areaName` **después** de `orgName`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
orgId: string | null // 🆕 Sprint 7
|
||||||
|
orgName?: string | null // 🆕 Sprint 7 — poblado via JOIN en getById
|
||||||
|
areaId: string | null // 🆕 Sprint 8 — área de la org (null = sin área)
|
||||||
|
areaName?: string | null // 🆕 Sprint 8 — poblado via JOIN en getById
|
||||||
|
```
|
||||||
|
|
||||||
|
**NO eliminar** `clientName: string` ni `groupId: UUID | null` — siguen siendo necesarios hasta Etapas 3-4.
|
||||||
|
|
||||||
|
### 2. Actualizar `src/domain/schemas.ts`
|
||||||
|
|
||||||
|
Leer el archivo primero y reportar su contenido.
|
||||||
|
|
||||||
|
Agregar `areaId` al schema de Process:
|
||||||
|
```typescript
|
||||||
|
areaId: z.string().uuid().nullable(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**NO eliminar** `clientName` ni `groupId` del schema — todavía se usan.
|
||||||
|
|
||||||
|
Si `schemas.ts` no tiene un schema de Process (posiblemente solo tiene schemas para formularios), reportarlo antes de modificar.
|
||||||
|
|
||||||
|
### 3. Crear `src/persistence/supabase/area-repo.ts`
|
||||||
|
|
||||||
|
Nuevo repositorio para la tabla `areas`. Seguir el mismo patrón que `group-repo.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { Area } from '@/domain/types'
|
||||||
|
|
||||||
|
function fromRow(row: Record<string, unknown>): Area {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
orgId: row.org_id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
description: (row.description as string | null) ?? null,
|
||||||
|
createdAt: new Date(row.created_at as string).getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseAreaRepo = {
|
||||||
|
// Todas las áreas de una organización, ordenadas por nombre
|
||||||
|
async getByOrg(orgId: string): Promise<Area[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('areas')
|
||||||
|
.select('*')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(fromRow)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crear área nueva en una org
|
||||||
|
async create(data: { orgId: string; name: string; description?: string }): Promise<Area> {
|
||||||
|
const { data: row, error } = await supabase
|
||||||
|
.from('areas')
|
||||||
|
.insert({
|
||||||
|
org_id: data.orgId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
})
|
||||||
|
.select('*')
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return fromRow(row as Record<string, unknown>)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actualizar nombre y/o descripción de un área
|
||||||
|
async update(id: string, data: { name?: string; description?: string }): Promise<Area> {
|
||||||
|
const { data: row, error } = await supabase
|
||||||
|
.from('areas')
|
||||||
|
.update({
|
||||||
|
...(data.name !== undefined && { name: data.name }),
|
||||||
|
...(data.description !== undefined && { description: data.description }),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
.select('*')
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return fromRow(row as Record<string, unknown>)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Eliminar área. Los procesos con esta área pasan a area_id = null (ON DELETE SET NULL)
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('areas').delete().eq('id', id)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Actualizar `src/persistence/supabase/process-repo.ts`
|
||||||
|
|
||||||
|
Cuatro cambios específicos en el archivo existente:
|
||||||
|
|
||||||
|
**4A — `fromRow()`: extraer `areaId` y `areaName`**
|
||||||
|
|
||||||
|
Agregar a la función existente, después de la extracción de `org`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// area puede estar presente si el query hizo JOIN con areas (getById lo incluye)
|
||||||
|
const area = row.area as { name: string } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
Y en el objeto de retorno, después de `orgName`:
|
||||||
|
```typescript
|
||||||
|
areaId: (row.area_id as string | null) ?? null,
|
||||||
|
areaName: area?.name ?? null,
|
||||||
|
```
|
||||||
|
|
||||||
|
**4B — `toRow()`: incluir `area_id`**
|
||||||
|
|
||||||
|
Agregar al objeto de retorno de `toRow()`, después de `group_id`:
|
||||||
|
```typescript
|
||||||
|
area_id: process.areaId,
|
||||||
|
```
|
||||||
|
|
||||||
|
**4C — `updateEditable()`: incluir `area_id`**
|
||||||
|
|
||||||
|
Agregar al objeto del `.update()`, después de `group_id`:
|
||||||
|
```typescript
|
||||||
|
area_id: process.areaId,
|
||||||
|
```
|
||||||
|
|
||||||
|
**4D — `getById()`: extender el JOIN para incluir área**
|
||||||
|
|
||||||
|
Cambiar el select actual:
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
.select('*, org:organizations!org_id(name)')
|
||||||
|
|
||||||
|
// Después:
|
||||||
|
.select('*, org:organizations!org_id(name), area:areas!area_id(name)')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lo que NO cambia en `process-repo.ts`:**
|
||||||
|
- `client: process.clientName` en `toRow()` — permanece
|
||||||
|
- `group_id: process.groupId` en `toRow()` — permanece
|
||||||
|
- `client: process.clientName` en `updateEditable()` — permanece
|
||||||
|
- `group_id: process.groupId` en `updateEditable()` — permanece
|
||||||
|
- `fromRow()` sigue extrayendo `clientName` y `groupId` — permanece
|
||||||
|
- `getAll()` — no cambia (no hace JOIN con areas, solo devuelve `area_id` como UUID crudo via `select('*')`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Agregar al menos 2 tests unitarios para `area-repo.ts`. Seguir el patrón de los tests existentes de repositorios (probablemente con `vi.mock('@/lib/supabase')`).
|
||||||
|
|
||||||
|
Tests mínimos:
|
||||||
|
1. `getByOrg()` devuelve lista correcta de áreas
|
||||||
|
2. `create()` inserta y retorna el área creada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] `Area` interface existe en `types.ts`
|
||||||
|
- [ ] `Process.areaId` y `Process.areaName` existen en `types.ts` (sin eliminar `clientName`/`groupId`)
|
||||||
|
- [ ] `area-repo.ts` existe con las 4 operaciones (getByOrg, create, update, delete)
|
||||||
|
- [ ] `process-repo.ts`: `fromRow()` extrae `areaId` y `areaName`, `toRow()` incluye `area_id`, `updateEditable()` incluye `area_id`, `getById()` hace JOIN con `areas`
|
||||||
|
- [ ] `npm run build` sin errores TypeScript
|
||||||
|
- [ ] `npm run test` sin regresiones + 2 tests nuevos de `area-repo.ts`
|
||||||
|
- [ ] Ningún archivo de UI modificado (LibraryPage, WorkspacePage, etc. no cambian)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restricciones explícitas
|
||||||
|
|
||||||
|
- **NO eliminar** `clientName`, `groupId` de `types.ts` — aún usados por UI
|
||||||
|
- **NO modificar** `group-repo.ts` — sigue siendo usado por la Settings page
|
||||||
|
- **NO modificar** `library-store.ts` — los cambios de store van en Etapa 3
|
||||||
|
- **NO modificar** ningún componente React — solo capa de dominio y persistencia
|
||||||
|
- **NO agregar** `area_id` al `select('*')` de `getAll()` explícitamente — ya está incluido en el wildcard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos autorizados a modificar/crear
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── domain/
|
||||||
|
│ ├── types.ts ← agregar Area interface + areaId/areaName a Process
|
||||||
|
│ └── schemas.ts ← agregar areaId al schema (leer primero)
|
||||||
|
└── persistence/
|
||||||
|
└── supabase/
|
||||||
|
├── area-repo.ts ← crear
|
||||||
|
└── process-repo.ts ← 4 cambios puntuales
|
||||||
|
```
|
||||||
|
|
||||||
|
Opcionalmente, un archivo de tests para `area-repo.ts` en `tests/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit esperado
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(area-repo): repositorio CRUD de áreas por organización
|
||||||
|
feat(process-repo): extender fromRow/toRow/getById con areaId y areaName
|
||||||
|
feat(domain): interface Area + campos areaId/areaName en Process
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporte esperado
|
||||||
|
|
||||||
|
1. Contenido actual de `schemas.ts` (antes de modificar)
|
||||||
|
2. Diff compacto de `types.ts`
|
||||||
|
3. Diff compacto de `process-repo.ts`
|
||||||
|
4. Contenido de `area-repo.ts` creado
|
||||||
|
5. Resultado de `npm run build` y `npm run test` (con conteo de tests)
|
||||||
|
6. Confirmar que `clientName` y `groupId` siguen presentes en `types.ts`
|
||||||
432
sprints/sprint-8/ETAPA_3_PROMPT.md
Normal file
432
sprints/sprint-8/ETAPA_3_PROMPT.md
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
# Sprint 8 — Etapa 3: LibraryPage — agrupación org → área
|
||||||
|
|
||||||
|
> **Tipo:** UI — refactorización de vista de biblioteca
|
||||||
|
> **Rama:** main
|
||||||
|
> **Estimación:** 2-3 horas
|
||||||
|
> **Dependencias:** Etapas 1 y 2 completadas ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Reemplazar la agrupación por `process_groups` / `groupId` en LibraryPage con una agrupación real por `organizations → areas`. El resultado visual: el admin ve sus procesos organizados por cliente (org) con sub-secciones por área, colapsables. Los roles cliente no cambian (ya tienen lista plana).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto del código actual
|
||||||
|
|
||||||
|
Leer en su totalidad antes de modificar:
|
||||||
|
- `src/features/library/LibraryPage.tsx` (942 líneas)
|
||||||
|
- `src/persistence/supabase/process-repo.ts` (para el cambio en `getAll()`)
|
||||||
|
- `src/router.tsx` (para el guard de `/settings`)
|
||||||
|
|
||||||
|
**Estructura actual relevante de LibraryPage:**
|
||||||
|
- `HomeView` para admin: grid de `GroupCard` (desde `process_groups` via `library-store.groups`) + sección "Sin clasificar" filtrada por `groupId == null`
|
||||||
|
- `HomeView` para cliente: lista plana de procesos (sin cambios en esta etapa)
|
||||||
|
- `GroupView`: lista de procesos filtrada por `groupId` — se preserva como código muerto (no eliminar)
|
||||||
|
- `LibraryPage`: botón Settings ⚙ visible para todos los roles (línea ~891)
|
||||||
|
- `LibraryPage`: efecto `orgParam` que hace scroll a `group-${proc.groupId}` (líneas ~793-800)
|
||||||
|
|
||||||
|
**Dato importante:** `getAll()` en `process-repo.ts` actualmente NO hace JOIN con org ni area, por lo que los procesos retornados tienen `orgName: null` y `areaName: null`. Esta etapa extiende ese JOIN como primer paso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
### Cambio 1: Extender `getAll()` en `process-repo.ts`
|
||||||
|
|
||||||
|
Agregar org y area al select de `getAll()`. Cambio de una línea:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)')
|
||||||
|
|
||||||
|
// Después:
|
||||||
|
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name), org:organizations!org_id(name), area:areas!area_id(name)')
|
||||||
|
```
|
||||||
|
|
||||||
|
`fromRow()` ya sabe extraer `orgName` y `areaName` desde los JOINs (implementado en Etapa 2). Los tests existentes siguen pasando porque `fromRow()` usa null-safe extraction (`org?.name ?? null`).
|
||||||
|
|
||||||
|
### Cambio 2: Refactorizar `HomeView` — sección admin
|
||||||
|
|
||||||
|
**Reemplazar** el bloque `{!isSearching && !isClientRole && (...)}` (líneas 543-631 aproximadamente) por una lista agrupada org → área.
|
||||||
|
|
||||||
|
**Nueva estructura visual para admin:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Org: Solar Banco] ← header colapsable con ícono Building2 y badge de conteo
|
||||||
|
[Área: Operaciones] ← sub-header con ícono FolderOpen
|
||||||
|
[ProcessCard] [ProcessCard]
|
||||||
|
[Área: RR.HH.]
|
||||||
|
[ProcessCard]
|
||||||
|
[Sin área] ← solo si existen procesos sin area_id
|
||||||
|
[ProcessCard]
|
||||||
|
|
||||||
|
[Org: InQuality]
|
||||||
|
[Sin área]
|
||||||
|
[ProcessCard]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lógica de agrupación** (computar desde `visibleProcesses`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Agrupar por orgId → areaId
|
||||||
|
const orgGroups = useMemo(() => {
|
||||||
|
const byOrg = new Map<string, {
|
||||||
|
orgId: string
|
||||||
|
orgName: string
|
||||||
|
processes: ProcessWithUserNames[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const p of visibleProcesses) {
|
||||||
|
const key = p.orgId ?? '__sin_org'
|
||||||
|
const orgName = p.orgName ?? 'Sin organización'
|
||||||
|
if (!byOrg.has(key)) {
|
||||||
|
byOrg.set(key, { orgId: key, orgName, processes: [] })
|
||||||
|
}
|
||||||
|
byOrg.get(key)!.processes.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName))
|
||||||
|
}, [visibleProcesses])
|
||||||
|
|
||||||
|
// Dentro de cada org, agrupar por areaId
|
||||||
|
function getAreaGroups(orgProcesses: ProcessWithUserNames[]) {
|
||||||
|
const byArea = new Map<string, {
|
||||||
|
areaId: string
|
||||||
|
areaName: string
|
||||||
|
processes: ProcessWithUserNames[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const p of orgProcesses) {
|
||||||
|
const key = p.areaId ?? '__sin_area'
|
||||||
|
const areaName = p.areaName ?? 'Sin área'
|
||||||
|
if (!byArea.has(key)) {
|
||||||
|
byArea.set(key, { areaId: key, areaName, processes: [] })
|
||||||
|
}
|
||||||
|
byArea.get(key)!.processes.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Sin área" siempre al final
|
||||||
|
const sorted = [...byArea.values()].sort((a, b) => {
|
||||||
|
if (a.areaId === '__sin_area') return 1
|
||||||
|
if (b.areaId === '__sin_area') return -1
|
||||||
|
return a.areaName.localeCompare(b.areaName)
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estado de colapso por org** (criterio de wow):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function toggleOrg(orgId: string) {
|
||||||
|
setCollapsedOrgs(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(orgId)) next.delete(orgId)
|
||||||
|
else next.add(orgId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Renderizado del bloque admin:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{!isSearching && !isClientRole && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Tag cloud — mantener igual que antes */}
|
||||||
|
{cloud.length >= 3 && (...)}
|
||||||
|
|
||||||
|
{/* Grupos org → área */}
|
||||||
|
{orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
|
||||||
|
const isCollapsed = collapsedOrgs.has(orgId)
|
||||||
|
const areaGroups = getAreaGroups(orgProcesses)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
|
||||||
|
{/* Header de org — colapsable */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleOrg(orgId)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 bg-secondary/40 hover:bg-secondary/60 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-[13px] font-medium">{orgName}</span>
|
||||||
|
<span className="text-[11px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
||||||
|
{orgProcesses.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
|
||||||
|
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenido — animado */}
|
||||||
|
<div
|
||||||
|
className="transition-all duration-200 overflow-hidden"
|
||||||
|
style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
|
||||||
|
<div key={areaId}>
|
||||||
|
{/* Sub-header de área (solo si hay más de un área o existe un área nombrada) */}
|
||||||
|
{(areaGroups.length > 1 || areaId !== '__sin_area') && (
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
{areaName}
|
||||||
|
<span className="normal-case tracking-normal font-normal">
|
||||||
|
· {areaProcesses.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{areaProcesses.map((p) => (
|
||||||
|
<ProcessCardWithSim
|
||||||
|
key={p.id}
|
||||||
|
process={p}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onShared={onShared}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty state: admin sin procesos */}
|
||||||
|
{orgGroups.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="text-[13px] font-medium text-secondary-foreground">
|
||||||
|
Tu biblioteca está vacía
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
Importá tu primer BPMN para empezar
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onImport()}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: '#F59845' }}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Importar BPMN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agregar `ChevronDown` y `FolderOpen` a los imports de lucide-react** en el archivo. Verificar que no estén ya importados.
|
||||||
|
|
||||||
|
### Cambio 3: Actualizar resultados de búsqueda
|
||||||
|
|
||||||
|
En el bloque de `filteredProcesses.map()` (búsqueda activa), reemplazar la resolución del nombre de grupo por area/org:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
const group = groups.find((g) => g.id === p.groupId)
|
||||||
|
<ProcessCardWithSim showGroupChip groupName={group?.name} ... />
|
||||||
|
|
||||||
|
// Después:
|
||||||
|
<ProcessCardWithSim showGroupChip groupName={p.areaName ?? p.orgName ?? undefined} ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cambio 4: Actualizar header de LibraryPage
|
||||||
|
|
||||||
|
La línea que muestra `{totalGroups} {settings.groupLabel.toLowerCase()}s` no aplica al nuevo modelo. Reemplazar por conteo de orgs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
{processes.length} proceso{...} · {totalGroups} {settings.groupLabel.toLowerCase()}{...}
|
||||||
|
|
||||||
|
// Después (para admin):
|
||||||
|
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {orgGroups.length} cliente{orgGroups.length !== 1 ? 's' : ''}
|
||||||
|
|
||||||
|
// Para cliente: mostrar solo procesos (sin conteo de org)
|
||||||
|
{processes.length} proceso{processes.length !== 1 ? 's' : ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nota: `orgGroups` se computa en `HomeView`, no en `LibraryPage`. Para el header en `LibraryPage`, usar un conteo simple:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
|
||||||
|
```
|
||||||
|
|
||||||
|
Y en el JSX del header:
|
||||||
|
```typescript
|
||||||
|
{isClientRole
|
||||||
|
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
|
||||||
|
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cambio 5: Ocultar botón Settings para roles cliente
|
||||||
|
|
||||||
|
En `LibraryPage` (líneas ~891-897), envolver el botón de Settings:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Antes: el botón de Settings está suelto, visible para todos
|
||||||
|
|
||||||
|
// Después: solo visible para admin/member
|
||||||
|
{!isClientRole && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/settings' })}
|
||||||
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||||
|
title="Configuración"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cambio 6: Guard de ruta `/settings` en `router.tsx`
|
||||||
|
|
||||||
|
Agregar `beforeLoad` a `settingsRoute` para bloquear acceso de roles cliente:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// En router.tsx, actualmente:
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/settings',
|
||||||
|
component: SettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nuevo:
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/settings',
|
||||||
|
component: SettingsPage,
|
||||||
|
beforeLoad: ({ context }: { context: RouterContext }) => {
|
||||||
|
const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole
|
||||||
|
if (role === 'client_editor' || role === 'client_viewer') {
|
||||||
|
throw redirect({ to: '/library' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Verificar primero cómo está tipado `context` en los guards existentes del router (por ejemplo en `adminLayoutRoute`) y usar el mismo patrón.
|
||||||
|
|
||||||
|
### Cambio 7: Eliminar el efecto `orgParam` obsoleto
|
||||||
|
|
||||||
|
Las líneas ~793-800:
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orgParam || isLoading || processes.length === 0) return
|
||||||
|
const proc = processes.find((p) => p.orgId === orgParam)
|
||||||
|
if (!proc?.groupId) return // ← depende de groupId, ya no válido
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.getElementById(`group-${proc.groupId}`)?.scrollIntoView(...)
|
||||||
|
})
|
||||||
|
}, [orgParam, isLoading, processes])
|
||||||
|
```
|
||||||
|
|
||||||
|
Este efecto busca un proceso por `orgId` y hace scroll a `group-${proc.groupId}` — un ID que ya no existe en el nuevo DOM. Eliminar el efecto completo. La variable `orgParam` puede eliminarse también si ya no se usa en ningún otro lugar.
|
||||||
|
|
||||||
|
### Cambio 8: Simplificar `handleImport`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
function handleImport(groupId?: string) {
|
||||||
|
if (groupId) {
|
||||||
|
navigate({ to: '/import', search: { groupId } })
|
||||||
|
} else {
|
||||||
|
navigate({ to: '/import' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Después:
|
||||||
|
function handleImport() {
|
||||||
|
navigate({ to: '/import' })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Y actualizar todas las llamadas a `handleImport` para no pasar argumentos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que NO cambia en esta etapa
|
||||||
|
|
||||||
|
- **`GroupView`**: mantener el componente intacto — se convierte en código no alcanzable desde el admin, pero no eliminar (evita riesgo de romper algo)
|
||||||
|
- **`library-store.ts`**: sin cambios — sigue cargando `groups` para la Settings page
|
||||||
|
- **Vista cliente de `HomeView`**: sin cambios (lista plana, funciona igual)
|
||||||
|
- **`group-repo.ts`**: sin cambios
|
||||||
|
- **`WorkspacePage.tsx`**: sin cambios (Etapa 4)
|
||||||
|
- **`GlobalSettingsPanel.tsx`**: sin cambios (Etapa 4)
|
||||||
|
- **`ProcessGroup` interface en types.ts**: sin cambios
|
||||||
|
- **Props `showGroupChip` / `groupName` de `ProcessCard`**: adaptar pero no eliminar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
### Funcionales (validación visual mandatoria)
|
||||||
|
- [ ] Admin ve procesos agrupados por org → área en la biblioteca
|
||||||
|
- [ ] Hacer click en header de org colapsa/expande la sección con animación
|
||||||
|
- [ ] "Sin área" aparece al final de cada org (si existen procesos sin área)
|
||||||
|
- [ ] Roles cliente siguen viendo lista plana (sin cambio)
|
||||||
|
- [ ] Botón Settings ⚙ no visible para client_editor y client_viewer
|
||||||
|
- [ ] Navegar a `/settings` como client_editor o client_viewer redirige a `/library`
|
||||||
|
- [ ] Búsqueda muestra chip con nombre de área u org (en lugar de nombre de grupo)
|
||||||
|
- [ ] El botón "Importar BPMN" funciona y lleva a `/import`
|
||||||
|
|
||||||
|
### Técnicos
|
||||||
|
- [ ] `npm run build` sin errores TypeScript
|
||||||
|
- [ ] `npm run test` sin regresiones (596 tests actuales)
|
||||||
|
- [ ] `getAll()` ahora incluye org y area en el select — verificar en Network tab que el request lleva el select extendido
|
||||||
|
- [ ] `orgParam` y el efecto de scroll obsoleto eliminados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restricciones explícitas
|
||||||
|
|
||||||
|
- **NO eliminar** `GroupView` — preservar aunque sea código no alcanzable
|
||||||
|
- **NO modificar** `library-store.ts`, `group-repo.ts` ni la Settings page
|
||||||
|
- **NO eliminar** props `showGroupChip`/`groupName` de `ProcessCard` — solo actualizarlos
|
||||||
|
- **NO tocar** `WorkspacePage.tsx` ni `GlobalSettingsPanel.tsx` — Etapa 4
|
||||||
|
- **NO tocar** `clientName` ni `groupId` en el dominio — Etapas posteriores
|
||||||
|
- **NO eliminar** los datos de `groups` del store — la Settings page los sigue usando
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos autorizados a modificar
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── persistence/supabase/
|
||||||
|
│ └── process-repo.ts ← 1 línea: extender select de getAll()
|
||||||
|
├── features/library/
|
||||||
|
│ └── LibraryPage.tsx ← refactorización principal
|
||||||
|
└── router.tsx ← guard beforeLoad en settingsRoute
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits esperados
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(library): agrupación org → área reemplaza groupId en HomeView
|
||||||
|
feat(router): guard beforeLoad en /settings para client_editor y client_viewer
|
||||||
|
fix(process-repo): getAll() incluye JOIN con org y area para populear names
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporte esperado
|
||||||
|
|
||||||
|
1. Descripción de cómo quedó el nuevo bloque admin de `HomeView` (estructura visual)
|
||||||
|
2. Confirmar que `GroupView` sigue en el archivo (código no alcanzable, no eliminado)
|
||||||
|
3. Lista de archivos modificados con diff compacto
|
||||||
|
4. `npm run build` y `npm run test` (con conteo)
|
||||||
|
5. **Solicitar validación visual del director** antes de declarar OK — mostrar descripción del estado de la UI para que el director valide en la app
|
||||||
59
supabase/migrations/017_create_areas.sql
Normal file
59
supabase/migrations/017_create_areas.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migración 017: tabla areas + area_id en processes + RLS + bulk-assign org
|
||||||
|
-- Completamente aditiva — no elimina columnas, tablas ni políticas existentes.
|
||||||
|
|
||||||
|
-- ── Paso 1: Crear tabla areas ─────────────────────────────────────────────────
|
||||||
|
-- Nueva tabla areas: reemplazará process_groups como agrupador normalizado por org
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.areas (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
org_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.areas ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Paso 2: Agregar area_id a processes ───────────────────────────────────────
|
||||||
|
-- area_id reemplazará group_id como agrupador normalizado dentro de una org.
|
||||||
|
-- Nullable: procesos sin área asignada son válidos ("Sin área").
|
||||||
|
|
||||||
|
ALTER TABLE public.processes
|
||||||
|
ADD COLUMN IF NOT EXISTS area_id uuid REFERENCES public.areas(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ── Paso 3: RLS para areas ────────────────────────────────────────────────────
|
||||||
|
-- SELECT: InQuality ve todas las áreas; cliente ve solo las de su org
|
||||||
|
|
||||||
|
CREATE POLICY "areas_select" ON public.areas
|
||||||
|
FOR SELECT USING (
|
||||||
|
public.is_inquality()
|
||||||
|
OR org_id = public.current_org()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT / UPDATE / DELETE: solo platform_admin
|
||||||
|
|
||||||
|
CREATE POLICY "areas_insert" ON public.areas
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "areas_update" ON public.areas
|
||||||
|
FOR UPDATE USING (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "areas_delete" ON public.areas
|
||||||
|
FOR DELETE USING (
|
||||||
|
public.current_platform_role() = 'platform_admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Paso 4: Migración de datos — asignar procesos sin org a InQuality ─────────
|
||||||
|
-- Procesos sin org_id son datos de prueba de InQuality.
|
||||||
|
-- Asignarlos a la org de InQuality (is_provider = true) para limpiar los NULLs.
|
||||||
|
|
||||||
|
UPDATE public.processes
|
||||||
|
SET org_id = (
|
||||||
|
SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE org_id IS NULL;
|
||||||
Reference in New Issue
Block a user