Corrección 1 — Delete de proceso restaurado +
This commit is contained in:
@@ -13,29 +13,45 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
|||||||
|
|
||||||
const authService = new SupabaseAuthService()
|
const authService = new SupabaseAuthService()
|
||||||
|
|
||||||
|
// Lee el usuario almacenado en localStorage SIN hacer llamadas de red.
|
||||||
|
// Supabase v2 usa la clave sb-{project_ref}-auth-token.
|
||||||
|
// Permite renderizar la app inmediatamente sin esperar la verificación async de sesión.
|
||||||
|
function getUserFromStorage(): AuthUser | null {
|
||||||
|
try {
|
||||||
|
const key = Object.keys(localStorage).find(
|
||||||
|
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
|
||||||
|
)
|
||||||
|
if (!key) return null
|
||||||
|
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
|
||||||
|
const u = stored?.user
|
||||||
|
if (!u?.id) return null
|
||||||
|
const email = (u.email as string) ?? ''
|
||||||
|
return {
|
||||||
|
id: u.id as string,
|
||||||
|
email,
|
||||||
|
name: (u.user_metadata?.full_name as string) ?? email,
|
||||||
|
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
|
||||||
|
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null)
|
// Inicialización sincrónica desde localStorage — 0ms, sin red
|
||||||
const [loading, setLoading] = useState(true)
|
const [user, setUser] = useState<AuthUser | null>(getUserFromStorage())
|
||||||
|
// loading siempre false: la respuesta de localStorage es inmediata
|
||||||
|
const [loading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Timeout de seguridad: si Supabase no responde en 10s, desbloquear UI
|
// Verificar y sincronizar con Supabase en background
|
||||||
const timeout = setTimeout(() => {
|
// Si la sesión expiró o fue revocada, onAuthStateChange llamará callback(null)
|
||||||
setLoading((prev) => {
|
// y el router redirigirá a /login
|
||||||
if (prev) console.error('Auth timeout: la sesión de Supabase no respondió en 10s')
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
const unsubscribe = authService.onAuthStateChange((u) => {
|
const unsubscribe = authService.onAuthStateChange((u) => {
|
||||||
clearTimeout(timeout)
|
|
||||||
setUser(u)
|
setUser(u)
|
||||||
setLoading(false)
|
|
||||||
})
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
// Siempre llamar callback — incluso si falla algo interno
|
|
||||||
try {
|
try {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
callback(null)
|
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
|
||||||
|
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
|
||||||
|
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
|
||||||
|
if (event !== 'INITIAL_SESSION') {
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
|
// Primer login: upsert del usuario en la tabla users
|
||||||
const email = session.user.email ?? ''
|
const email = session.user.email ?? ''
|
||||||
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
|
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
|
||||||
const { error: upsertError } = await supabase.from('users').upsert(
|
const { error: upsertError } = await supabase.from('users').upsert(
|
||||||
@@ -46,19 +51,14 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
|
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.buildAuthUser(session.user)
|
// buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión
|
||||||
|
const user = this.buildAuthUser(session.user)
|
||||||
callback(user)
|
callback(user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error en onAuthStateChange:', err)
|
console.error('Error en onAuthStateChange:', err)
|
||||||
// Garantizar que loading se desbloquea incluso en error
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
callback({
|
callback(this.buildAuthUser(session.user))
|
||||||
id: session.user.id,
|
} else if (event !== 'INITIAL_SESSION') {
|
||||||
email: session.user.email ?? '',
|
|
||||||
name: session.user.user_metadata?.full_name as string ?? session.user.email ?? '',
|
|
||||||
platformRole: 'guest',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
callback(null)
|
callback(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,20 +68,16 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> {
|
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
|
||||||
|
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN).
|
||||||
|
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
|
||||||
const email = supabaseUser.email ?? ''
|
const email = supabaseUser.email ?? ''
|
||||||
const { data: row } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.select('name, avatar_url, platform_role')
|
|
||||||
.eq('id', supabaseUser.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: supabaseUser.id,
|
id: supabaseUser.id,
|
||||||
email,
|
email,
|
||||||
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email,
|
name: supabaseUser.user_metadata?.full_name as string ?? email,
|
||||||
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined,
|
avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
|
||||||
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'],
|
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
|
|||||||
|
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
id: UUID
|
id: UUID
|
||||||
processId: UUID
|
processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
|
||||||
name: string
|
name: string
|
||||||
type: ResourceType
|
type: ResourceType
|
||||||
costPerHour: number
|
costPerHour: number
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
|
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
|
||||||
import { activityRepo, gatewayRepo } from '@/persistence/repositories'
|
|
||||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
|
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||||
|
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
|
||||||
import type { Process, Activity, GatewayConfig } from '@/domain/types'
|
import type { Process, Activity, GatewayConfig } from '@/domain/types'
|
||||||
import { bpmnTypeToGatewayType } from '@/domain/types'
|
import { bpmnTypeToGatewayType } from '@/domain/types'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
@@ -114,10 +115,20 @@ export function ImportPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.race([
|
||||||
supabaseProcessRepo.save(process, user!.id),
|
(async () => {
|
||||||
activityRepo.saveMany(activities),
|
await supabaseProcessRepo.save(process, user!.id)
|
||||||
gatewayRepo.saveMany(gateways),
|
await Promise.all([
|
||||||
|
supabaseActivityRepo.saveMany(activities, user!.id),
|
||||||
|
supabaseGatewayRepo.saveMany(gateways),
|
||||||
|
])
|
||||||
|
})(),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')),
|
||||||
|
20_000,
|
||||||
|
)
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ function HomeView({
|
|||||||
onNavigateToGroup: (groupId: string | null) => void
|
onNavigateToGroup: (groupId: string | null) => void
|
||||||
onImport: (groupId?: string) => void
|
onImport: (groupId?: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { groups, processes, settings, createGroup } = useLibraryStore()
|
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||||
@@ -330,7 +330,26 @@ function HomeView({
|
|||||||
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
|
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state: sin grupos ni procesos
|
// Skeleton mientras carga y aún no hay datos (evita flash de empty state)
|
||||||
|
if (isLoading && groups.length === 0 && processes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 animate-pulse">
|
||||||
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-20 rounded-xl bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-14 rounded-xl bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
||||||
if (groups.length === 0 && processes.length === 0) {
|
if (groups.length === 0 && processes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
@@ -601,7 +620,7 @@ function GroupView({
|
|||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { load, isLoading, processes, groups, settings } = useLibraryStore()
|
const { load, processes, groups, settings } = useLibraryStore()
|
||||||
const [view, setView] = useState<'home' | 'group'>('home')
|
const [view, setView] = useState<'home' | 'group'>('home')
|
||||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||||
const [transitioning, setTransitioning] = useState(false)
|
const [transitioning, setTransitioning] = useState(false)
|
||||||
@@ -670,29 +689,23 @@ export function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenido con transición */}
|
{/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */}
|
||||||
{isLoading ? (
|
<div
|
||||||
<div className="flex items-center justify-center py-24">
|
className="transition-all duration-150"
|
||||||
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
||||||
</div>
|
>
|
||||||
) : (
|
{view === 'home' ? (
|
||||||
<div
|
<HomeView
|
||||||
className="transition-all duration-150"
|
onNavigateToGroup={navigateToGroup}
|
||||||
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
onImport={handleImport}
|
||||||
>
|
/>
|
||||||
{view === 'home' ? (
|
) : (
|
||||||
<HomeView
|
<GroupView
|
||||||
onNavigateToGroup={navigateToGroup}
|
groupId={activeGroupId}
|
||||||
onImport={handleImport}
|
onBack={navigateHome}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<GroupView
|
</div>
|
||||||
groupId={activeGroupId}
|
|
||||||
onBack={navigateHome}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories'
|
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||||
|
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
||||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
|
import { simulationRepo } from '@/persistence/repositories'
|
||||||
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
|
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
@@ -30,8 +32,8 @@ export function useReportData(processId: string): ReportData {
|
|||||||
const [proc, sim, acts, res] = await Promise.all([
|
const [proc, sim, acts, res] = await Promise.all([
|
||||||
supabaseProcessRepo.getById(processId),
|
supabaseProcessRepo.getById(processId),
|
||||||
simulationRepo.getLatestByProcess(processId),
|
simulationRepo.getLatestByProcess(processId),
|
||||||
activityRepo.getByProcess(processId),
|
supabaseActivityRepo.getByProcess(processId),
|
||||||
resourceRepo.getByProcess(processId),
|
supabaseResourceRepo.getAll(),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
|
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
|
||||||
|
|||||||
@@ -136,8 +136,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
|||||||
|
|
||||||
if (!localActivity) {
|
if (!localActivity) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
|
||||||
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
|
<p className="text-xs text-slate-400">Actividad no encontrada en la base de datos.</p>
|
||||||
|
<p className="text-[11px] text-slate-300">
|
||||||
|
Si importaste este proceso antes de la última actualización, volvé a importar el archivo BPMN.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function ResourcesPanel() {
|
|||||||
} else {
|
} else {
|
||||||
const newResource: Resource = {
|
const newResource: Resource = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
processId: currentProcess.id,
|
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
type: form.type,
|
type: form.type,
|
||||||
costPerHour,
|
costPerHour,
|
||||||
|
|||||||
183
src/persistence/supabase/activity-repo.ts
Normal file
183
src/persistence/supabase/activity-repo.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { Activity, ActivityResourceAssignment } from '@/domain/types'
|
||||||
|
|
||||||
|
function fromRow(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
assignedResources: ActivityResourceAssignment[],
|
||||||
|
automatedAssignedResources: ActivityResourceAssignment[],
|
||||||
|
): Activity {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
processId: row.process_id as string,
|
||||||
|
bpmnElementId: row.bpmn_element_id as string,
|
||||||
|
name: (row.name as string) ?? '',
|
||||||
|
type: (row.type as Activity['type']) ?? 'task',
|
||||||
|
directCostFixed: Number(row.direct_cost_fixed ?? 0),
|
||||||
|
executionTimeMinutes: Number(row.execution_time_minutes ?? 0),
|
||||||
|
automatable: Boolean(row.automatable),
|
||||||
|
automatedCostFixed: Number(row.automated_cost_fixed ?? 0),
|
||||||
|
automatedTimeMinutes: Number(row.automated_time_minutes ?? 0),
|
||||||
|
assignedResources,
|
||||||
|
automatedAssignedResources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentFromRow(row: Record<string, unknown>): ActivityResourceAssignment {
|
||||||
|
return {
|
||||||
|
resourceId: row.resource_id as string,
|
||||||
|
utilizationMinutes: Number(row.utilization_minutes ?? 0),
|
||||||
|
units: Number(row.units ?? 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrae assignments de la relación anidada que Supabase devuelve con select('*, activity_resource_assignments(*)')
|
||||||
|
function extractAssignments(row: Record<string, unknown>): {
|
||||||
|
actual: ActivityResourceAssignment[]
|
||||||
|
automated: ActivityResourceAssignment[]
|
||||||
|
} {
|
||||||
|
const raw = (row.activity_resource_assignments as Array<Record<string, unknown>>) ?? []
|
||||||
|
return {
|
||||||
|
actual: raw.filter((a) => a.scenario === 'actual').map(assignmentFromRow),
|
||||||
|
automated: raw.filter((a) => a.scenario === 'automated').map(assignmentFromRow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseActivityRepo = {
|
||||||
|
async save(activity: Activity, userId: string): Promise<void> {
|
||||||
|
const { error: actError } = await supabase.from('activities').upsert({
|
||||||
|
id: activity.id,
|
||||||
|
process_id: activity.processId,
|
||||||
|
bpmn_element_id: activity.bpmnElementId,
|
||||||
|
name: activity.name,
|
||||||
|
type: activity.type,
|
||||||
|
direct_cost_fixed: activity.directCostFixed,
|
||||||
|
execution_time_minutes: activity.executionTimeMinutes,
|
||||||
|
automatable: activity.automatable,
|
||||||
|
automated_cost_fixed: activity.automatedCostFixed,
|
||||||
|
automated_time_minutes: activity.automatedTimeMinutes,
|
||||||
|
updated_by: userId,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (actError) throw actError
|
||||||
|
|
||||||
|
const { error: delError } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.delete()
|
||||||
|
.eq('activity_id', activity.id)
|
||||||
|
if (delError) throw delError
|
||||||
|
|
||||||
|
const assignments = [
|
||||||
|
...activity.assignedResources.map((r) => ({
|
||||||
|
activity_id: activity.id,
|
||||||
|
resource_id: r.resourceId,
|
||||||
|
utilization_minutes: r.utilizationMinutes,
|
||||||
|
units: r.units,
|
||||||
|
scenario: 'actual',
|
||||||
|
})),
|
||||||
|
...(activity.automatedAssignedResources ?? []).map((r) => ({
|
||||||
|
activity_id: activity.id,
|
||||||
|
resource_id: r.resourceId,
|
||||||
|
utilization_minutes: r.utilizationMinutes,
|
||||||
|
units: r.units,
|
||||||
|
scenario: 'automated',
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (assignments.length > 0) {
|
||||||
|
const { error: insError } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.insert(assignments)
|
||||||
|
if (insError) throw insError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk insert optimizado: 3 round-trips totales sin importar el número de actividades
|
||||||
|
async saveMany(activities: Activity[], userId: string): Promise<void> {
|
||||||
|
if (activities.length === 0) return
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const { error: actError } = await supabase.from('activities').upsert(
|
||||||
|
activities.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
process_id: a.processId,
|
||||||
|
bpmn_element_id: a.bpmnElementId,
|
||||||
|
name: a.name,
|
||||||
|
type: a.type,
|
||||||
|
direct_cost_fixed: a.directCostFixed,
|
||||||
|
execution_time_minutes: a.executionTimeMinutes,
|
||||||
|
automatable: a.automatable,
|
||||||
|
automated_cost_fixed: a.automatedCostFixed,
|
||||||
|
automated_time_minutes: a.automatedTimeMinutes,
|
||||||
|
updated_by: userId,
|
||||||
|
updated_at: now,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (actError) throw actError
|
||||||
|
|
||||||
|
const activityIds = activities.map((a) => a.id)
|
||||||
|
const { error: delError } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.delete()
|
||||||
|
.in('activity_id', activityIds)
|
||||||
|
if (delError) throw delError
|
||||||
|
|
||||||
|
const assignments = activities.flatMap((a) => [
|
||||||
|
...a.assignedResources.map((r) => ({
|
||||||
|
activity_id: a.id,
|
||||||
|
resource_id: r.resourceId,
|
||||||
|
utilization_minutes: r.utilizationMinutes,
|
||||||
|
units: r.units,
|
||||||
|
scenario: 'actual',
|
||||||
|
})),
|
||||||
|
...(a.automatedAssignedResources ?? []).map((r) => ({
|
||||||
|
activity_id: a.id,
|
||||||
|
resource_id: r.resourceId,
|
||||||
|
utilization_minutes: r.utilizationMinutes,
|
||||||
|
units: r.units,
|
||||||
|
scenario: 'automated',
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (assignments.length > 0) {
|
||||||
|
const { error: insError } = await supabase
|
||||||
|
.from('activity_resource_assignments')
|
||||||
|
.insert(assignments)
|
||||||
|
if (insError) throw insError
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Un solo round-trip: activities + assignments anidados en una query
|
||||||
|
async getByProcess(processId: string): Promise<Activity[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('activities')
|
||||||
|
.select('*, activity_resource_assignments(*)')
|
||||||
|
.eq('process_id', processId)
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
return (data ?? []).map((row) => {
|
||||||
|
const { actual, automated } = extractAssignments(row as Record<string, unknown>)
|
||||||
|
return fromRow(row as Record<string, unknown>, actual, automated)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Un solo round-trip: actividad + assignments anidados
|
||||||
|
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('activities')
|
||||||
|
.select('*, activity_resource_assignments(*)')
|
||||||
|
.eq('process_id', processId)
|
||||||
|
.eq('bpmn_element_id', bpmnElementId)
|
||||||
|
.single()
|
||||||
|
if (error) return undefined
|
||||||
|
|
||||||
|
const { actual, automated } = extractAssignments(data as Record<string, unknown>)
|
||||||
|
return fromRow(data as Record<string, unknown>, actual, automated)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteByProcess(processId: string): Promise<void> {
|
||||||
|
// CASCADE elimina activity_resource_assignments automáticamente
|
||||||
|
const { error } = await supabase.from('activities').delete().eq('process_id', processId)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
55
src/persistence/supabase/gateway-repo.ts
Normal file
55
src/persistence/supabase/gateway-repo.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { GatewayConfig } from '@/domain/types'
|
||||||
|
|
||||||
|
function fromRow(row: Record<string, unknown>): GatewayConfig {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
processId: row.process_id as string,
|
||||||
|
bpmnElementId: row.bpmn_element_id as string,
|
||||||
|
gatewayType: row.gateway_type as GatewayConfig['gatewayType'],
|
||||||
|
branches: Array.isArray(row.branches) ? row.branches as GatewayConfig['branches'] : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseGatewayRepo = {
|
||||||
|
async save(gateway: GatewayConfig): Promise<void> {
|
||||||
|
const { error } = await supabase.from('gateways').upsert({
|
||||||
|
id: gateway.id,
|
||||||
|
process_id: gateway.processId,
|
||||||
|
bpmn_element_id: gateway.bpmnElementId,
|
||||||
|
gateway_type: gateway.gatewayType,
|
||||||
|
branches: gateway.branches,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveMany(gateways: GatewayConfig[]): Promise<void> {
|
||||||
|
if (gateways.length === 0) return
|
||||||
|
const { error } = await supabase.from('gateways').upsert(
|
||||||
|
gateways.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
process_id: g.processId,
|
||||||
|
bpmn_element_id: g.bpmnElementId,
|
||||||
|
gateway_type: g.gatewayType,
|
||||||
|
branches: g.branches,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByProcess(processId: string): Promise<GatewayConfig[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('gateways')
|
||||||
|
.select('*')
|
||||||
|
.eq('process_id', processId)
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(fromRow)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteByProcess(processId: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('gateways').delete().eq('process_id', processId)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -66,14 +66,12 @@ export const supabaseProcessRepo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
|
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments
|
||||||
const { error } = await supabase.from('processes').delete().eq('id', id)
|
const { error } = await supabase.from('processes').delete().eq('id', id)
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
// TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase
|
// TODO Sprint 4 Etapa 4: eliminar cuando simulaciones migren a Supabase
|
||||||
await db.transaction('rw', [db.activities, db.gateways, db.resources, db.simulations], async () => {
|
await db.transaction('rw', [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()
|
await db.simulations.where('processId').equals(id).delete()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
40
src/persistence/supabase/resource-repo.ts
Normal file
40
src/persistence/supabase/resource-repo.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { Resource } from '@/domain/types'
|
||||||
|
|
||||||
|
function fromRow(row: Record<string, unknown>): Resource {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
type: row.type as Resource['type'],
|
||||||
|
costPerHour: Number(row.cost_per_hour ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabaseResourceRepo = {
|
||||||
|
async save(resource: Resource, userId: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('resources').upsert({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
type: resource.type,
|
||||||
|
cost_per_hour: resource.costPerHour,
|
||||||
|
created_by: userId,
|
||||||
|
updated_by: userId,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAll(): Promise<Resource[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(fromRow)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(resourceId: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('resources').delete().eq('id', resourceId)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -47,7 +47,11 @@ function RootComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user && pathname !== '/login') return null
|
if (!user && pathname !== '/login') return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
|||||||
load: async () => {
|
load: async () => {
|
||||||
set({ isLoading: true })
|
set({ isLoading: true })
|
||||||
try {
|
try {
|
||||||
const [groups, processes, settings] = await Promise.all([
|
const TIMEOUT_MS = 8_000
|
||||||
supabaseGroupRepo.getAll(),
|
const [groups, processes, settings] = await Promise.race([
|
||||||
supabaseProcessRepo.getAll(),
|
Promise.all([
|
||||||
supabaseSettingsRepo.get(),
|
supabaseGroupRepo.getAll(),
|
||||||
|
supabaseProcessRepo.getAll(),
|
||||||
|
supabaseSettingsRepo.get(),
|
||||||
|
]),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error('Timeout: la biblioteca tardó más de 8s en cargar')),
|
||||||
|
TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
),
|
||||||
])
|
])
|
||||||
set({ groups, processes, settings, isLoading: false })
|
set({ groups, processes, settings, isLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
import { activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories'
|
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||||
|
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
|
||||||
|
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
||||||
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
|
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
|
||||||
|
|
||||||
interface ProcessState {
|
interface ProcessState {
|
||||||
@@ -35,9 +37,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const [process, activities, gateways, resources] = await Promise.all([
|
const [process, activities, gateways, resources] = await Promise.all([
|
||||||
supabaseProcessRepo.getById(processId),
|
supabaseProcessRepo.getById(processId),
|
||||||
activityRepo.getByProcess(processId),
|
supabaseActivityRepo.getByProcess(processId),
|
||||||
gatewayRepo.getByProcess(processId),
|
supabaseGatewayRepo.getByProcess(processId),
|
||||||
resourceRepo.getByProcess(processId),
|
supabaseResourceRepo.getAll(),
|
||||||
])
|
])
|
||||||
if (!process) throw new Error('Proceso no encontrado')
|
if (!process) throw new Error('Proceso no encontrado')
|
||||||
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
|
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
|
||||||
@@ -49,9 +51,9 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
setCurrentProcess: async (process, userId) => {
|
setCurrentProcess: async (process, userId) => {
|
||||||
await supabaseProcessRepo.save(process, userId)
|
await supabaseProcessRepo.save(process, userId)
|
||||||
const [activities, gateways, resources] = await Promise.all([
|
const [activities, gateways, resources] = await Promise.all([
|
||||||
activityRepo.getByProcess(process.id),
|
supabaseActivityRepo.getByProcess(process.id),
|
||||||
gatewayRepo.getByProcess(process.id),
|
supabaseGatewayRepo.getByProcess(process.id),
|
||||||
resourceRepo.getByProcess(process.id),
|
supabaseResourceRepo.getAll(),
|
||||||
])
|
])
|
||||||
set({ currentProcess: process, activities, gateways, resources })
|
set({ currentProcess: process, activities, gateways, resources })
|
||||||
},
|
},
|
||||||
@@ -65,7 +67,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateActivity: async (activity, userId) => {
|
updateActivity: async (activity, userId) => {
|
||||||
await activityRepo.save(activity)
|
await supabaseActivityRepo.save(activity, userId)
|
||||||
const current = get().currentProcess
|
const current = get().currentProcess
|
||||||
if (current) {
|
if (current) {
|
||||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||||
@@ -82,7 +84,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateGateway: async (gateway, userId) => {
|
updateGateway: async (gateway, userId) => {
|
||||||
await gatewayRepo.save(gateway)
|
await supabaseGatewayRepo.save(gateway)
|
||||||
const current = get().currentProcess
|
const current = get().currentProcess
|
||||||
if (current) {
|
if (current) {
|
||||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||||
@@ -99,7 +101,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addResource: async (resource, userId) => {
|
addResource: async (resource, userId) => {
|
||||||
await resourceRepo.save(resource)
|
await supabaseResourceRepo.save(resource, userId)
|
||||||
const current = get().currentProcess
|
const current = get().currentProcess
|
||||||
if (current) {
|
if (current) {
|
||||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||||
@@ -111,7 +113,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateResource: async (resource, userId) => {
|
updateResource: async (resource, userId) => {
|
||||||
await resourceRepo.save(resource)
|
await supabaseResourceRepo.save(resource, userId)
|
||||||
const current = get().currentProcess
|
const current = get().currentProcess
|
||||||
if (current) {
|
if (current) {
|
||||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||||
@@ -128,7 +130,7 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteResource: async (resourceId, userId) => {
|
deleteResource: async (resourceId, userId) => {
|
||||||
await resourceRepo.delete(resourceId)
|
await supabaseResourceRepo.delete(resourceId)
|
||||||
const current = get().currentProcess
|
const current = get().currentProcess
|
||||||
if (current) {
|
if (current) {
|
||||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||||
|
|||||||
16
supabase/migrations/005_create_resources.sql
Normal file
16
supabase/migrations/005_create_resources.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
CREATE POLICY "all_resources" ON public.resources
|
||||||
|
FOR ALL USING (auth.uid() IS NOT NULL)
|
||||||
|
WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
25
supabase/migrations/006_create_activities.sql
Normal file
25
supabase/migrations/006_create_activities.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
18
supabase/migrations/008_create_gateways.sql
Normal file
18
supabase/migrations/008_create_gateways.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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);
|
||||||
Reference in New Issue
Block a user