realtimex-crm 0.9.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-DnGVfS15.js → DealList-DbwJCRGl.js} +2 -2
- package/dist/assets/{DealList-DnGVfS15.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-DPrpo5Xq.js.map → index-mE-upBfc.js.map} +1 -1
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +2 -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/CreateChannelDialog.tsx +139 -0
- package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +15 -3
- package/supabase/fix_webhook_hardcoded.sql +34 -0
- package/supabase/functions/_shared/ingestionGuard.ts +128 -0
- package/supabase/functions/_shared/utils.ts +1 -1
- package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
- package/supabase/functions/ingest-activity/index.ts +261 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +10 -5
- 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-DPrpo5Xq.js +0 -159
- package/dist/assets/index-kM1Og1AS.css +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useDataProvider, useNotify } from "ra-core";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import { Label } from "@/components/ui/label";
|
|
15
|
+
import {
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
} from "@/components/ui/select";
|
|
22
|
+
import { Loader2 } from "lucide-react";
|
|
23
|
+
|
|
24
|
+
interface CreateChannelDialogProps {
|
|
25
|
+
open: boolean;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const CreateChannelDialog = ({
|
|
30
|
+
open,
|
|
31
|
+
onClose,
|
|
32
|
+
}: CreateChannelDialogProps) => {
|
|
33
|
+
const [providerCode, setProviderCode] = useState<string>("twilio");
|
|
34
|
+
const [name, setName] = useState("");
|
|
35
|
+
const [authToken, setAuthToken] = useState("");
|
|
36
|
+
const dataProvider = useDataProvider();
|
|
37
|
+
const notify = useNotify();
|
|
38
|
+
const queryClient = useQueryClient();
|
|
39
|
+
|
|
40
|
+
const createMutation = useMutation({
|
|
41
|
+
mutationFn: async () => {
|
|
42
|
+
// Generate a cryptographically secure random ingestion key
|
|
43
|
+
const ingestionKey = "ik_live_" + crypto.randomUUID().replace(/-/g, '');
|
|
44
|
+
|
|
45
|
+
const config: any = {};
|
|
46
|
+
if (authToken) {
|
|
47
|
+
config.auth_token = authToken;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await dataProvider.create("ingestion_providers", {
|
|
51
|
+
data: {
|
|
52
|
+
name,
|
|
53
|
+
provider_code: providerCode,
|
|
54
|
+
is_active: true,
|
|
55
|
+
config,
|
|
56
|
+
ingestion_key: ingestionKey
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ["ingestion_providers"] });
|
|
62
|
+
notify("Ingestion Channel created successfully");
|
|
63
|
+
onClose();
|
|
64
|
+
// Reset form
|
|
65
|
+
setName("");
|
|
66
|
+
setAuthToken("");
|
|
67
|
+
setProviderCode("twilio");
|
|
68
|
+
},
|
|
69
|
+
onError: (error: Error) => {
|
|
70
|
+
notify(`Failed to create channel: ${error.message}`, { type: "error" });
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Dialog open={open} onOpenChange={onClose}>
|
|
76
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
77
|
+
<DialogHeader>
|
|
78
|
+
<DialogTitle>Add Ingestion Channel</DialogTitle>
|
|
79
|
+
<DialogDescription>
|
|
80
|
+
Configure a new source for incoming activities.
|
|
81
|
+
</DialogDescription>
|
|
82
|
+
</DialogHeader>
|
|
83
|
+
<div className="grid gap-4 py-4">
|
|
84
|
+
<div className="grid gap-2">
|
|
85
|
+
<Label htmlFor="name">Channel Name</Label>
|
|
86
|
+
<Input
|
|
87
|
+
id="name"
|
|
88
|
+
placeholder="e.g. US Support Line"
|
|
89
|
+
value={name}
|
|
90
|
+
onChange={(e) => setName(e.target.value)}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="grid gap-2">
|
|
94
|
+
<Label htmlFor="provider">Provider</Label>
|
|
95
|
+
<Select value={providerCode} onValueChange={setProviderCode}>
|
|
96
|
+
<SelectTrigger>
|
|
97
|
+
<SelectValue placeholder="Select provider" />
|
|
98
|
+
</SelectTrigger>
|
|
99
|
+
<SelectContent>
|
|
100
|
+
<SelectItem value="twilio">Twilio (Voice/SMS)</SelectItem>
|
|
101
|
+
<SelectItem value="postmark">Postmark (Email)</SelectItem>
|
|
102
|
+
<SelectItem value="generic">Generic / Internal</SelectItem>
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{providerCode === "twilio" && (
|
|
108
|
+
<div className="grid gap-2">
|
|
109
|
+
<Label htmlFor="token">Auth Token (Validation)</Label>
|
|
110
|
+
<Input
|
|
111
|
+
id="token"
|
|
112
|
+
type="password"
|
|
113
|
+
placeholder="Twilio Auth Token"
|
|
114
|
+
value={authToken}
|
|
115
|
+
onChange={(e) => setAuthToken(e.target.value)}
|
|
116
|
+
/>
|
|
117
|
+
<p className="text-xs text-muted-foreground">Required to validate inbound requests.</p>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
</div>
|
|
122
|
+
<DialogFooter>
|
|
123
|
+
<Button variant="outline" onClick={onClose}>
|
|
124
|
+
Cancel
|
|
125
|
+
</Button>
|
|
126
|
+
<Button
|
|
127
|
+
onClick={() => createMutation.mutate()}
|
|
128
|
+
disabled={!name || createMutation.isPending}
|
|
129
|
+
>
|
|
130
|
+
{createMutation.isPending && (
|
|
131
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
132
|
+
)}
|
|
133
|
+
Create Channel
|
|
134
|
+
</Button>
|
|
135
|
+
</DialogFooter>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useDataProvider, useNotify } from "ra-core";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Label } from "@/components/ui/label";
|
|
7
|
+
import { Plus, Trash2, Copy, Activity } from "lucide-react";
|
|
8
|
+
import { CreateChannelDialog } from "./CreateChannelDialog";
|
|
9
|
+
import { Badge } from "@/components/ui/badge";
|
|
10
|
+
import { format } from "date-fns";
|
|
11
|
+
import { getSupabaseConfig } from "@/lib/supabase-config";
|
|
12
|
+
import {
|
|
13
|
+
AlertDialog,
|
|
14
|
+
AlertDialogAction,
|
|
15
|
+
AlertDialogCancel,
|
|
16
|
+
AlertDialogContent,
|
|
17
|
+
AlertDialogDescription,
|
|
18
|
+
AlertDialogFooter,
|
|
19
|
+
AlertDialogHeader,
|
|
20
|
+
AlertDialogTitle,
|
|
21
|
+
} from "@/components/ui/alert-dialog";
|
|
22
|
+
|
|
23
|
+
export const IngestionChannelsTab = () => {
|
|
24
|
+
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
25
|
+
const [channelToDelete, setChannelToDelete] = useState<string | null>(null);
|
|
26
|
+
const dataProvider = useDataProvider();
|
|
27
|
+
const notify = useNotify();
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
|
|
30
|
+
const { data: channels, isLoading } = useQuery({
|
|
31
|
+
queryKey: ["ingestion_providers"],
|
|
32
|
+
queryFn: async () => {
|
|
33
|
+
const { data } = await dataProvider.getList("ingestion_providers", {
|
|
34
|
+
pagination: { page: 1, perPage: 100 },
|
|
35
|
+
sort: { field: "created_at", order: "DESC" },
|
|
36
|
+
filter: {},
|
|
37
|
+
});
|
|
38
|
+
return data;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const deleteMutation = useMutation({
|
|
43
|
+
mutationFn: async (id: string) => {
|
|
44
|
+
await dataProvider.delete("ingestion_providers", { id, previousData: {} });
|
|
45
|
+
},
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
queryClient.invalidateQueries({ queryKey: ["ingestion_providers"] });
|
|
48
|
+
notify("Channel deleted successfully");
|
|
49
|
+
setChannelToDelete(null);
|
|
50
|
+
},
|
|
51
|
+
onError: () => {
|
|
52
|
+
notify("Failed to delete channel", { type: "error" });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
<div className="flex justify-between items-center">
|
|
59
|
+
<p className="text-sm text-muted-foreground">
|
|
60
|
+
Configure inbound channels (Email, Voice, SMS) to ingest activities into your CRM.
|
|
61
|
+
</p>
|
|
62
|
+
<Button onClick={() => setShowCreateDialog(true)}>
|
|
63
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
64
|
+
Add Channel
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{isLoading ? (
|
|
69
|
+
<Card>
|
|
70
|
+
<CardContent className="py-8 text-center text-muted-foreground">
|
|
71
|
+
Loading...
|
|
72
|
+
</CardContent>
|
|
73
|
+
</Card>
|
|
74
|
+
) : channels && channels.length > 0 ? (
|
|
75
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
76
|
+
{channels.map((channel: any) => (
|
|
77
|
+
<ChannelCard
|
|
78
|
+
key={channel.id}
|
|
79
|
+
channel={channel}
|
|
80
|
+
onDelete={() => setChannelToDelete(channel.id)}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
) : (
|
|
85
|
+
<Card>
|
|
86
|
+
<CardContent className="py-12 text-center">
|
|
87
|
+
<p className="text-muted-foreground mb-4">No ingestion channels configured</p>
|
|
88
|
+
<Button onClick={() => setShowCreateDialog(true)}>
|
|
89
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
90
|
+
Add your first channel
|
|
91
|
+
</Button>
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<CreateChannelDialog
|
|
97
|
+
open={showCreateDialog}
|
|
98
|
+
onClose={() => setShowCreateDialog(false)}
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
<AlertDialog
|
|
102
|
+
open={channelToDelete !== null}
|
|
103
|
+
onOpenChange={() => setChannelToDelete(null)}
|
|
104
|
+
>
|
|
105
|
+
<AlertDialogContent>
|
|
106
|
+
<AlertDialogHeader>
|
|
107
|
+
<AlertDialogTitle>Delete Channel?</AlertDialogTitle>
|
|
108
|
+
<AlertDialogDescription>
|
|
109
|
+
This will stop all ingestion from this source. This action cannot be undone.
|
|
110
|
+
</AlertDialogDescription>
|
|
111
|
+
</AlertDialogHeader>
|
|
112
|
+
<AlertDialogFooter>
|
|
113
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
114
|
+
<AlertDialogAction
|
|
115
|
+
onClick={() => channelToDelete && deleteMutation.mutate(channelToDelete)}
|
|
116
|
+
className="bg-destructive hover:bg-destructive/90"
|
|
117
|
+
>
|
|
118
|
+
Delete
|
|
119
|
+
</AlertDialogAction>
|
|
120
|
+
</AlertDialogFooter>
|
|
121
|
+
</AlertDialogContent>
|
|
122
|
+
</AlertDialog>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const ChannelCard = ({
|
|
128
|
+
channel,
|
|
129
|
+
onDelete,
|
|
130
|
+
}: {
|
|
131
|
+
channel: any;
|
|
132
|
+
onDelete: () => void;
|
|
133
|
+
}) => {
|
|
134
|
+
const notify = useNotify();
|
|
135
|
+
|
|
136
|
+
// Get the actual Supabase URL from config (localStorage or env vars)
|
|
137
|
+
const supabaseConfig = getSupabaseConfig();
|
|
138
|
+
const webhookUrl = supabaseConfig
|
|
139
|
+
? `${supabaseConfig.url}/functions/v1/ingest-activity?key=${channel.ingestion_key}`
|
|
140
|
+
: `https://your-project.supabase.co/functions/v1/ingest-activity?key=${channel.ingestion_key}`;
|
|
141
|
+
|
|
142
|
+
const copyUrl = () => {
|
|
143
|
+
navigator.clipboard.writeText(webhookUrl);
|
|
144
|
+
notify("Webhook URL copied to clipboard");
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Card>
|
|
149
|
+
<CardHeader className="pb-3">
|
|
150
|
+
<div className="flex justify-between items-start">
|
|
151
|
+
<div className="flex items-center gap-3">
|
|
152
|
+
<div className="p-2 bg-primary/10 rounded-full">
|
|
153
|
+
<Activity className="h-5 w-5 text-primary" />
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<CardTitle className="text-base">{channel.name}</CardTitle>
|
|
157
|
+
<div className="flex gap-2 mt-1">
|
|
158
|
+
<Badge variant="outline">{channel.provider_code}</Badge>
|
|
159
|
+
{channel.is_active ? (
|
|
160
|
+
<Badge className="bg-green-500 hover:bg-green-600">Active</Badge>
|
|
161
|
+
) : (
|
|
162
|
+
<Badge variant="secondary">Inactive</Badge>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<Button variant="ghost" size="icon" onClick={onDelete}>
|
|
168
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<CardContent className="space-y-3">
|
|
173
|
+
<div className="space-y-1">
|
|
174
|
+
<Label className="text-xs text-muted-foreground">Webhook URL</Label>
|
|
175
|
+
<div className="flex items-center gap-2 font-mono text-xs bg-muted p-2 rounded overflow-hidden">
|
|
176
|
+
<span className="truncate">{webhookUrl}</span>
|
|
177
|
+
<Button variant="ghost" size="icon" className="h-6 w-6 ml-auto flex-shrink-0" onClick={copyUrl}>
|
|
178
|
+
<Copy className="h-3 w-3" />
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="text-xs text-muted-foreground">
|
|
183
|
+
<p>Created: {format(new Date(channel.created_at), "PPP")}</p>
|
|
184
|
+
</div>
|
|
185
|
+
</CardContent>
|
|
186
|
+
</Card>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
2
2
|
import { ApiKeysTab } from "./ApiKeysTab";
|
|
3
3
|
import { WebhooksTab } from "./WebhooksTab";
|
|
4
|
+
import { IngestionChannelsTab } from "./IngestionChannelsTab";
|
|
5
|
+
import { FileUpload } from "../activities/FileUpload";
|
|
4
6
|
|
|
5
7
|
export const IntegrationsPage = () => {
|
|
6
8
|
return (
|
|
@@ -8,17 +10,27 @@ export const IntegrationsPage = () => {
|
|
|
8
10
|
<div className="mb-6">
|
|
9
11
|
<h1 className="text-3xl font-bold">Integrations</h1>
|
|
10
12
|
<p className="text-muted-foreground mt-2">
|
|
11
|
-
Manage API keys and
|
|
13
|
+
Manage API keys, webhooks, and ingestion channels to integrate Atomic CRM with external
|
|
12
14
|
systems.
|
|
13
15
|
</p>
|
|
14
16
|
</div>
|
|
15
17
|
|
|
16
|
-
<Tabs defaultValue="
|
|
18
|
+
<Tabs defaultValue="ingestion">
|
|
17
19
|
<TabsList className="mb-4">
|
|
20
|
+
<TabsTrigger value="ingestion">Ingestion Channels</TabsTrigger>
|
|
21
|
+
<TabsTrigger value="file-upload">File Upload</TabsTrigger>
|
|
18
22
|
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
|
19
|
-
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
|
23
|
+
<TabsTrigger value="webhooks">Webhooks (Outbound)</TabsTrigger>
|
|
20
24
|
</TabsList>
|
|
21
25
|
|
|
26
|
+
<TabsContent value="ingestion">
|
|
27
|
+
<IngestionChannelsTab />
|
|
28
|
+
</TabsContent>
|
|
29
|
+
|
|
30
|
+
<TabsContent value="file-upload">
|
|
31
|
+
<FileUpload />
|
|
32
|
+
</TabsContent>
|
|
33
|
+
|
|
22
34
|
<TabsContent value="api-keys">
|
|
23
35
|
<ApiKeysTab />
|
|
24
36
|
</TabsContent>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- ==================================================================================
|
|
2
|
+
-- HARDCODED WEBHOOK FIX (Bypasses Permission Issues)
|
|
3
|
+
-- ==================================================================================
|
|
4
|
+
|
|
5
|
+
-- 1. Ensure extensions are active
|
|
6
|
+
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
|
7
|
+
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
|
|
8
|
+
|
|
9
|
+
-- 2. Remove the old dynamic job that relies on missing settings
|
|
10
|
+
SELECT cron.unschedule(jobid)
|
|
11
|
+
FROM cron.job
|
|
12
|
+
WHERE jobname = 'webhook-dispatcher';
|
|
13
|
+
|
|
14
|
+
-- 3. Schedule the new job with HARDCODED credentials
|
|
15
|
+
-- !!! IMPORTANT: Replace [YOUR_SERVICE_ROLE_KEY] below before running !!!
|
|
16
|
+
|
|
17
|
+
SELECT cron.schedule(
|
|
18
|
+
'webhook-dispatcher',
|
|
19
|
+
'* * * * *', -- Run every minute
|
|
20
|
+
$$
|
|
21
|
+
select
|
|
22
|
+
net.http_post(
|
|
23
|
+
url:='https://xydvyhnspkzcsocewhuy.supabase.co/functions/v1/webhook-dispatcher',
|
|
24
|
+
headers:=jsonb_build_object(
|
|
25
|
+
'Content-Type','application/json',
|
|
26
|
+
'Authorization', 'Bearer [YOUR_SERVICE_ROLE_KEY]'
|
|
27
|
+
),
|
|
28
|
+
body:='{}'::jsonb
|
|
29
|
+
) as request_id;
|
|
30
|
+
$$
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- 4. Check that the job is scheduled
|
|
34
|
+
SELECT jobid, jobname, command FROM cron.job WHERE jobname = 'webhook-dispatcher';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createErrorResponse } from "./utils.ts";
|
|
2
|
+
import { supabaseAdmin } from "./supabaseAdmin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates the request signature or API key.
|
|
6
|
+
* Returns the resolved provider config or an error response.
|
|
7
|
+
* Note: For Twilio, signature validation must be done separately after body parsing
|
|
8
|
+
* using validateTwilioWebhook()
|
|
9
|
+
*/
|
|
10
|
+
export async function validateIngestionRequest(req: Request) {
|
|
11
|
+
const url = new URL(req.url);
|
|
12
|
+
const providerCode = url.searchParams.get("provider");
|
|
13
|
+
|
|
14
|
+
// Check for ingestion key in header (preferred) or URL query param (backward compatibility)
|
|
15
|
+
const ingestionKey = req.headers.get("x-ingestion-key") || url.searchParams.get("key");
|
|
16
|
+
|
|
17
|
+
// 1. Internal/Manual API Key (Bearer)
|
|
18
|
+
const authHeader = req.headers.get("Authorization");
|
|
19
|
+
if (authHeader?.startsWith("Bearer ak_live_")) {
|
|
20
|
+
// Validate Internal API Key (already implemented in apiKeyAuth.ts)
|
|
21
|
+
// For V1, we trust internal keys as "generic" provider
|
|
22
|
+
return { provider: { id: null, provider_code: "generic", sales_id: null } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Identify Provider by 'ingestion_key' (Preferred)
|
|
26
|
+
if (ingestionKey) {
|
|
27
|
+
const { data: provider, error } = await supabaseAdmin
|
|
28
|
+
.from("ingestion_providers")
|
|
29
|
+
.select("*")
|
|
30
|
+
.eq("ingestion_key", ingestionKey)
|
|
31
|
+
.eq("is_active", true)
|
|
32
|
+
.single();
|
|
33
|
+
|
|
34
|
+
if (error || !provider) {
|
|
35
|
+
return { error: createErrorResponse(401, "Invalid Ingestion Key") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { provider };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Fallback: Identify by Query Param (Legacy/Public Webhooks)
|
|
42
|
+
if (providerCode === "twilio") {
|
|
43
|
+
// In this case, we need to find WHICH Twilio config to use.
|
|
44
|
+
// Usually, we match by the 'To' phone number in the body, but that requires parsing the body first.
|
|
45
|
+
// For strict security, we REJECT requests without an ingestion_key in the URL.
|
|
46
|
+
return { error: createErrorResponse(401, "Missing 'key' parameter in webhook URL") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { error: createErrorResponse(400, "Unknown Provider or Missing Authentication") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates a Twilio webhook request after body parsing
|
|
54
|
+
* Call this after validateIngestionRequest() and body parsing
|
|
55
|
+
*/
|
|
56
|
+
export async function validateTwilioWebhook(
|
|
57
|
+
req: Request,
|
|
58
|
+
authToken: string,
|
|
59
|
+
body: Record<string, any>
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
return await validateTwilioSignature(req, authToken, body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validates Twilio X-Twilio-Signature using HMAC-SHA1
|
|
66
|
+
* Implementation follows Twilio's security specification:
|
|
67
|
+
* https://www.twilio.com/docs/usage/security#validating-requests
|
|
68
|
+
*/
|
|
69
|
+
async function validateTwilioSignature(
|
|
70
|
+
req: Request,
|
|
71
|
+
authToken: string,
|
|
72
|
+
body: Record<string, any>
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const signature = req.headers.get("X-Twilio-Signature");
|
|
75
|
+
if (!signature) return false;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 1. Get the full URL (including protocol, host, path, and query params)
|
|
79
|
+
const url = req.url;
|
|
80
|
+
|
|
81
|
+
// 2. Sort POST parameters alphabetically and concatenate
|
|
82
|
+
const sortedParams = Object.keys(body)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => `${key}${body[key]}`)
|
|
85
|
+
.join("");
|
|
86
|
+
|
|
87
|
+
// 3. Concatenate URL + sorted params
|
|
88
|
+
const data = url + sortedParams;
|
|
89
|
+
|
|
90
|
+
// 4. Compute HMAC-SHA1
|
|
91
|
+
const encoder = new TextEncoder();
|
|
92
|
+
const keyData = encoder.encode(authToken);
|
|
93
|
+
const messageData = encoder.encode(data);
|
|
94
|
+
|
|
95
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
96
|
+
"raw",
|
|
97
|
+
keyData,
|
|
98
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
99
|
+
false,
|
|
100
|
+
["sign"]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
|
|
104
|
+
|
|
105
|
+
// 5. Convert to Base64
|
|
106
|
+
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
|
|
107
|
+
const signatureBase64 = btoa(String.fromCharCode(...signatureArray));
|
|
108
|
+
|
|
109
|
+
// 6. Constant-time comparison to prevent timing attacks
|
|
110
|
+
return constantTimeCompare(signature, signatureBase64);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error("Twilio signature validation error:", error);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Constant-time string comparison to prevent timing attacks
|
|
119
|
+
*/
|
|
120
|
+
function constantTimeCompare(a: string, b: string): boolean {
|
|
121
|
+
if (a.length !== b.length) return false;
|
|
122
|
+
|
|
123
|
+
let result = 0;
|
|
124
|
+
for (let i = 0; i < a.length; i++) {
|
|
125
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
126
|
+
}
|
|
127
|
+
return result === 0;
|
|
128
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const corsHeaders = {
|
|
2
2
|
"Access-Control-Allow-Origin": "*",
|
|
3
3
|
"Access-Control-Allow-Headers":
|
|
4
|
-
"authorization, x-client-info, apikey, content-type",
|
|
4
|
+
"authorization, x-client-info, apikey, content-type, x-ingestion-key",
|
|
5
5
|
"Access-Control-Allow-Methods": "POST, PATCH, PUT, DELETE",
|
|
6
6
|
};
|
|
7
7
|
|