diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index b22cc2a..46257b3 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -4,7 +4,8 @@ import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react' import { v4 as uuidv4 } from 'uuid' import { Card, CardContent } from '@/components/ui/card' import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser' -import { processRepo, activityRepo, gatewayRepo } from '@/persistence/repositories' +import { activityRepo, gatewayRepo } from '@/persistence/repositories' +import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import type { Process, Activity, GatewayConfig } from '@/domain/types' import { bpmnTypeToGatewayType } from '@/domain/types' import { useToast } from '@/components/ui/use-toast' @@ -112,7 +113,7 @@ export function ImportPage() { }) await Promise.all([ - processRepo.save(process), + supabaseProcessRepo.save(process), activityRepo.saveMany(activities), gatewayRepo.saveMany(gateways), ]) diff --git a/src/features/report/useReportData.ts b/src/features/report/useReportData.ts index 53d11b2..29ddfdb 100644 --- a/src/features/report/useReportData.ts +++ b/src/features/report/useReportData.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { processRepo, activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories' +import { activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories' +import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import type { Process, Activity, Resource, Simulation } from '@/domain/types' interface ReportData { @@ -27,7 +28,7 @@ export function useReportData(processId: string): ReportData { setError(null) try { const [proc, sim, acts, res] = await Promise.all([ - processRepo.getById(processId), + supabaseProcessRepo.getById(processId), simulationRepo.getLatestByProcess(processId), activityRepo.getByProcess(processId), resourceRepo.getByProcess(processId), diff --git a/src/persistence/supabase/group-repo.ts b/src/persistence/supabase/group-repo.ts new file mode 100644 index 0000000..b7ebef0 --- /dev/null +++ b/src/persistence/supabase/group-repo.ts @@ -0,0 +1,55 @@ +import { supabase } from '@/lib/supabase' +import type { ProcessGroup } from '@/domain/types' + +async function getCurrentUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession() + if (!session?.user) throw new Error('Usuario no autenticado') + return session.user.id +} + +function fromRow(row: Record): ProcessGroup { + return { + id: row.id as string, + name: row.name as string, + createdAt: new Date(row.created_at as string).getTime(), + updatedAt: new Date(row.updated_at as string).getTime(), + } +} + +export const supabaseGroupRepo = { + async save(group: ProcessGroup): Promise { + const userId = await getCurrentUserId() + const { error } = await supabase.from('process_groups').upsert({ + id: group.id, + name: group.name, + created_by: userId, + updated_at: new Date().toISOString(), + }) + if (error) throw error + }, + + async getAll(): Promise { + const { data, error } = await supabase + .from('process_groups') + .select('*') + .order('name', { ascending: true }) + if (error) throw error + return (data ?? []).map(fromRow) + }, + + async getById(id: string): Promise { + const { data, error } = await supabase + .from('process_groups') + .select('*') + .eq('id', id) + .single() + if (error) return undefined + return fromRow(data) + }, + + async delete(id: string): Promise { + // La FK group_id en processes tiene ON DELETE SET NULL → procesos pasan a "Sin clasificar" + const { error } = await supabase.from('process_groups').delete().eq('id', id) + if (error) throw error + }, +} diff --git a/src/persistence/supabase/process-repo.ts b/src/persistence/supabase/process-repo.ts new file mode 100644 index 0000000..62af432 --- /dev/null +++ b/src/persistence/supabase/process-repo.ts @@ -0,0 +1,87 @@ +import { supabase } from '@/lib/supabase' +import { db } from '@/persistence/db' +import type { Process } from '@/domain/types' + +async function getCurrentUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession() + if (!session?.user) throw new Error('Usuario no autenticado') + return session.user.id +} + +function toRow(process: Process, userId: string) { + return { + id: process.id, + name: process.name, + client: process.clientName, + bpmn_xml: process.bpmnXml, + currency: process.currency, + overhead_percentage: process.overheadPercentage, + annual_frequency: process.annualFrequency, + analysis_horizon_years: process.analysisHorizonYears, + automation_investment: process.automationInvestment, + group_id: process.groupId, + tags: process.tags, + owner_id: userId, + created_by: userId, + updated_by: userId, + updated_at: new Date().toISOString(), + } +} + +function fromRow(row: Record): Process { + return { + id: row.id as string, + name: row.name as string, + clientName: (row.client as string) ?? '', + bpmnXml: (row.bpmn_xml as string) ?? '', + currency: (row.currency as string) ?? 'USD', + overheadPercentage: Number(row.overhead_percentage ?? 0), + annualFrequency: Number(row.annual_frequency ?? 1000), + analysisHorizonYears: Number(row.analysis_horizon_years ?? 3), + automationInvestment: Number(row.automation_investment ?? 0), + groupId: (row.group_id as string | null) ?? null, + tags: Array.isArray(row.tags) ? (row.tags as string[]) : [], + createdAt: new Date(row.created_at as string).getTime(), + updatedAt: new Date(row.updated_at as string).getTime(), + } +} + +export const supabaseProcessRepo = { + async save(process: Process): Promise { + const userId = await getCurrentUserId() + const { error } = await supabase.from('processes').upsert(toRow(process, userId)) + if (error) throw error + }, + + async getById(id: string): Promise { + const { data, error } = await supabase + .from('processes') + .select('*') + .eq('id', id) + .single() + if (error) return undefined + return fromRow(data) + }, + + async getAll(): Promise { + const { data, error } = await supabase + .from('processes') + .select('*') + .order('updated_at', { ascending: false }) + if (error) throw error + return (data ?? []).map(fromRow) + }, + + async delete(id: string): Promise { + const { error } = await supabase.from('processes').delete().eq('id', id) + if (error) throw error + + // TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase + await db.transaction('rw', [db.activities, db.gateways, db.resources, db.simulations], async () => { + await db.activities.where('processId').equals(id).delete() + await db.gateways.where('processId').equals(id).delete() + await db.resources.where('processId').equals(id).delete() + await db.simulations.where('processId').equals(id).delete() + }) + }, +} diff --git a/src/persistence/supabase/settings-repo.ts b/src/persistence/supabase/settings-repo.ts new file mode 100644 index 0000000..23a2f0c --- /dev/null +++ b/src/persistence/supabase/settings-repo.ts @@ -0,0 +1,27 @@ +import { supabase } from '@/lib/supabase' +import type { AppSettings } from '@/domain/types' + +const DEFAULT_SETTINGS: AppSettings = { id: 'singleton', groupLabel: 'Cliente' } + +export const supabaseSettingsRepo = { + async get(): Promise { + const { data } = await supabase + .from('app_settings') + .select('id, group_label') + .eq('id', 'singleton') + .single() + if (!data) return DEFAULT_SETTINGS + return { id: 'singleton', groupLabel: (data.group_label as string) ?? 'Cliente' } + }, + + async update(updates: Partial>): Promise { + const patch: Record = { updated_at: new Date().toISOString() } + if (updates.groupLabel !== undefined) patch.group_label = updates.groupLabel + + const { error } = await supabase + .from('app_settings') + .update(patch) + .eq('id', 'singleton') + if (error) throw error + }, +} diff --git a/src/store/library-store.ts b/src/store/library-store.ts index e0e3d77..080150d 100644 --- a/src/store/library-store.ts +++ b/src/store/library-store.ts @@ -1,6 +1,9 @@ import { create } from 'zustand' import { v4 as uuidv4 } from 'uuid' -import { processRepo, groupRepo, settingsRepo, simulationRepo } from '@/persistence/repositories' +import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' +import { supabaseGroupRepo } from '@/persistence/supabase/group-repo' +import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo' +import { simulationRepo } from '@/persistence/repositories' import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types' interface LibraryState { @@ -14,6 +17,8 @@ interface LibraryState { createGroup: (name: string) => Promise deleteGroup: (id: string) => Promise + deleteProcess: (id: string) => Promise + assignProcessToGroup: (processId: string, groupId: string | null) => Promise getProcessesByGroup: (groupId: string | null) => Process[] @@ -33,42 +38,40 @@ export const useLibraryStore = create((set, get) => ({ load: async () => { set({ isLoading: true }) - const [groups, rawProcesses, settings] = await Promise.all([ - groupRepo.getAll(), - processRepo.getAll(), - settingsRepo.get(), + const [groups, processes, settings] = await Promise.all([ + supabaseGroupRepo.getAll(), + supabaseProcessRepo.getAll(), + supabaseSettingsRepo.get(), ]) - // Normalizar datos legacy que puedan llegar sin tags/groupId del IndexedDB - const processes = rawProcesses.map((p) => ({ - ...p, - groupId: p.groupId ?? null, - tags: Array.isArray(p.tags) ? p.tags : [], - })) set({ groups, processes, settings, isLoading: false }) }, createGroup: async (name: string) => { const now = Date.now() const group: ProcessGroup = { id: uuidv4(), name, createdAt: now, updatedAt: now } - await groupRepo.save(group) + await supabaseGroupRepo.save(group) set((state) => ({ groups: [...state.groups, group].sort((a, b) => a.name.localeCompare(b.name)) })) return group }, deleteGroup: async (id: string) => { - await groupRepo.delete(id) + await supabaseGroupRepo.delete(id) set((state) => ({ groups: state.groups.filter((g) => g.id !== id), - // Los procesos del grupo pasan a groupId = null processes: state.processes.map((p) => p.groupId === id ? { ...p, groupId: null } : p), })) }, + deleteProcess: async (id: string) => { + await supabaseProcessRepo.delete(id) + set((state) => ({ processes: state.processes.filter((p) => p.id !== id) })) + }, + assignProcessToGroup: async (processId: string, groupId: string | null) => { const process = get().processes.find((p) => p.id === processId) if (!process) return const updated = { ...process, groupId, updatedAt: Date.now() } - await processRepo.save(updated) + await supabaseProcessRepo.save(updated) set((state) => ({ processes: state.processes.map((p) => p.id === processId ? updated : p), })) @@ -86,7 +89,7 @@ export const useLibraryStore = create((set, get) => ({ }, updateSettings: async (updates) => { - await settingsRepo.update(updates) + await supabaseSettingsRepo.update(updates) set((state) => ({ settings: { ...state.settings, ...updates } })) }, diff --git a/src/store/process-store.ts b/src/store/process-store.ts index f4de582..f8432a4 100644 --- a/src/store/process-store.ts +++ b/src/store/process-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' -import { processRepo, activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories' +import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' +import { activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories' import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types' interface ProcessState { @@ -10,7 +11,6 @@ interface ProcessState { isLoading: boolean error: string | null - // Acciones loadProcess: (processId: string) => Promise setCurrentProcess: (process: Process) => Promise updateProcess: (updates: Partial) => Promise @@ -34,7 +34,7 @@ export const useProcessStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const [process, activities, gateways, resources] = await Promise.all([ - processRepo.getById(processId), + supabaseProcessRepo.getById(processId), activityRepo.getByProcess(processId), gatewayRepo.getByProcess(processId), resourceRepo.getByProcess(processId), @@ -47,7 +47,7 @@ export const useProcessStore = create((set, get) => ({ }, setCurrentProcess: async (process) => { - await processRepo.save(process) + await supabaseProcessRepo.save(process) const [activities, gateways, resources] = await Promise.all([ activityRepo.getByProcess(process.id), gatewayRepo.getByProcess(process.id), @@ -60,7 +60,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (!current) return const updated: Process = { ...current, ...updates, updatedAt: Date.now() } - await processRepo.save(updated) + await supabaseProcessRepo.save(updated) set({ currentProcess: updated }) }, @@ -69,7 +69,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (current) { const updatedProcess = { ...current, updatedAt: Date.now() } - await processRepo.save(updatedProcess) + await supabaseProcessRepo.save(updatedProcess) set((state) => ({ activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), currentProcess: updatedProcess, @@ -86,7 +86,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (current) { const updatedProcess = { ...current, updatedAt: Date.now() } - await processRepo.save(updatedProcess) + await supabaseProcessRepo.save(updatedProcess) set((state) => ({ gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)), currentProcess: updatedProcess, @@ -103,7 +103,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (current) { const updatedProcess = { ...current, updatedAt: Date.now() } - await processRepo.save(updatedProcess) + await supabaseProcessRepo.save(updatedProcess) set((state) => ({ resources: [...state.resources, resource], currentProcess: updatedProcess })) } else { set((state) => ({ resources: [...state.resources, resource] })) @@ -115,7 +115,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (current) { const updatedProcess = { ...current, updatedAt: Date.now() } - await processRepo.save(updatedProcess) + await supabaseProcessRepo.save(updatedProcess) set((state) => ({ resources: state.resources.map((r) => (r.id === resource.id ? resource : r)), currentProcess: updatedProcess, @@ -132,7 +132,7 @@ export const useProcessStore = create((set, get) => ({ const current = get().currentProcess if (current) { const updatedProcess = { ...current, updatedAt: Date.now() } - await processRepo.save(updatedProcess) + await supabaseProcessRepo.save(updatedProcess) set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId), currentProcess: updatedProcess, diff --git a/supabase/migrations/002_create_process_groups.sql b/supabase/migrations/002_create_process_groups.sql new file mode 100644 index 0000000..b40e2b2 --- /dev/null +++ b/supabase/migrations/002_create_process_groups.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS public.process_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_by uuid REFERENCES public.users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.process_groups ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "all_process_groups" ON public.process_groups + FOR ALL USING (auth.uid() IS NOT NULL) + WITH CHECK (auth.uid() IS NOT NULL); diff --git a/supabase/migrations/003_create_processes.sql b/supabase/migrations/003_create_processes.sql new file mode 100644 index 0000000..0e6d69e --- /dev/null +++ b/supabase/migrations/003_create_processes.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS public.processes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + client text NOT NULL DEFAULT '', + bpmn_xml text NOT NULL DEFAULT '', + currency text NOT NULL DEFAULT 'USD', + overhead_percentage numeric NOT NULL DEFAULT 0, + annual_frequency numeric NOT NULL DEFAULT 1000, + analysis_horizon_years numeric NOT NULL DEFAULT 3, + automation_investment numeric NOT NULL DEFAULT 0, + group_id uuid REFERENCES public.process_groups(id) ON DELETE SET NULL, + tags text[] NOT NULL DEFAULT '{}', + owner_id uuid NOT NULL REFERENCES public.users(id), + created_by uuid NOT NULL REFERENCES public.users(id), + updated_by uuid NOT NULL REFERENCES public.users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.processes ENABLE ROW LEVEL SECURITY; + +-- En Sprint 4 todos los usuarios son platform_admin: ven todo. +-- Las políticas granulares (process_access) se agregan en Etapa 5. +CREATE POLICY "authenticated_all_processes" ON public.processes + FOR ALL USING (auth.uid() IS NOT NULL) + WITH CHECK (auth.uid() IS NOT NULL); diff --git a/supabase/migrations/004_create_app_settings.sql b/supabase/migrations/004_create_app_settings.sql new file mode 100644 index 0000000..f7e2e9c --- /dev/null +++ b/supabase/migrations/004_create_app_settings.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS public.app_settings ( + id text PRIMARY KEY DEFAULT 'singleton', + group_label text NOT NULL DEFAULT 'Cliente', + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "all_app_settings" ON public.app_settings + FOR ALL USING (auth.uid() IS NOT NULL) + WITH CHECK (auth.uid() IS NOT NULL); + +INSERT INTO public.app_settings (id, group_label) +VALUES ('singleton', 'Cliente') +ON CONFLICT (id) DO NOTHING;