Ctrl K
ComunidadGuías…

src/stores/webhooks.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { Ref } from 'vue'
import type { Database } from '~/types/database.types'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invokeCapgoApi } from '~/services/capgoApi'
import { errorMessage } from '~/utils/errors'
import { useOrganizationStore } from './organization'

export interface DeliveryPagination {
  page: number
  per_page: number
  total: number
  has_more: boolean
}

export interface TestResult {
  success: boolean
  status: number | null
  duration_ms: number | null
  response_preview: string | null
  delivery_id: string
  message: string
}

export type Webhook = Omit<Database['public']['Tables']['webhooks']['Row'], 'secret'> & {
  secret?: string
}

export type WebhookDeliveryVersion = 'legacy' | 'standard'

// Supported event types
export const WEBHOOK_EVENT_TYPES = [
  { value: 'apps', label: 'App Changes', description: 'When apps are created, updated, or deleted' },
  { value: 'app_versions', label: 'Bundle Changes', description: 'When bundles are created, updated, or deleted' },
  { value: 'channels', label: 'Channel Updates', description: 'When channels are modified' },
  { value: 'org_users', label: 'Member Changes', description: 'When members are added or removed' },
  { value: 'orgs', label: 'Organization Changes', description: 'When organization settings are updated' },
] as const

function buildWebhookFunctionPath(path: string, params: Record<string, string | number | undefined>) {
  const query = new URLSearchParams()
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined)
      query.set(key, String(value))
  })

  const queryString = query.toString()
  return queryString ? `${path}?${queryString}` : path
}

function getFunctionErrorMessage(error: unknown, fallback: string) {
  return errorMessage(error, fallback)
}

const VALID_WEBHOOK_EVENT_VALUES = new Set<string>(WEBHOOK_EVENT_TYPES.map(e => e.value))

export const useWebhooksStore = defineStore('webhooks', () => {
  const webhooks: Ref<Webhook[]> = ref([])
  const deliveries: Ref<Database['public']['Tables']['webhook_deliveries']['Row'][]> = ref([])
  const deliveryPagination: Ref<DeliveryPagination | null> = ref(null)
  const isLoading = ref(false)
  const isLoadingDeliveries = ref(false)

  /**
   * Fetch all webhooks for the current organization
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function fetchWebhooks(): Promise<void> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      console.error('No organization selected')
      return
    }

    isLoading.value = true
    try {
      const { data, error } = await invokeCapgoApi(buildWebhookFunctionPath('webhooks', { orgId }), {
        method: 'GET',
      })

      if (error) {
        console.error('Failed to fetch webhooks:', error)
        return
      }

      webhooks.value = Array.isArray(data) ? data : []
    }
    catch (err) {
      console.error('Error fetching webhooks:', err)
    }
    finally {
      isLoading.value = false
    }
  }

  /**
   * Get a single webhook
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function getWebhook(webhookId: string): Promise<Webhook | null> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      console.error('No organization selected')
      return null
    }

    try {
      const { data, error } = await invokeCapgoApi(buildWebhookFunctionPath('webhooks', { orgId, webhookId }), {
        method: 'GET',
      })

      if (error) {
        console.error('Failed to fetch webhook:', error)
        return null
      }

      return data
    }
    catch (err) {
      console.error('Error fetching webhook:', err)
      return null
    }
  }

  /**
   * Create a new webhook
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function createWebhook(webhookData: {
    name: string
    url: string
    events: string[]
    deliveryVersion?: WebhookDeliveryVersion
  }): Promise<{ success: boolean, webhook?: Webhook, error?: string }> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      return { success: false, error: 'No organization selected' }
    }

    // Validate URL is HTTPS (except localhost for testing)
    try {
      const parsedUrl = new URL(webhookData.url)
      const isLocalhost = parsedUrl.hostname === 'localhost' || parsedUrl.hostname.endsWith('.localhost')
      const isLoopback = parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === '::1'
      if (parsedUrl.protocol !== 'https:' && !isLocalhost && !isLoopback) {
        return { success: false, error: 'Webhook URL must use HTTPS' }
      }
    }
    catch {
      return { success: false, error: 'Invalid URL' }
    }

    // Validate events
    const invalidEvents = webhookData.events.filter(e => !VALID_WEBHOOK_EVENT_VALUES.has(e))
    if (invalidEvents.length > 0) {
      return { success: false, error: `Invalid event types: ${invalidEvents.join(', ')}` }
    }

    try {
      const { data, error } = await invokeCapgoApi('webhooks', {
        method: 'POST',
        body: {
          orgId,
          name: webhookData.name,
          url: webhookData.url,
          events: webhookData.events,
          enabled: true,
          deliveryVersion: webhookData.deliveryVersion ?? 'legacy',
        },
      })

      if (error) {
        return { success: false, error: getFunctionErrorMessage(error, 'Failed to create webhook') }
      }

      // Add to local list
      if (data?.webhook) {
        webhooks.value.unshift(data.webhook)
      }

      return { success: true, webhook: data?.webhook }
    }
    catch (err: unknown) {
      return { success: false, error: errorMessage(err, 'Error creating webhook') }
    }
  }

  /**
   * Update an existing webhook
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function updateWebhook(
    webhookId: string,
    webhookData: Partial<{
      name: string
      url: string
      events: string[]
      enabled: boolean
      deliveryVersion: WebhookDeliveryVersion
    }>,
  ): Promise<{ success: boolean, webhook?: Webhook, error?: string }> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      return { success: false, error: 'No organization selected' }
    }

    // Validate URL if provided
    if (webhookData.url) {
      try {
        const parsedUrl = new URL(webhookData.url)
        const isLocalhost = parsedUrl.hostname === 'localhost' || parsedUrl.hostname.endsWith('.localhost')
        const isLoopback = parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === '::1'
        if (parsedUrl.protocol !== 'https:' && !isLocalhost && !isLoopback) {
          return { success: false, error: 'Webhook URL must use HTTPS' }
        }
      }
      catch {
        return { success: false, error: 'Invalid URL' }
      }
    }

    // Validate events if provided
    if (webhookData.events) {
      const invalidEvents = webhookData.events.filter(e => !VALID_WEBHOOK_EVENT_VALUES.has(e))
      if (invalidEvents.length > 0) {
        return { success: false, error: `Invalid event types: ${invalidEvents.join(', ')}` }
      }
    }

    try {
      const { data, error } = await invokeCapgoApi('webhooks', {
        method: 'PUT',
        body: {
          orgId,
          webhookId,
          ...webhookData,
        },
      })

      if (error) {
        return { success: false, error: getFunctionErrorMessage(error, 'Failed to update webhook') }
      }

      // Update local list
      if (data?.webhook) {
        const index = webhooks.value.findIndex(w => w.id === webhookId)
        if (index !== -1) {
          webhooks.value[index] = data.webhook
        }
      }

      return { success: true, webhook: data?.webhook }
    }
    catch (err: unknown) {
      return { success: false, error: errorMessage(err, 'Error updating webhook') }
    }
  }

  /**
   * Delete a webhook
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function deleteWebhook(webhookId: string): Promise<{ success: boolean, error?: string }> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      return { success: false, error: 'No organization selected' }
    }

    try {
      const { error } = await invokeCapgoApi('webhooks', {
        method: 'DELETE',
        body: { orgId, webhookId },
      })

      if (error) {
        return { success: false, error: getFunctionErrorMessage(error, 'Failed to delete webhook') }
      }

      // Remove from local list
      webhooks.value = webhooks.value.filter(w => w.id !== webhookId)

      return { success: true }
    }
    catch (err: unknown) {
      return { success: false, error: errorMessage(err, 'Error deleting webhook') }
    }
  }

  /**
   * Test a webhook - requires edge function to make actual HTTP call
   */
  async function testWebhook(webhookId: string): Promise<TestResult> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      return {
        success: false,
        status: null,
        duration_ms: null,
        response_preview: null,
        delivery_id: '',
        message: 'No organization selected',
      }
    }

    try {
      const { data, error } = await invokeCapgoApi('webhooks/test', {
        method: 'POST',
        body: { orgId, webhookId },
      })

      if (error) {
        return {
          success: false,
          status: null,
          duration_ms: null,
          response_preview: null,
          delivery_id: '',
          message: getFunctionErrorMessage(error, 'Failed to test webhook'),
        }
      }

      return data
    }
    catch (err: unknown) {
      return {
        success: false,
        status: null,
        duration_ms: null,
        response_preview: null,
        delivery_id: '',
        message: errorMessage(err, 'Error testing webhook'),
      }
    }
  }

  /**
   * Fetch deliveries for a webhook
   * Uses the webhook API so secrets and delivery data never go through direct table access.
   */
  async function fetchDeliveries(webhookId: string, page = 0, status?: string): Promise<void> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      console.error('No organization selected')
      return
    }

    isLoadingDeliveries.value = true
    try {
      const { data, error } = await invokeCapgoApi(buildWebhookFunctionPath('webhooks/deliveries', {
        orgId,
        webhookId,
        page,
        status,
      }), {
        method: 'GET',
      })

      if (error) {
        console.error('Failed to fetch deliveries:', error)
        return
      }

      deliveries.value = data?.deliveries || []
      deliveryPagination.value = data?.pagination || null
    }
    catch (err) {
      console.error('Error fetching deliveries:', err)
    }
    finally {
      isLoadingDeliveries.value = false
    }
  }

  /**
   * Retry a failed delivery - requires edge function to queue the retry
   */
  async function retryDelivery(deliveryId: string): Promise<{ success: boolean, error?: string }> {
    const organizationStore = useOrganizationStore()
    const orgId = organizationStore.currentOrganization?.gid

    if (!orgId) {
      return { success: false, error: 'No organization selected' }
    }

    try {
      const { error } = await invokeCapgoApi('webhooks/deliveries/retry', {
        method: 'POST',
        body: { orgId, deliveryId },
      })

      if (error) {
        return { success: false, error: getFunctionErrorMessage(error, 'Failed to retry delivery') }
      }

      // Update local delivery status
      const index = deliveries.value.findIndex(d => d.id === deliveryId)
      if (index !== -1) {
        deliveries.value[index].status = 'pending'
        deliveries.value[index].attempt_count = 0
      }

      return { success: true }
    }
    catch (err: unknown) {
      return { success: false, error: errorMessage(err, 'Error retrying delivery') }
    }
  }

  /**
   * Toggle webhook enabled state
   */
  async function toggleWebhook(webhookId: string): Promise<{ success: boolean, error?: string }> {
    const webhook = webhooks.value.find(w => w.id === webhookId)
    if (!webhook) {
      return { success: false, error: 'Webhook not found' }
    }

    return updateWebhook(webhookId, { enabled: !webhook.enabled })
  }

  /**
   * Clear store state
   */
  function reset(): void {
    webhooks.value = []
    deliveries.value = []
    deliveryPagination.value = null
    isLoading.value = false
    isLoadingDeliveries.value = false
  }

  return {
    // State
    webhooks,
    deliveries,
    deliveryPagination,
    isLoading,
    isLoadingDeliveries,

    // Actions
    fetchWebhooks,
    getWebhook,
    createWebhook,
    updateWebhook,
    deleteWebhook,
    testWebhook,
    fetchDeliveries,
    retryDelivery,
    toggleWebhook,
    reset,
  }
})