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 & { 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) { 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(WEBHOOK_EVENT_TYPES.map(e => e.value)) export const useWebhooksStore = defineStore('webhooks', () => { const webhooks: Ref = ref([]) const deliveries: Ref = ref([]) const deliveryPagination: Ref = 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 { 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 { 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 { 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 { 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, } })