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.
- package/bin/realtimex-crm.js +56 -32
- package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
- package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
- package/dist/assets/index-C__S90Gb.css +1 -0
- package/dist/assets/index-mE-upBfc.js +166 -0
- package/dist/assets/index-mE-upBfc.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +3 -1
- package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
- package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
- package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
- package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
- package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
- package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
- package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
- package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
- package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
- package/src/components/atomic-crm/layout/Header.tsx +14 -1
- package/src/components/atomic-crm/root/CRM.tsx +2 -0
- package/src/components/ui/alert-dialog.tsx +155 -0
- package/src/lib/api-key-utils.ts +22 -0
- package/supabase/fix_webhook_hardcoded.sql +34 -0
- package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
- package/supabase/functions/_shared/ingestionGuard.ts +128 -0
- package/supabase/functions/_shared/utils.ts +1 -1
- package/supabase/functions/_shared/webhookSignature.ts +23 -0
- package/supabase/functions/api-v1-activities/index.ts +137 -0
- package/supabase/functions/api-v1-companies/index.ts +166 -0
- package/supabase/functions/api-v1-contacts/index.ts +171 -0
- package/supabase/functions/api-v1-deals/index.ts +166 -0
- package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
- package/supabase/functions/ingest-activity/index.ts +261 -0
- package/supabase/functions/webhook-dispatcher/index.ts +133 -0
- package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
- package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
- package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
- package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
- package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
- package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
- package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
- package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
- package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
- package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
- package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
- package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
- package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
- package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
- package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
- package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
- package/dist/assets/index-CHk72bAf.css +0 -1
- package/dist/assets/index-mhAjQ1_w.js +0 -153
- 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);
|