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,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
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
2
|
+
import { ApiKeysTab } from "./ApiKeysTab";
|
|
3
|
+
import { WebhooksTab } from "./WebhooksTab";
|
|
4
|
+
import { IngestionChannelsTab } from "./IngestionChannelsTab";
|
|
5
|
+
import { FileUpload } from "../activities/FileUpload";
|
|
6
|
+
|
|
7
|
+
export const IntegrationsPage = () => {
|
|
8
|
+
return (
|
|
9
|
+
<div className="max-w-6xl mx-auto mt-8 px-4">
|
|
10
|
+
<div className="mb-6">
|
|
11
|
+
<h1 className="text-3xl font-bold">Integrations</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-2">
|
|
13
|
+
Manage API keys, webhooks, and ingestion channels to integrate Atomic CRM with external
|
|
14
|
+
systems.
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<Tabs defaultValue="ingestion">
|
|
19
|
+
<TabsList className="mb-4">
|
|
20
|
+
<TabsTrigger value="ingestion">Ingestion Channels</TabsTrigger>
|
|
21
|
+
<TabsTrigger value="file-upload">File Upload</TabsTrigger>
|
|
22
|
+
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
|
23
|
+
<TabsTrigger value="webhooks">Webhooks (Outbound)</TabsTrigger>
|
|
24
|
+
</TabsList>
|
|
25
|
+
|
|
26
|
+
<TabsContent value="ingestion">
|
|
27
|
+
<IngestionChannelsTab />
|
|
28
|
+
</TabsContent>
|
|
29
|
+
|
|
30
|
+
<TabsContent value="file-upload">
|
|
31
|
+
<FileUpload />
|
|
32
|
+
</TabsContent>
|
|
33
|
+
|
|
34
|
+
<TabsContent value="api-keys">
|
|
35
|
+
<ApiKeysTab />
|
|
36
|
+
</TabsContent>
|
|
37
|
+
|
|
38
|
+
<TabsContent value="webhooks">
|
|
39
|
+
<WebhooksTab />
|
|
40
|
+
</TabsContent>
|
|
41
|
+
</Tabs>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
IntegrationsPage.path = "/integrations";
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useDataProvider, useNotify, useGetIdentity } from "ra-core";
|
|
4
|
+
import { useForm } from "react-hook-form";
|
|
5
|
+
import { generateApiKey } from "@/lib/api-key-utils";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { Plus, Trash2, Power, PowerOff } from "lucide-react";
|
|
9
|
+
import { Badge } from "@/components/ui/badge";
|
|
10
|
+
import { format } from "date-fns";
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
} from "@/components/ui/dialog";
|
|
18
|
+
import { Input } from "@/components/ui/input";
|
|
19
|
+
import { Label } from "@/components/ui/label";
|
|
20
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
21
|
+
import {
|
|
22
|
+
AlertDialog,
|
|
23
|
+
AlertDialogAction,
|
|
24
|
+
AlertDialogCancel,
|
|
25
|
+
AlertDialogContent,
|
|
26
|
+
AlertDialogDescription,
|
|
27
|
+
AlertDialogFooter,
|
|
28
|
+
AlertDialogHeader,
|
|
29
|
+
AlertDialogTitle,
|
|
30
|
+
} from "@/components/ui/alert-dialog";
|
|
31
|
+
|
|
32
|
+
const AVAILABLE_EVENTS = [
|
|
33
|
+
{ value: "contact.created", label: "Contact Created", category: "Contacts" },
|
|
34
|
+
{ value: "contact.updated", label: "Contact Updated", category: "Contacts" },
|
|
35
|
+
{ value: "contact.deleted", label: "Contact Deleted", category: "Contacts" },
|
|
36
|
+
{ value: "company.created", label: "Company Created", category: "Companies" },
|
|
37
|
+
{ value: "company.updated", label: "Company Updated", category: "Companies" },
|
|
38
|
+
{ value: "company.deleted", label: "Company Deleted", category: "Companies" },
|
|
39
|
+
{ value: "deal.created", label: "Deal Created", category: "Deals" },
|
|
40
|
+
{ value: "deal.updated", label: "Deal Updated", category: "Deals" },
|
|
41
|
+
{ value: "deal.deleted", label: "Deal Deleted", category: "Deals" },
|
|
42
|
+
{
|
|
43
|
+
value: "deal.stage_changed",
|
|
44
|
+
label: "Deal Stage Changed",
|
|
45
|
+
category: "Deals",
|
|
46
|
+
},
|
|
47
|
+
{ value: "deal.won", label: "Deal Won", category: "Deals" },
|
|
48
|
+
{ value: "deal.lost", label: "Deal Lost", category: "Deals" },
|
|
49
|
+
{ value: "task.completed", label: "Task Completed", category: "Tasks" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export const WebhooksTab = () => {
|
|
53
|
+
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
54
|
+
const [webhookToDelete, setWebhookToDelete] = useState<number | null>(null);
|
|
55
|
+
const dataProvider = useDataProvider();
|
|
56
|
+
const notify = useNotify();
|
|
57
|
+
const queryClient = useQueryClient();
|
|
58
|
+
|
|
59
|
+
const { data: webhooks, isLoading } = useQuery({
|
|
60
|
+
queryKey: ["webhooks"],
|
|
61
|
+
queryFn: async () => {
|
|
62
|
+
const { data } = await dataProvider.getList("webhooks", {
|
|
63
|
+
pagination: { page: 1, perPage: 100 },
|
|
64
|
+
sort: { field: "created_at", order: "DESC" },
|
|
65
|
+
filter: {},
|
|
66
|
+
});
|
|
67
|
+
return data;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const deleteMutation = useMutation({
|
|
72
|
+
mutationFn: async (id: number) => {
|
|
73
|
+
await dataProvider.delete("webhooks", { id, previousData: {} });
|
|
74
|
+
},
|
|
75
|
+
onSuccess: () => {
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks"] });
|
|
77
|
+
notify("Webhook deleted successfully");
|
|
78
|
+
setWebhookToDelete(null);
|
|
79
|
+
},
|
|
80
|
+
onError: () => {
|
|
81
|
+
notify("Failed to delete webhook", { type: "error" });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const toggleMutation = useMutation({
|
|
86
|
+
mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
|
|
87
|
+
await dataProvider.update("webhooks", {
|
|
88
|
+
id,
|
|
89
|
+
data: { is_active },
|
|
90
|
+
previousData: {},
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
onSuccess: () => {
|
|
94
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks"] });
|
|
95
|
+
notify("Webhook updated successfully");
|
|
96
|
+
},
|
|
97
|
+
onError: () => {
|
|
98
|
+
notify("Failed to update webhook", { type: "error" });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-4">
|
|
104
|
+
<div className="flex justify-between items-center">
|
|
105
|
+
<p className="text-sm text-muted-foreground">
|
|
106
|
+
Webhooks notify external systems when events occur in your CRM.
|
|
107
|
+
</p>
|
|
108
|
+
<Button onClick={() => setShowCreateDialog(true)}>
|
|
109
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
110
|
+
Create Webhook
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{isLoading ? (
|
|
115
|
+
<Card>
|
|
116
|
+
<CardContent className="py-8 text-center text-muted-foreground">
|
|
117
|
+
Loading...
|
|
118
|
+
</CardContent>
|
|
119
|
+
</Card>
|
|
120
|
+
) : webhooks && webhooks.length > 0 ? (
|
|
121
|
+
<div className="space-y-3">
|
|
122
|
+
{webhooks.map((webhook: any) => (
|
|
123
|
+
<WebhookCard
|
|
124
|
+
key={webhook.id}
|
|
125
|
+
webhook={webhook}
|
|
126
|
+
onDelete={() => setWebhookToDelete(webhook.id)}
|
|
127
|
+
onToggle={() =>
|
|
128
|
+
toggleMutation.mutate({
|
|
129
|
+
id: webhook.id,
|
|
130
|
+
is_active: !webhook.is_active,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<Card>
|
|
138
|
+
<CardContent className="py-12 text-center">
|
|
139
|
+
<p className="text-muted-foreground mb-4">No webhooks yet</p>
|
|
140
|
+
<Button onClick={() => setShowCreateDialog(true)}>
|
|
141
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
142
|
+
Create your first webhook
|
|
143
|
+
</Button>
|
|
144
|
+
</CardContent>
|
|
145
|
+
</Card>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<CreateWebhookDialog
|
|
149
|
+
open={showCreateDialog}
|
|
150
|
+
onClose={() => setShowCreateDialog(false)}
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
<AlertDialog
|
|
154
|
+
open={webhookToDelete !== null}
|
|
155
|
+
onOpenChange={() => setWebhookToDelete(null)}
|
|
156
|
+
>
|
|
157
|
+
<AlertDialogContent>
|
|
158
|
+
<AlertDialogHeader>
|
|
159
|
+
<AlertDialogTitle>Delete Webhook?</AlertDialogTitle>
|
|
160
|
+
<AlertDialogDescription>
|
|
161
|
+
This will permanently delete this webhook. No more events will be
|
|
162
|
+
sent to this endpoint. This action cannot be undone.
|
|
163
|
+
</AlertDialogDescription>
|
|
164
|
+
</AlertDialogHeader>
|
|
165
|
+
<AlertDialogFooter>
|
|
166
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
167
|
+
<AlertDialogAction
|
|
168
|
+
onClick={() =>
|
|
169
|
+
webhookToDelete && deleteMutation.mutate(webhookToDelete)
|
|
170
|
+
}
|
|
171
|
+
className="bg-destructive hover:bg-destructive/90"
|
|
172
|
+
>
|
|
173
|
+
Delete
|
|
174
|
+
</AlertDialogAction>
|
|
175
|
+
</AlertDialogFooter>
|
|
176
|
+
</AlertDialogContent>
|
|
177
|
+
</AlertDialog>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const WebhookCard = ({
|
|
183
|
+
webhook,
|
|
184
|
+
onDelete,
|
|
185
|
+
onToggle,
|
|
186
|
+
}: {
|
|
187
|
+
webhook: any;
|
|
188
|
+
onDelete: () => void;
|
|
189
|
+
onToggle: () => void;
|
|
190
|
+
}) => {
|
|
191
|
+
return (
|
|
192
|
+
<Card>
|
|
193
|
+
<CardHeader className="pb-3">
|
|
194
|
+
<div className="flex justify-between items-start">
|
|
195
|
+
<div className="flex-1">
|
|
196
|
+
<CardTitle className="text-lg">{webhook.name}</CardTitle>
|
|
197
|
+
<p className="text-sm text-muted-foreground mt-1 break-all">
|
|
198
|
+
{webhook.url}
|
|
199
|
+
</p>
|
|
200
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
201
|
+
{webhook.is_active ? (
|
|
202
|
+
<Badge variant="default">Active</Badge>
|
|
203
|
+
) : (
|
|
204
|
+
<Badge variant="secondary">Inactive</Badge>
|
|
205
|
+
)}
|
|
206
|
+
{webhook.events &&
|
|
207
|
+
webhook.events.slice(0, 3).map((event: string) => (
|
|
208
|
+
<Badge key={event} variant="outline">
|
|
209
|
+
{event}
|
|
210
|
+
</Badge>
|
|
211
|
+
))}
|
|
212
|
+
{webhook.events && webhook.events.length > 3 && (
|
|
213
|
+
<Badge variant="outline">+{webhook.events.length - 3} more</Badge>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div className="flex gap-2">
|
|
218
|
+
<Button variant="ghost" size="icon" onClick={onToggle}>
|
|
219
|
+
{webhook.is_active ? (
|
|
220
|
+
<PowerOff className="h-4 w-4" />
|
|
221
|
+
) : (
|
|
222
|
+
<Power className="h-4 w-4" />
|
|
223
|
+
)}
|
|
224
|
+
</Button>
|
|
225
|
+
<Button variant="ghost" size="icon" onClick={onDelete}>
|
|
226
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
227
|
+
</Button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</CardHeader>
|
|
231
|
+
<CardContent>
|
|
232
|
+
<div className="text-xs text-muted-foreground space-y-1">
|
|
233
|
+
<p>Created: {format(new Date(webhook.created_at), "PPP")}</p>
|
|
234
|
+
{webhook.last_triggered_at && (
|
|
235
|
+
<p>
|
|
236
|
+
Last triggered:{" "}
|
|
237
|
+
{format(new Date(webhook.last_triggered_at), "PPp")}
|
|
238
|
+
</p>
|
|
239
|
+
)}
|
|
240
|
+
{webhook.failure_count > 0 && (
|
|
241
|
+
<p className="text-destructive">
|
|
242
|
+
Failed deliveries: {webhook.failure_count}
|
|
243
|
+
</p>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
</CardContent>
|
|
247
|
+
</Card>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const CreateWebhookDialog = ({
|
|
252
|
+
open,
|
|
253
|
+
onClose,
|
|
254
|
+
}: {
|
|
255
|
+
open: boolean;
|
|
256
|
+
onClose: () => void;
|
|
257
|
+
}) => {
|
|
258
|
+
const dataProvider = useDataProvider();
|
|
259
|
+
const notify = useNotify();
|
|
260
|
+
const queryClient = useQueryClient();
|
|
261
|
+
const { identity } = useGetIdentity();
|
|
262
|
+
|
|
263
|
+
const { register, handleSubmit, watch, setValue, reset } = useForm({
|
|
264
|
+
defaultValues: {
|
|
265
|
+
name: "",
|
|
266
|
+
url: "",
|
|
267
|
+
events: [] as string[],
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const createMutation = useMutation({
|
|
272
|
+
mutationFn: async (values: any) => {
|
|
273
|
+
// Generate a random secret for webhook signature
|
|
274
|
+
const secret = generateApiKey();
|
|
275
|
+
|
|
276
|
+
await dataProvider.create("webhooks", {
|
|
277
|
+
data: {
|
|
278
|
+
name: values.name,
|
|
279
|
+
url: values.url,
|
|
280
|
+
events: values.events,
|
|
281
|
+
is_active: true,
|
|
282
|
+
secret,
|
|
283
|
+
sales_id: identity?.id,
|
|
284
|
+
created_by_sales_id: identity?.id,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
onSuccess: () => {
|
|
289
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks"] });
|
|
290
|
+
notify("Webhook created successfully");
|
|
291
|
+
reset();
|
|
292
|
+
onClose();
|
|
293
|
+
},
|
|
294
|
+
onError: () => {
|
|
295
|
+
notify("Failed to create webhook", { type: "error" });
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const toggleEvent = (event: string) => {
|
|
300
|
+
const currentEvents = watch("events");
|
|
301
|
+
if (currentEvents.includes(event)) {
|
|
302
|
+
setValue(
|
|
303
|
+
"events",
|
|
304
|
+
currentEvents.filter((e) => e !== event)
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
setValue("events", [...currentEvents, event]);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const handleClose = () => {
|
|
312
|
+
reset();
|
|
313
|
+
onClose();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Group events by category
|
|
317
|
+
const eventsByCategory = AVAILABLE_EVENTS.reduce((acc, event) => {
|
|
318
|
+
if (!acc[event.category]) {
|
|
319
|
+
acc[event.category] = [];
|
|
320
|
+
}
|
|
321
|
+
acc[event.category].push(event);
|
|
322
|
+
return acc;
|
|
323
|
+
}, {} as Record<string, typeof AVAILABLE_EVENTS>);
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<Dialog open={open} onOpenChange={handleClose}>
|
|
327
|
+
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
|
328
|
+
<DialogHeader>
|
|
329
|
+
<DialogTitle>Create Webhook</DialogTitle>
|
|
330
|
+
<DialogDescription>
|
|
331
|
+
Create a new webhook to receive event notifications
|
|
332
|
+
</DialogDescription>
|
|
333
|
+
</DialogHeader>
|
|
334
|
+
|
|
335
|
+
<form
|
|
336
|
+
onSubmit={handleSubmit((values) => createMutation.mutate(values))}
|
|
337
|
+
>
|
|
338
|
+
<div className="space-y-4">
|
|
339
|
+
<div className="space-y-2">
|
|
340
|
+
<Label htmlFor="name">Name</Label>
|
|
341
|
+
<Input
|
|
342
|
+
id="name"
|
|
343
|
+
placeholder="e.g., Slack Notifications"
|
|
344
|
+
{...register("name", { required: true })}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div className="space-y-2">
|
|
349
|
+
<Label htmlFor="url">Webhook URL</Label>
|
|
350
|
+
<Input
|
|
351
|
+
id="url"
|
|
352
|
+
type="url"
|
|
353
|
+
placeholder="https://example.com/webhook"
|
|
354
|
+
{...register("url", { required: true })}
|
|
355
|
+
/>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="space-y-2">
|
|
359
|
+
<Label>Events to Subscribe</Label>
|
|
360
|
+
<div className="space-y-3 max-h-60 overflow-y-auto border rounded-md p-3">
|
|
361
|
+
{Object.entries(eventsByCategory).map(([category, events]) => (
|
|
362
|
+
<div key={category}>
|
|
363
|
+
<p className="text-sm font-semibold mb-2">{category}</p>
|
|
364
|
+
<div className="space-y-2 ml-2">
|
|
365
|
+
{events.map((event) => (
|
|
366
|
+
<div
|
|
367
|
+
key={event.value}
|
|
368
|
+
className="flex items-center space-x-2"
|
|
369
|
+
>
|
|
370
|
+
<Checkbox
|
|
371
|
+
id={event.value}
|
|
372
|
+
checked={watch("events").includes(event.value)}
|
|
373
|
+
onCheckedChange={() => toggleEvent(event.value)}
|
|
374
|
+
/>
|
|
375
|
+
<label
|
|
376
|
+
htmlFor={event.value}
|
|
377
|
+
className="text-sm cursor-pointer"
|
|
378
|
+
>
|
|
379
|
+
{event.label}
|
|
380
|
+
</label>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<div className="flex justify-end gap-2">
|
|
390
|
+
<Button type="button" variant="outline" onClick={handleClose}>
|
|
391
|
+
Cancel
|
|
392
|
+
</Button>
|
|
393
|
+
<Button type="submit" disabled={createMutation.isPending}>
|
|
394
|
+
Create
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</form>
|
|
399
|
+
</DialogContent>
|
|
400
|
+
</Dialog>
|
|
401
|
+
);
|
|
402
|
+
};
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
DropdownMenuLabel,
|
|
4
4
|
DropdownMenuSeparator,
|
|
5
5
|
} from "@/components/ui/dropdown-menu";
|
|
6
|
-
import { Database, Settings, User } from "lucide-react";
|
|
6
|
+
import { Database, Settings, User, Webhook } from "lucide-react";
|
|
7
7
|
import { CanAccess } from "ra-core";
|
|
8
8
|
import { Link, matchPath, useLocation } from "react-router";
|
|
9
9
|
import { RefreshButton } from "@/components/admin/refresh-button";
|
|
@@ -64,6 +64,7 @@ const Header = () => {
|
|
|
64
64
|
<UserMenu>
|
|
65
65
|
<ConfigurationMenu />
|
|
66
66
|
<DatabaseMenu />
|
|
67
|
+
<IntegrationsMenu />
|
|
67
68
|
<CanAccess resource="sales" action="list">
|
|
68
69
|
<UsersMenu />
|
|
69
70
|
</CanAccess>
|
|
@@ -165,4 +166,16 @@ const DatabaseMenu = () => {
|
|
|
165
166
|
);
|
|
166
167
|
};
|
|
167
168
|
|
|
169
|
+
const IntegrationsMenu = () => {
|
|
170
|
+
const { onClose } = useUserMenu() ?? {};
|
|
171
|
+
return (
|
|
172
|
+
<DropdownMenuItem asChild onClick={onClose}>
|
|
173
|
+
<Link to="/integrations" className="flex items-center gap-2">
|
|
174
|
+
<Webhook className="h-4 w-4" />
|
|
175
|
+
Integrations
|
|
176
|
+
</Link>
|
|
177
|
+
</DropdownMenuItem>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
168
181
|
export default Header;
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import sales from "../sales";
|
|
27
27
|
import { DatabasePage } from "../settings/DatabasePage";
|
|
28
28
|
import { SettingsPage } from "../settings/SettingsPage";
|
|
29
|
+
import { IntegrationsPage } from "../integrations/IntegrationsPage";
|
|
29
30
|
import type { ConfigurationContextValue } from "./ConfigurationContext";
|
|
30
31
|
import { ConfigurationProvider } from "./ConfigurationContext";
|
|
31
32
|
import {
|
|
@@ -163,6 +164,7 @@ export const CRM = ({
|
|
|
163
164
|
<CustomRoutes>
|
|
164
165
|
<Route path={SettingsPage.path} element={<SettingsPage />} />
|
|
165
166
|
<Route path={DatabasePage.path} element={<DatabasePage />} />
|
|
167
|
+
<Route path={IntegrationsPage.path} element={<IntegrationsPage />} />
|
|
166
168
|
</CustomRoutes>
|
|
167
169
|
<Resource name="deals" {...deals} />
|
|
168
170
|
<Resource name="contacts" {...contacts} />
|