realtimex-crm 0.8.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
  4. package/dist/assets/index-C__S90Gb.css +1 -0
  5. package/dist/assets/index-mE-upBfc.js +166 -0
  6. package/dist/assets/index-mE-upBfc.js.map +1 -0
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +3 -1
  10. package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
  11. package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
  12. package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
  13. package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
  14. package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
  15. package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
  16. package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
  17. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  18. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
  19. package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
  20. package/src/components/atomic-crm/layout/Header.tsx +14 -1
  21. package/src/components/atomic-crm/root/CRM.tsx +2 -0
  22. package/src/components/ui/alert-dialog.tsx +155 -0
  23. package/src/lib/api-key-utils.ts +22 -0
  24. package/supabase/fix_webhook_hardcoded.sql +34 -0
  25. package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
  26. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  27. package/supabase/functions/_shared/utils.ts +1 -1
  28. package/supabase/functions/_shared/webhookSignature.ts +23 -0
  29. package/supabase/functions/api-v1-activities/index.ts +137 -0
  30. package/supabase/functions/api-v1-companies/index.ts +166 -0
  31. package/supabase/functions/api-v1-contacts/index.ts +171 -0
  32. package/supabase/functions/api-v1-deals/index.ts +166 -0
  33. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  34. package/supabase/functions/ingest-activity/index.ts +261 -0
  35. package/supabase/functions/webhook-dispatcher/index.ts +133 -0
  36. package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
  37. package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
  38. package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
  39. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  40. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  41. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  42. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  43. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  44. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  45. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  46. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  47. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  48. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  49. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  50. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  51. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  52. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  53. package/dist/assets/index-CHk72bAf.css +0 -1
  54. package/dist/assets/index-mhAjQ1_w.js +0 -153
  55. package/dist/assets/index-mhAjQ1_w.js.map +0 -1
@@ -0,0 +1,261 @@
1
+ import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
+ import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
3
+ import { createErrorResponse, corsHeaders } from "../_shared/utils.ts";
4
+ import { validateIngestionRequest, validateTwilioWebhook } from "../_shared/ingestionGuard.ts";
5
+
6
+ /**
7
+ * Sanitize filename to remove special characters that break storage paths
8
+ * Preserves extension and basic readability while keeping original in metadata
9
+ */
10
+ function sanitizeFilename(filename: string): string {
11
+ // Get file extension
12
+ const lastDotIndex = filename.lastIndexOf('.');
13
+ const name = lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
14
+ const ext = lastDotIndex > 0 ? filename.substring(lastDotIndex) : '';
15
+
16
+ // Replace special characters with underscores, keep alphanumeric and basic punctuation
17
+ const safeName = name
18
+ .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
19
+ .replace(/_+/g, '_') // Collapse multiple underscores
20
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
21
+
22
+ // Limit length to avoid path issues (200 chars for name + extension)
23
+ const maxLength = 200 - ext.length;
24
+ const truncatedName = safeName.length > maxLength
25
+ ? safeName.substring(0, maxLength)
26
+ : safeName;
27
+
28
+ return truncatedName + ext;
29
+ }
30
+
31
+ Deno.serve(async (req) => {
32
+ // Handle CORS
33
+ if (req.method === "OPTIONS") {
34
+ return new Response(null, { status: 204, headers: corsHeaders });
35
+ }
36
+
37
+ try {
38
+ // 1. Validate & Identify Provider
39
+ const validation = await validateIngestionRequest(req);
40
+ if (validation.error) return validation.error;
41
+
42
+ const { provider } = validation;
43
+
44
+ // 2. Parse Body based on Content-Type
45
+ const contentType = req.headers.get("content-type") || "";
46
+ let rawBody: any;
47
+ const uploadedFiles: Array<{ fieldName: string; storagePath: string; size: number; type: string }> = [];
48
+
49
+ if (contentType.includes("application/json")) {
50
+ rawBody = await req.json();
51
+ } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
52
+ const formData = await req.formData();
53
+ rawBody = {};
54
+
55
+ // Process FormData entries (files + fields)
56
+ for (const [key, value] of formData.entries()) {
57
+ if (value instanceof File) {
58
+ // Handle file upload: upload to storage immediately
59
+ const file = value as File;
60
+ const timestamp = Date.now();
61
+
62
+ // Sanitize filename to remove special characters that break storage paths
63
+ // Keep original filename in metadata, use safe version for storage path
64
+ const sanitizedName = sanitizeFilename(file.name);
65
+ const storagePath = `incoming/${timestamp}_${sanitizedName}`;
66
+
67
+ console.log(`Uploading file: ${file.name} → ${sanitizedName} (${file.size} bytes, ${file.type})`);
68
+
69
+ const { error: uploadError } = await supabaseAdmin.storage
70
+ .from('activity-payloads')
71
+ .upload(storagePath, file, {
72
+ contentType: file.type || 'application/octet-stream',
73
+ upsert: false,
74
+ });
75
+
76
+ if (uploadError) {
77
+ console.error(`Failed to upload file ${file.name}:`, uploadError);
78
+ return createErrorResponse(500, `File upload failed: ${uploadError.message}`);
79
+ }
80
+
81
+ console.log(`File uploaded successfully: ${storagePath}`);
82
+
83
+ // Store file reference instead of file content
84
+ uploadedFiles.push({
85
+ fieldName: key,
86
+ storagePath,
87
+ size: file.size,
88
+ type: file.type || 'application/octet-stream',
89
+ });
90
+
91
+ // Add storage reference to rawBody
92
+ rawBody[key] = {
93
+ _type: 'file_ref',
94
+ storage_path: storagePath,
95
+ filename: file.name,
96
+ size: file.size,
97
+ mime_type: file.type,
98
+ };
99
+ } else {
100
+ // Regular form field
101
+ rawBody[key] = value;
102
+ }
103
+ }
104
+ } else {
105
+ rawBody = await req.text();
106
+ }
107
+
108
+ // 2.5. Validate Twilio Signature (after body is parsed)
109
+ if (provider.provider_code === "twilio" && provider.config?.auth_token) {
110
+ const isValid = await validateTwilioWebhook(req, provider.config.auth_token, rawBody);
111
+ if (!isValid) {
112
+ console.error("Invalid Twilio signature");
113
+ return createErrorResponse(401, "Invalid Twilio Signature");
114
+ }
115
+ }
116
+
117
+ // 3. Normalize Data (The "Normalizer" Pattern)
118
+ const normalized = normalizeActivity(provider.provider_code, rawBody);
119
+
120
+ // 4. Add file upload metadata if files were uploaded
121
+ const activityMetadata = {
122
+ ...normalized.metadata,
123
+ provider_code: provider.provider_code,
124
+ };
125
+
126
+ if (uploadedFiles.length > 0) {
127
+ activityMetadata.uploaded_files = uploadedFiles;
128
+ activityMetadata.has_attachments = true;
129
+ }
130
+
131
+ // 5. Insert into 'activities' table
132
+ const { data, error } = await supabaseAdmin
133
+ .from("activities")
134
+ .insert({
135
+ type: normalized.type,
136
+ direction: "inbound",
137
+ processing_status: "raw", // Ready for Local Agent to steal
138
+ raw_data: normalized.raw_data,
139
+ metadata: activityMetadata,
140
+ provider_id: provider.id,
141
+ sales_id: provider.sales_id, // Auto-assign if provider has an owner
142
+ payload_storage_status: uploadedFiles.length > 0 ? 'in_storage' : undefined, // Files uploaded directly to storage
143
+ })
144
+ .select()
145
+ .single();
146
+
147
+ if (error) {
148
+ console.error("DB Insert Error:", error);
149
+ console.error("Error details:", JSON.stringify(error, null, 2));
150
+ return createErrorResponse(500, `Failed to persist activity: ${error.message || JSON.stringify(error)}`);
151
+ }
152
+
153
+ return new Response(JSON.stringify({ success: true, id: data.id }), {
154
+ status: 202, // Accepted
155
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
156
+ });
157
+
158
+ } catch (err) {
159
+ console.error("Ingestion Error:", err);
160
+ return createErrorResponse(500, "Internal Server Error");
161
+ }
162
+ });
163
+
164
+ /**
165
+ * Normalizes provider-specific payloads into the standard RealTimeX schema.
166
+ */
167
+ function normalizeActivity(providerCode: string, payload: any) {
168
+ let type = "other";
169
+ let raw_data = {};
170
+ let metadata = {};
171
+
172
+ if (providerCode === "postmark") {
173
+ type = "email";
174
+ raw_data = {
175
+ source_type: "text",
176
+ content: payload.TextBody || payload.HtmlBody || "",
177
+ subject: payload.Subject,
178
+ sender: payload.From,
179
+ };
180
+ metadata = {
181
+ message_id: payload.MessageID,
182
+ to: payload.To,
183
+ };
184
+ } else if (providerCode === "twilio") {
185
+ // Detect SMS vs Voice
186
+ if (payload.RecordingUrl) {
187
+ type = "call";
188
+ raw_data = {
189
+ source_type: "url",
190
+ content: payload.RecordingUrl, // The Local Agent will download this
191
+ format: "audio/wav",
192
+ };
193
+ metadata = {
194
+ call_sid: payload.CallSid,
195
+ from: payload.From,
196
+ duration: payload.CallDuration,
197
+ };
198
+ } else {
199
+ type = "sms";
200
+ raw_data = {
201
+ source_type: "text",
202
+ content: payload.Body,
203
+ };
204
+ metadata = {
205
+ message_sid: payload.MessageSid,
206
+ from: payload.From,
207
+ };
208
+ }
209
+ } else {
210
+ // Generic / Manual / Multipart
211
+ type = payload.type || "note";
212
+
213
+ // Check if payload contains file references from multipart upload
214
+ const fileFields = Object.entries(payload).filter(([_, value]) =>
215
+ typeof value === 'object' && value !== null && value._type === 'file_ref'
216
+ );
217
+
218
+ if (fileFields.length > 0) {
219
+ // Handle uploaded files
220
+ if (fileFields.length === 1) {
221
+ // Single file - store in raw_data
222
+ const [_fieldName, fileRef] = fileFields[0];
223
+ raw_data = {
224
+ source_type: "storage_ref",
225
+ storage_path: (fileRef as any).storage_path,
226
+ filename: (fileRef as any).filename,
227
+ format: (fileRef as any).mime_type,
228
+ size: (fileRef as any).size,
229
+ };
230
+ } else {
231
+ // Multiple files - store array in raw_data
232
+ raw_data = {
233
+ source_type: "storage_refs",
234
+ files: fileFields.map(([fieldName, fileRef]) => ({
235
+ storage_path: (fileRef as any).storage_path,
236
+ filename: (fileRef as any).filename,
237
+ format: (fileRef as any).mime_type,
238
+ size: (fileRef as any).size,
239
+ field_name: fieldName,
240
+ })),
241
+ };
242
+ }
243
+
244
+ // Include other form fields in metadata
245
+ metadata = {
246
+ ...payload.metadata,
247
+ form_data: Object.fromEntries(
248
+ Object.entries(payload).filter(([key]) =>
249
+ !key.startsWith('_') && typeof payload[key] !== 'object'
250
+ )
251
+ ),
252
+ };
253
+ } else {
254
+ // Regular JSON payload
255
+ raw_data = payload.raw_data || { source_type: "text", content: JSON.stringify(payload) };
256
+ metadata = payload.metadata || {};
257
+ }
258
+ }
259
+
260
+ return { type, raw_data, metadata };
261
+ }
@@ -0,0 +1,133 @@
1
+ import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
+ import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
3
+ import { generateWebhookSignature } from "../_shared/webhookSignature.ts";
4
+
5
+ Deno.serve(async () => {
6
+ console.log("Webhook dispatcher running...");
7
+
8
+ // Get pending webhook deliveries
9
+ const { data: queueItems } = await supabaseAdmin
10
+ .from("webhook_queue")
11
+ .select("*, webhooks(*)")
12
+ .eq("status", "pending")
13
+ .lte("next_retry_at", new Date().toISOString())
14
+ .limit(50);
15
+
16
+ if (!queueItems || queueItems.length === 0) {
17
+ return new Response(JSON.stringify({ processed: 0 }), {
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+ }
21
+
22
+ console.log(`Processing ${queueItems.length} webhook deliveries`);
23
+
24
+ const results = await Promise.allSettled(
25
+ queueItems.map((item) => deliverWebhook(item))
26
+ );
27
+
28
+ const successful = results.filter((r) => r.status === "fulfilled").length;
29
+ const failed = results.filter((r) => r.status === "rejected").length;
30
+
31
+ return new Response(
32
+ JSON.stringify({ processed: queueItems.length, successful, failed }),
33
+ { headers: { "Content-Type": "application/json" } }
34
+ );
35
+ });
36
+
37
+ async function deliverWebhook(queueItem: any) {
38
+ const webhook = queueItem.webhooks;
39
+
40
+ // Mark as processing
41
+ await supabaseAdmin
42
+ .from("webhook_queue")
43
+ .update({ status: "processing" })
44
+ .eq("id", queueItem.id);
45
+
46
+ try {
47
+ const payloadString = JSON.stringify(queueItem.payload);
48
+ const signature = await generateWebhookSignature(
49
+ webhook.secret,
50
+ payloadString
51
+ );
52
+
53
+ const headers: Record<string, string> = {
54
+ "Content-Type": "application/json",
55
+ "X-Webhook-Signature": signature,
56
+ "X-Webhook-Event": queueItem.event_type,
57
+ "User-Agent": "AtomicCRM-Webhooks/1.0",
58
+ };
59
+
60
+ // Add custom headers from webhook config
61
+ if (webhook.headers) {
62
+ Object.assign(headers, webhook.headers);
63
+ }
64
+
65
+ const response = await fetch(webhook.url, {
66
+ method: "POST",
67
+ headers,
68
+ body: payloadString,
69
+ signal: AbortSignal.timeout(30000), // 30 second timeout
70
+ });
71
+
72
+ if (response.ok) {
73
+ // Success
74
+ await supabaseAdmin
75
+ .from("webhook_queue")
76
+ .update({
77
+ status: "delivered",
78
+ delivered_at: new Date().toISOString(),
79
+ })
80
+ .eq("id", queueItem.id);
81
+
82
+ await supabaseAdmin
83
+ .from("webhooks")
84
+ .update({
85
+ last_triggered_at: new Date().toISOString(),
86
+ failure_count: 0,
87
+ })
88
+ .eq("id", webhook.id);
89
+
90
+ console.log(`Webhook ${queueItem.id} delivered successfully`);
91
+ } else {
92
+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
93
+ }
94
+ } catch (error) {
95
+ console.error(`Webhook ${queueItem.id} delivery failed:`, error);
96
+
97
+ const newAttempts = queueItem.attempts + 1;
98
+ const shouldRetry = newAttempts < queueItem.max_attempts;
99
+
100
+ if (shouldRetry) {
101
+ // Exponential backoff: 1min, 5min, 15min
102
+ const retryDelays = [60, 300, 900];
103
+ const delaySeconds = retryDelays[newAttempts - 1] || 900;
104
+ const nextRetry = new Date(Date.now() + delaySeconds * 1000)
105
+ .toISOString();
106
+
107
+ await supabaseAdmin
108
+ .from("webhook_queue")
109
+ .update({
110
+ status: "pending",
111
+ attempts: newAttempts,
112
+ next_retry_at: nextRetry,
113
+ error_message: error.message,
114
+ })
115
+ .eq("id", queueItem.id);
116
+ } else {
117
+ // Max attempts reached
118
+ await supabaseAdmin
119
+ .from("webhook_queue")
120
+ .update({
121
+ status: "failed",
122
+ attempts: newAttempts,
123
+ error_message: error.message,
124
+ })
125
+ .eq("id", queueItem.id);
126
+
127
+ await supabaseAdmin
128
+ .from("webhooks")
129
+ .update({ failure_count: webhook.failure_count + 1 })
130
+ .eq("id", webhook.id);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,133 @@
1
+ -- API Keys table
2
+ create table "public"."api_keys" (
3
+ "id" bigint generated by default as identity not null,
4
+ "created_at" timestamp with time zone not null default now(),
5
+ "sales_id" bigint not null,
6
+ "name" text not null,
7
+ "key_hash" text not null,
8
+ "key_prefix" text not null,
9
+ "scopes" text[] not null default '{}',
10
+ "is_active" boolean not null default true,
11
+ "expires_at" timestamp with time zone,
12
+ "last_used_at" timestamp with time zone,
13
+ "created_by_sales_id" bigint not null
14
+ );
15
+
16
+ alter table "public"."api_keys" enable row level security;
17
+
18
+ CREATE UNIQUE INDEX api_keys_pkey ON public.api_keys USING btree (id);
19
+ CREATE UNIQUE INDEX api_keys_key_hash_unique ON public.api_keys USING btree (key_hash);
20
+ CREATE INDEX api_keys_sales_id_idx ON public.api_keys USING btree (sales_id);
21
+
22
+ alter table "public"."api_keys" add constraint "api_keys_pkey" PRIMARY KEY using index "api_keys_pkey";
23
+ alter table "public"."api_keys" add constraint "api_keys_sales_id_fkey"
24
+ FOREIGN KEY (sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
25
+ alter table "public"."api_keys" validate constraint "api_keys_sales_id_fkey";
26
+ alter table "public"."api_keys" add constraint "api_keys_created_by_sales_id_fkey"
27
+ FOREIGN KEY (created_by_sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
28
+ alter table "public"."api_keys" validate constraint "api_keys_created_by_sales_id_fkey";
29
+
30
+ -- Webhooks table
31
+ create table "public"."webhooks" (
32
+ "id" bigint generated by default as identity not null,
33
+ "created_at" timestamp with time zone not null default now(),
34
+ "sales_id" bigint not null,
35
+ "name" text not null,
36
+ "url" text not null,
37
+ "events" text[] not null default '{}',
38
+ "headers" jsonb,
39
+ "is_active" boolean not null default true,
40
+ "secret" text not null,
41
+ "last_triggered_at" timestamp with time zone,
42
+ "failure_count" int not null default 0,
43
+ "created_by_sales_id" bigint not null
44
+ );
45
+
46
+ alter table "public"."webhooks" enable row level security;
47
+
48
+ CREATE UNIQUE INDEX webhooks_pkey ON public.webhooks USING btree (id);
49
+ CREATE INDEX webhooks_sales_id_idx ON public.webhooks USING btree (sales_id);
50
+ CREATE INDEX webhooks_events_idx ON public.webhooks USING gin (events);
51
+
52
+ alter table "public"."webhooks" add constraint "webhooks_pkey" PRIMARY KEY using index "webhooks_pkey";
53
+ alter table "public"."webhooks" add constraint "webhooks_sales_id_fkey"
54
+ FOREIGN KEY (sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
55
+ alter table "public"."webhooks" validate constraint "webhooks_sales_id_fkey";
56
+ alter table "public"."webhooks" add constraint "webhooks_created_by_sales_id_fkey"
57
+ FOREIGN KEY (created_by_sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
58
+ alter table "public"."webhooks" validate constraint "webhooks_created_by_sales_id_fkey";
59
+
60
+ -- API Request Logs table
61
+ create table "public"."api_logs" (
62
+ "id" bigint generated by default as identity not null,
63
+ "created_at" timestamp with time zone not null default now(),
64
+ "api_key_id" bigint not null,
65
+ "endpoint" text not null,
66
+ "method" text not null,
67
+ "status_code" int not null,
68
+ "response_time_ms" int,
69
+ "ip_address" text,
70
+ "user_agent" text,
71
+ "error_message" text
72
+ );
73
+
74
+ alter table "public"."api_logs" enable row level security;
75
+
76
+ CREATE UNIQUE INDEX api_logs_pkey ON public.api_logs USING btree (id);
77
+ CREATE INDEX api_logs_api_key_id_idx ON public.api_logs USING btree (api_key_id);
78
+ CREATE INDEX api_logs_created_at_idx ON public.api_logs USING btree (created_at DESC);
79
+
80
+ alter table "public"."api_logs" add constraint "api_logs_pkey" PRIMARY KEY using index "api_logs_pkey";
81
+ alter table "public"."api_logs" add constraint "api_logs_api_key_id_fkey"
82
+ FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
83
+ alter table "public"."api_logs" validate constraint "api_logs_api_key_id_fkey";
84
+
85
+ -- Webhook Queue table (for async delivery)
86
+ create table "public"."webhook_queue" (
87
+ "id" bigint generated by default as identity not null,
88
+ "created_at" timestamp with time zone not null default now(),
89
+ "webhook_id" bigint not null,
90
+ "event_type" text not null,
91
+ "payload" jsonb not null,
92
+ "status" text not null default 'pending',
93
+ "attempts" int not null default 0,
94
+ "max_attempts" int not null default 3,
95
+ "next_retry_at" timestamp with time zone,
96
+ "delivered_at" timestamp with time zone,
97
+ "error_message" text
98
+ );
99
+
100
+ alter table "public"."webhook_queue" enable row level security;
101
+
102
+ CREATE UNIQUE INDEX webhook_queue_pkey ON public.webhook_queue USING btree (id);
103
+ CREATE INDEX webhook_queue_status_idx ON public.webhook_queue USING btree (status, next_retry_at);
104
+ CREATE INDEX webhook_queue_webhook_id_idx ON public.webhook_queue USING btree (webhook_id);
105
+
106
+ alter table "public"."webhook_queue" add constraint "webhook_queue_pkey" PRIMARY KEY using index "webhook_queue_pkey";
107
+ alter table "public"."webhook_queue" add constraint "webhook_queue_webhook_id_fkey"
108
+ FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
109
+ alter table "public"."webhook_queue" validate constraint "webhook_queue_webhook_id_fkey";
110
+
111
+ -- Grant permissions
112
+ grant select, insert, update, delete on table "public"."api_keys" to "authenticated";
113
+ grant select, insert, update, delete on table "public"."webhooks" to "authenticated";
114
+ grant select on table "public"."api_logs" to "authenticated";
115
+ grant select on table "public"."webhook_queue" to "authenticated";
116
+
117
+ grant all on table "public"."api_keys" to "service_role";
118
+ grant all on table "public"."webhooks" to "service_role";
119
+ grant all on table "public"."api_logs" to "service_role";
120
+ grant all on table "public"."webhook_queue" to "service_role";
121
+
122
+ -- RLS Policies (permissive for authenticated users)
123
+ create policy "Enable all for authenticated users" on "public"."api_keys"
124
+ for all to authenticated using (true) with check (true);
125
+
126
+ create policy "Enable all for authenticated users" on "public"."webhooks"
127
+ for all to authenticated using (true) with check (true);
128
+
129
+ create policy "Enable read for authenticated users" on "public"."api_logs"
130
+ for select to authenticated using (true);
131
+
132
+ create policy "Enable read for authenticated users" on "public"."webhook_queue"
133
+ for select to authenticated using (true);