realtimex-crm 0.8.1 → 0.9.1
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/dist/assets/{DealList-B3alafQv.js → DealList-DnGVfS15.js} +2 -2
- package/dist/assets/{DealList-B3alafQv.js.map → DealList-DnGVfS15.js.map} +1 -1
- package/dist/assets/index-DPrpo5Xq.js +159 -0
- package/dist/assets/index-DPrpo5Xq.js.map +1 -0
- package/dist/assets/index-kM1Og1AS.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +2 -1
- 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/IntegrationsPage.tsx +34 -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/functions/_shared/apiKeyAuth.ts +171 -0
- 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/webhook-dispatcher/index.ts +133 -0
- package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +171 -0
- package/supabase/migrations/20251219120200_webhook_cron.sql +26 -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,166 @@
|
|
|
1
|
+
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
2
|
+
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
|
|
3
|
+
import { corsHeaders, createErrorResponse } from "../_shared/utils.ts";
|
|
4
|
+
import {
|
|
5
|
+
validateApiKey,
|
|
6
|
+
checkRateLimit,
|
|
7
|
+
hasScope,
|
|
8
|
+
logApiRequest,
|
|
9
|
+
} from "../_shared/apiKeyAuth.ts";
|
|
10
|
+
|
|
11
|
+
Deno.serve(async (req: Request) => {
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
|
|
14
|
+
if (req.method === "OPTIONS") {
|
|
15
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const authResult = await validateApiKey(req);
|
|
19
|
+
if ("status" in authResult) {
|
|
20
|
+
return authResult;
|
|
21
|
+
}
|
|
22
|
+
const { apiKey } = authResult;
|
|
23
|
+
|
|
24
|
+
const rateLimitError = checkRateLimit(apiKey.id);
|
|
25
|
+
if (rateLimitError) {
|
|
26
|
+
await logApiRequest(
|
|
27
|
+
apiKey.id,
|
|
28
|
+
"/v1/deals",
|
|
29
|
+
req.method,
|
|
30
|
+
429,
|
|
31
|
+
Date.now() - startTime,
|
|
32
|
+
req
|
|
33
|
+
);
|
|
34
|
+
return rateLimitError;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
40
|
+
const dealId = pathParts[1];
|
|
41
|
+
|
|
42
|
+
let response: Response;
|
|
43
|
+
|
|
44
|
+
if (req.method === "GET" && dealId) {
|
|
45
|
+
response = await getDeal(apiKey, dealId);
|
|
46
|
+
} else if (req.method === "POST") {
|
|
47
|
+
response = await createDeal(apiKey, req);
|
|
48
|
+
} else if (req.method === "PATCH" && dealId) {
|
|
49
|
+
response = await updateDeal(apiKey, dealId, req);
|
|
50
|
+
} else if (req.method === "DELETE" && dealId) {
|
|
51
|
+
response = await deleteDeal(apiKey, dealId);
|
|
52
|
+
} else {
|
|
53
|
+
response = createErrorResponse(404, "Not found");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const responseTime = Date.now() - startTime;
|
|
57
|
+
await logApiRequest(
|
|
58
|
+
apiKey.id,
|
|
59
|
+
url.pathname,
|
|
60
|
+
req.method,
|
|
61
|
+
response.status,
|
|
62
|
+
responseTime,
|
|
63
|
+
req
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return response;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const responseTime = Date.now() - startTime;
|
|
69
|
+
await logApiRequest(
|
|
70
|
+
apiKey.id,
|
|
71
|
+
new URL(req.url).pathname,
|
|
72
|
+
req.method,
|
|
73
|
+
500,
|
|
74
|
+
responseTime,
|
|
75
|
+
req,
|
|
76
|
+
error.message
|
|
77
|
+
);
|
|
78
|
+
return createErrorResponse(500, "Internal server error");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function getDeal(apiKey: any, dealId: string) {
|
|
83
|
+
if (!hasScope(apiKey, "deals:read")) {
|
|
84
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { data, error } = await supabaseAdmin
|
|
88
|
+
.from("deals")
|
|
89
|
+
.select("*")
|
|
90
|
+
.eq("id", dealId)
|
|
91
|
+
.single();
|
|
92
|
+
|
|
93
|
+
if (error || !data) {
|
|
94
|
+
return createErrorResponse(404, "Deal not found");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return new Response(JSON.stringify({ data }), {
|
|
98
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function createDeal(apiKey: any, req: Request) {
|
|
103
|
+
if (!hasScope(apiKey, "deals:write")) {
|
|
104
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body = await req.json();
|
|
108
|
+
|
|
109
|
+
const { data, error } = await supabaseAdmin
|
|
110
|
+
.from("deals")
|
|
111
|
+
.insert({
|
|
112
|
+
...body,
|
|
113
|
+
sales_id: apiKey.sales_id,
|
|
114
|
+
})
|
|
115
|
+
.select()
|
|
116
|
+
.single();
|
|
117
|
+
|
|
118
|
+
if (error) {
|
|
119
|
+
return createErrorResponse(400, error.message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Response(JSON.stringify({ data }), {
|
|
123
|
+
status: 201,
|
|
124
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function updateDeal(apiKey: any, dealId: string, req: Request) {
|
|
129
|
+
if (!hasScope(apiKey, "deals:write")) {
|
|
130
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const body = await req.json();
|
|
134
|
+
|
|
135
|
+
const { data, error } = await supabaseAdmin
|
|
136
|
+
.from("deals")
|
|
137
|
+
.update(body)
|
|
138
|
+
.eq("id", dealId)
|
|
139
|
+
.select()
|
|
140
|
+
.single();
|
|
141
|
+
|
|
142
|
+
if (error || !data) {
|
|
143
|
+
return createErrorResponse(404, "Deal not found");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return new Response(JSON.stringify({ data }), {
|
|
147
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function deleteDeal(apiKey: any, dealId: string) {
|
|
152
|
+
if (!hasScope(apiKey, "deals:write")) {
|
|
153
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { error } = await supabaseAdmin
|
|
157
|
+
.from("deals")
|
|
158
|
+
.delete()
|
|
159
|
+
.eq("id", dealId);
|
|
160
|
+
|
|
161
|
+
if (error) {
|
|
162
|
+
return createErrorResponse(404, "Deal not found");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
166
|
+
}
|
|
@@ -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);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
-- Function to enqueue webhook events
|
|
2
|
+
create or replace function enqueue_webhook_event(
|
|
3
|
+
p_event_type text,
|
|
4
|
+
p_payload jsonb
|
|
5
|
+
) returns void
|
|
6
|
+
language plpgsql
|
|
7
|
+
security definer
|
|
8
|
+
set search_path = ''
|
|
9
|
+
as $$
|
|
10
|
+
begin
|
|
11
|
+
-- Find all active webhooks that listen to this event
|
|
12
|
+
insert into public.webhook_queue (webhook_id, event_type, payload, next_retry_at)
|
|
13
|
+
select
|
|
14
|
+
id,
|
|
15
|
+
p_event_type,
|
|
16
|
+
p_payload,
|
|
17
|
+
now()
|
|
18
|
+
from public.webhooks
|
|
19
|
+
where is_active = true
|
|
20
|
+
and p_event_type = any(events);
|
|
21
|
+
end;
|
|
22
|
+
$$;
|
|
23
|
+
|
|
24
|
+
-- Trigger function for contact CRUD events
|
|
25
|
+
create or replace function trigger_contact_webhooks()
|
|
26
|
+
returns trigger
|
|
27
|
+
language plpgsql
|
|
28
|
+
security definer
|
|
29
|
+
set search_path = ''
|
|
30
|
+
as $$
|
|
31
|
+
declare
|
|
32
|
+
event_type text;
|
|
33
|
+
payload jsonb;
|
|
34
|
+
begin
|
|
35
|
+
if (TG_OP = 'INSERT') then
|
|
36
|
+
event_type := 'contact.created';
|
|
37
|
+
payload := to_jsonb(NEW);
|
|
38
|
+
elsif (TG_OP = 'UPDATE') then
|
|
39
|
+
event_type := 'contact.updated';
|
|
40
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
41
|
+
elsif (TG_OP = 'DELETE') then
|
|
42
|
+
event_type := 'contact.deleted';
|
|
43
|
+
payload := to_jsonb(OLD);
|
|
44
|
+
end if;
|
|
45
|
+
|
|
46
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
47
|
+
|
|
48
|
+
if (TG_OP = 'DELETE') then
|
|
49
|
+
return OLD;
|
|
50
|
+
else
|
|
51
|
+
return NEW;
|
|
52
|
+
end if;
|
|
53
|
+
end;
|
|
54
|
+
$$;
|
|
55
|
+
|
|
56
|
+
-- Trigger function for company CRUD events
|
|
57
|
+
create or replace function trigger_company_webhooks()
|
|
58
|
+
returns trigger
|
|
59
|
+
language plpgsql
|
|
60
|
+
security definer
|
|
61
|
+
set search_path = ''
|
|
62
|
+
as $$
|
|
63
|
+
declare
|
|
64
|
+
event_type text;
|
|
65
|
+
payload jsonb;
|
|
66
|
+
begin
|
|
67
|
+
if (TG_OP = 'INSERT') then
|
|
68
|
+
event_type := 'company.created';
|
|
69
|
+
payload := to_jsonb(NEW);
|
|
70
|
+
elsif (TG_OP = 'UPDATE') then
|
|
71
|
+
event_type := 'company.updated';
|
|
72
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
73
|
+
elsif (TG_OP = 'DELETE') then
|
|
74
|
+
event_type := 'company.deleted';
|
|
75
|
+
payload := to_jsonb(OLD);
|
|
76
|
+
end if;
|
|
77
|
+
|
|
78
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
79
|
+
|
|
80
|
+
if (TG_OP = 'DELETE') then
|
|
81
|
+
return OLD;
|
|
82
|
+
else
|
|
83
|
+
return NEW;
|
|
84
|
+
end if;
|
|
85
|
+
end;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
-- Trigger function for deal CRUD and stage change events
|
|
89
|
+
create or replace function trigger_deal_webhooks()
|
|
90
|
+
returns trigger
|
|
91
|
+
language plpgsql
|
|
92
|
+
security definer
|
|
93
|
+
set search_path = ''
|
|
94
|
+
as $$
|
|
95
|
+
declare
|
|
96
|
+
event_type text;
|
|
97
|
+
payload jsonb;
|
|
98
|
+
begin
|
|
99
|
+
if (TG_OP = 'INSERT') then
|
|
100
|
+
event_type := 'deal.created';
|
|
101
|
+
payload := to_jsonb(NEW);
|
|
102
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
103
|
+
elsif (TG_OP = 'UPDATE') then
|
|
104
|
+
-- Check for stage changes
|
|
105
|
+
if (OLD.stage <> NEW.stage) then
|
|
106
|
+
event_type := 'deal.stage_changed';
|
|
107
|
+
payload := jsonb_build_object(
|
|
108
|
+
'deal_id', NEW.id,
|
|
109
|
+
'old_stage', OLD.stage,
|
|
110
|
+
'new_stage', NEW.stage,
|
|
111
|
+
'deal', to_jsonb(NEW)
|
|
112
|
+
);
|
|
113
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
114
|
+
|
|
115
|
+
-- Check for won/lost
|
|
116
|
+
if (NEW.stage = 'won') then
|
|
117
|
+
perform enqueue_webhook_event('deal.won', to_jsonb(NEW));
|
|
118
|
+
elsif (NEW.stage = 'lost') then
|
|
119
|
+
perform enqueue_webhook_event('deal.lost', to_jsonb(NEW));
|
|
120
|
+
end if;
|
|
121
|
+
end if;
|
|
122
|
+
|
|
123
|
+
event_type := 'deal.updated';
|
|
124
|
+
payload := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
|
|
125
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
126
|
+
elsif (TG_OP = 'DELETE') then
|
|
127
|
+
event_type := 'deal.deleted';
|
|
128
|
+
payload := to_jsonb(OLD);
|
|
129
|
+
perform enqueue_webhook_event(event_type, payload);
|
|
130
|
+
end if;
|
|
131
|
+
|
|
132
|
+
if (TG_OP = 'DELETE') then
|
|
133
|
+
return OLD;
|
|
134
|
+
else
|
|
135
|
+
return NEW;
|
|
136
|
+
end if;
|
|
137
|
+
end;
|
|
138
|
+
$$;
|
|
139
|
+
|
|
140
|
+
-- Trigger function for task completion
|
|
141
|
+
create or replace function trigger_task_webhooks()
|
|
142
|
+
returns trigger
|
|
143
|
+
language plpgsql
|
|
144
|
+
security definer
|
|
145
|
+
set search_path = ''
|
|
146
|
+
as $$
|
|
147
|
+
begin
|
|
148
|
+
if (TG_OP = 'UPDATE' and OLD.done_date is null and NEW.done_date is not null) then
|
|
149
|
+
perform enqueue_webhook_event('task.completed', to_jsonb(NEW));
|
|
150
|
+
end if;
|
|
151
|
+
|
|
152
|
+
return NEW;
|
|
153
|
+
end;
|
|
154
|
+
$$;
|
|
155
|
+
|
|
156
|
+
-- Create triggers
|
|
157
|
+
create trigger contact_webhook_trigger
|
|
158
|
+
after insert or update or delete on public.contacts
|
|
159
|
+
for each row execute function trigger_contact_webhooks();
|
|
160
|
+
|
|
161
|
+
create trigger company_webhook_trigger
|
|
162
|
+
after insert or update or delete on public.companies
|
|
163
|
+
for each row execute function trigger_company_webhooks();
|
|
164
|
+
|
|
165
|
+
create trigger deal_webhook_trigger
|
|
166
|
+
after insert or update or delete on public.deals
|
|
167
|
+
for each row execute function trigger_deal_webhooks();
|
|
168
|
+
|
|
169
|
+
create trigger task_webhook_trigger
|
|
170
|
+
after update on public.tasks
|
|
171
|
+
for each row execute function trigger_task_webhooks();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Enable pg_cron extension if not already enabled
|
|
2
|
+
create extension if not exists pg_cron with schema extensions;
|
|
3
|
+
|
|
4
|
+
-- Schedule webhook dispatcher to run every minute
|
|
5
|
+
-- Note: This uses Supabase's http extension to call the Edge Function
|
|
6
|
+
select cron.schedule(
|
|
7
|
+
'webhook-dispatcher',
|
|
8
|
+
'* * * * *', -- Every minute
|
|
9
|
+
$$
|
|
10
|
+
select
|
|
11
|
+
net.http_post(
|
|
12
|
+
url:='https://' || current_setting('app.settings.supabase_url', true) || '/functions/v1/webhook-dispatcher',
|
|
13
|
+
headers:=jsonb_build_object(
|
|
14
|
+
'Content-Type','application/json',
|
|
15
|
+
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key', true)
|
|
16
|
+
),
|
|
17
|
+
body:='{}'::jsonb
|
|
18
|
+
) as request_id;
|
|
19
|
+
$$
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- Note: To configure this cron job properly, you need to set the following settings in your Supabase project:
|
|
23
|
+
-- ALTER DATABASE postgres SET app.settings.supabase_url = 'your-project-ref.supabase.co';
|
|
24
|
+
-- ALTER DATABASE postgres SET app.settings.service_role_key = 'your-service-role-key';
|
|
25
|
+
--
|
|
26
|
+
-- Alternatively, the webhook dispatcher can be called manually or via a separate cron service like GitHub Actions or a cloud scheduler.
|