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.
Files changed (29) hide show
  1. package/dist/assets/{DealList-B3alafQv.js → DealList-DnGVfS15.js} +2 -2
  2. package/dist/assets/{DealList-B3alafQv.js.map → DealList-DnGVfS15.js.map} +1 -1
  3. package/dist/assets/index-DPrpo5Xq.js +159 -0
  4. package/dist/assets/index-DPrpo5Xq.js.map +1 -0
  5. package/dist/assets/index-kM1Og1AS.css +1 -0
  6. package/dist/index.html +1 -1
  7. package/dist/stats.html +1 -1
  8. package/package.json +2 -1
  9. package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
  10. package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
  11. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +34 -0
  12. package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
  13. package/src/components/atomic-crm/layout/Header.tsx +14 -1
  14. package/src/components/atomic-crm/root/CRM.tsx +2 -0
  15. package/src/components/ui/alert-dialog.tsx +155 -0
  16. package/src/lib/api-key-utils.ts +22 -0
  17. package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
  18. package/supabase/functions/_shared/webhookSignature.ts +23 -0
  19. package/supabase/functions/api-v1-activities/index.ts +137 -0
  20. package/supabase/functions/api-v1-companies/index.ts +166 -0
  21. package/supabase/functions/api-v1-contacts/index.ts +171 -0
  22. package/supabase/functions/api-v1-deals/index.ts +166 -0
  23. package/supabase/functions/webhook-dispatcher/index.ts +133 -0
  24. package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
  25. package/supabase/migrations/20251219120100_webhook_triggers.sql +171 -0
  26. package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
  27. package/dist/assets/index-CHk72bAf.css +0 -1
  28. package/dist/assets/index-mhAjQ1_w.js +0 -153
  29. package/dist/assets/index-mhAjQ1_w.js.map +0 -1
@@ -0,0 +1,171 @@
1
+ import { supabaseAdmin } from "./supabaseAdmin.ts";
2
+ import { createErrorResponse } from "./utils.ts";
3
+
4
+ // Rate limiting: Simple in-memory store (resets on function restart)
5
+ const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
6
+
7
+ const RATE_LIMIT_MAX = 100; // requests per window
8
+ const RATE_LIMIT_WINDOW = 60 * 1000; // 60 seconds
9
+
10
+ interface ApiKey {
11
+ id: number;
12
+ sales_id: number;
13
+ name: string;
14
+ scopes: string[];
15
+ is_active: boolean;
16
+ expires_at: string | null;
17
+ }
18
+
19
+ /**
20
+ * Extract and validate API key from request
21
+ * Returns api key record or error response
22
+ */
23
+ export async function validateApiKey(
24
+ req: Request
25
+ ): Promise<{ apiKey: ApiKey } | Response> {
26
+ const authHeader = req.headers.get("Authorization");
27
+
28
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
29
+ return createErrorResponse(401, "Missing or invalid Authorization header");
30
+ }
31
+
32
+ const apiKeyValue = authHeader.replace("Bearer ", "");
33
+
34
+ if (!apiKeyValue.startsWith("ak_live_")) {
35
+ return createErrorResponse(401, "Invalid API key format");
36
+ }
37
+
38
+ // Hash the API key for lookup
39
+ const encoder = new TextEncoder();
40
+ const data = encoder.encode(apiKeyValue);
41
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
42
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
43
+ const keyHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
44
+ ""
45
+ );
46
+
47
+ // Lookup API key in database
48
+ const { data: apiKey, error } = await supabaseAdmin
49
+ .from("api_keys")
50
+ .select("id, sales_id, name, scopes, is_active, expires_at")
51
+ .eq("key_hash", keyHash)
52
+ .single();
53
+
54
+ if (error || !apiKey) {
55
+ return createErrorResponse(401, "Invalid API key");
56
+ }
57
+
58
+ // Check if active
59
+ if (!apiKey.is_active) {
60
+ return createErrorResponse(401, "API key is disabled");
61
+ }
62
+
63
+ // Check expiration
64
+ if (apiKey.expires_at && new Date(apiKey.expires_at) < new Date()) {
65
+ return createErrorResponse(401, "API key has expired");
66
+ }
67
+
68
+ // Update last_used_at (fire and forget)
69
+ supabaseAdmin
70
+ .from("api_keys")
71
+ .update({ last_used_at: new Date().toISOString() })
72
+ .eq("id", apiKey.id)
73
+ .then(() => {});
74
+
75
+ return { apiKey };
76
+ }
77
+
78
+ /**
79
+ * Check rate limit for API key
80
+ */
81
+ export function checkRateLimit(apiKeyId: number): Response | null {
82
+ const now = Date.now();
83
+ const key = `ratelimit:${apiKeyId}`;
84
+
85
+ let bucket = rateLimitStore.get(key);
86
+
87
+ if (!bucket || now > bucket.resetAt) {
88
+ // Create new bucket
89
+ bucket = {
90
+ count: 1,
91
+ resetAt: now + RATE_LIMIT_WINDOW,
92
+ };
93
+ rateLimitStore.set(key, bucket);
94
+ return null;
95
+ }
96
+
97
+ if (bucket.count >= RATE_LIMIT_MAX) {
98
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
99
+ return new Response(
100
+ JSON.stringify({
101
+ status: 429,
102
+ message: "Rate limit exceeded",
103
+ retry_after: retryAfter,
104
+ }),
105
+ {
106
+ status: 429,
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ "Retry-After": retryAfter.toString(),
110
+ "X-RateLimit-Limit": RATE_LIMIT_MAX.toString(),
111
+ "X-RateLimit-Remaining": "0",
112
+ "X-RateLimit-Reset": bucket.resetAt.toString(),
113
+ },
114
+ }
115
+ );
116
+ }
117
+
118
+ bucket.count++;
119
+
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Check if API key has required scope
125
+ */
126
+ export function hasScope(apiKey: ApiKey, requiredScope: string): boolean {
127
+ // Check for wildcard scope
128
+ if (apiKey.scopes.includes("*")) {
129
+ return true;
130
+ }
131
+
132
+ // Check for exact scope match
133
+ if (apiKey.scopes.includes(requiredScope)) {
134
+ return true;
135
+ }
136
+
137
+ // Check for resource wildcard (e.g., "contacts:*" matches "contacts:read")
138
+ const [resource] = requiredScope.split(":");
139
+ if (apiKey.scopes.includes(`${resource}:*`)) {
140
+ return true;
141
+ }
142
+
143
+ return false;
144
+ }
145
+
146
+ /**
147
+ * Log API request
148
+ */
149
+ export async function logApiRequest(
150
+ apiKeyId: number,
151
+ endpoint: string,
152
+ method: string,
153
+ statusCode: number,
154
+ responseTimeMs: number,
155
+ req: Request,
156
+ errorMessage?: string
157
+ ) {
158
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
159
+ const userAgent = req.headers.get("user-agent");
160
+
161
+ await supabaseAdmin.from("api_logs").insert({
162
+ api_key_id: apiKeyId,
163
+ endpoint,
164
+ method,
165
+ status_code: statusCode,
166
+ response_time_ms: responseTimeMs,
167
+ ip_address: ip,
168
+ user_agent: userAgent,
169
+ error_message: errorMessage,
170
+ });
171
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Generate HMAC-SHA256 signature for webhook payload
3
+ */
4
+ export async function generateWebhookSignature(
5
+ secret: string,
6
+ payload: string
7
+ ): Promise<string> {
8
+ const encoder = new TextEncoder();
9
+ const keyData = encoder.encode(secret);
10
+ const messageData = encoder.encode(payload);
11
+
12
+ const cryptoKey = await crypto.subtle.importKey(
13
+ "raw",
14
+ keyData,
15
+ { name: "HMAC", hash: "SHA-256" },
16
+ false,
17
+ ["sign"]
18
+ );
19
+
20
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
21
+ const hashArray = Array.from(new Uint8Array(signature));
22
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
23
+ }
@@ -0,0 +1,137 @@
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/activities",
29
+ req.method,
30
+ 429,
31
+ Date.now() - startTime,
32
+ req
33
+ );
34
+ return rateLimitError;
35
+ }
36
+
37
+ try {
38
+ if (req.method === "POST") {
39
+ const response = await createActivity(apiKey, req);
40
+ const responseTime = Date.now() - startTime;
41
+ await logApiRequest(
42
+ apiKey.id,
43
+ "/v1/activities",
44
+ req.method,
45
+ response.status,
46
+ responseTime,
47
+ req
48
+ );
49
+ return response;
50
+ } else {
51
+ return createErrorResponse(404, "Not found");
52
+ }
53
+ } catch (error) {
54
+ const responseTime = Date.now() - startTime;
55
+ await logApiRequest(
56
+ apiKey.id,
57
+ "/v1/activities",
58
+ req.method,
59
+ 500,
60
+ responseTime,
61
+ req,
62
+ error.message
63
+ );
64
+ return createErrorResponse(500, "Internal server error");
65
+ }
66
+ });
67
+
68
+ async function createActivity(apiKey: any, req: Request) {
69
+ if (!hasScope(apiKey, "activities:write")) {
70
+ return createErrorResponse(403, "Insufficient permissions");
71
+ }
72
+
73
+ const body = await req.json();
74
+ const { type, ...activityData } = body;
75
+
76
+ // Activities can be notes or tasks
77
+ if (type === "note" || type === "contact_note") {
78
+ const { data, error } = await supabaseAdmin
79
+ .from("contactNotes")
80
+ .insert({
81
+ ...activityData,
82
+ sales_id: apiKey.sales_id,
83
+ })
84
+ .select()
85
+ .single();
86
+
87
+ if (error) {
88
+ return createErrorResponse(400, error.message);
89
+ }
90
+
91
+ return new Response(JSON.stringify({ data, type: "note" }), {
92
+ status: 201,
93
+ headers: { "Content-Type": "application/json", ...corsHeaders },
94
+ });
95
+ } else if (type === "task") {
96
+ const { data, error } = await supabaseAdmin
97
+ .from("tasks")
98
+ .insert({
99
+ ...activityData,
100
+ sales_id: apiKey.sales_id,
101
+ })
102
+ .select()
103
+ .single();
104
+
105
+ if (error) {
106
+ return createErrorResponse(400, error.message);
107
+ }
108
+
109
+ return new Response(JSON.stringify({ data, type: "task" }), {
110
+ status: 201,
111
+ headers: { "Content-Type": "application/json", ...corsHeaders },
112
+ });
113
+ } else if (type === "deal_note") {
114
+ const { data, error } = await supabaseAdmin
115
+ .from("dealNotes")
116
+ .insert({
117
+ ...activityData,
118
+ sales_id: apiKey.sales_id,
119
+ })
120
+ .select()
121
+ .single();
122
+
123
+ if (error) {
124
+ return createErrorResponse(400, error.message);
125
+ }
126
+
127
+ return new Response(JSON.stringify({ data, type: "deal_note" }), {
128
+ status: 201,
129
+ headers: { "Content-Type": "application/json", ...corsHeaders },
130
+ });
131
+ } else {
132
+ return createErrorResponse(
133
+ 400,
134
+ "Invalid activity type. Must be 'note', 'contact_note', 'task', or 'deal_note'"
135
+ );
136
+ }
137
+ }
@@ -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/companies",
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 companyId = pathParts[1];
41
+
42
+ let response: Response;
43
+
44
+ if (req.method === "GET" && companyId) {
45
+ response = await getCompany(apiKey, companyId);
46
+ } else if (req.method === "POST") {
47
+ response = await createCompany(apiKey, req);
48
+ } else if (req.method === "PATCH" && companyId) {
49
+ response = await updateCompany(apiKey, companyId, req);
50
+ } else if (req.method === "DELETE" && companyId) {
51
+ response = await deleteCompany(apiKey, companyId);
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 getCompany(apiKey: any, companyId: string) {
83
+ if (!hasScope(apiKey, "companies:read")) {
84
+ return createErrorResponse(403, "Insufficient permissions");
85
+ }
86
+
87
+ const { data, error } = await supabaseAdmin
88
+ .from("companies")
89
+ .select("*")
90
+ .eq("id", companyId)
91
+ .single();
92
+
93
+ if (error || !data) {
94
+ return createErrorResponse(404, "Company not found");
95
+ }
96
+
97
+ return new Response(JSON.stringify({ data }), {
98
+ headers: { "Content-Type": "application/json", ...corsHeaders },
99
+ });
100
+ }
101
+
102
+ async function createCompany(apiKey: any, req: Request) {
103
+ if (!hasScope(apiKey, "companies:write")) {
104
+ return createErrorResponse(403, "Insufficient permissions");
105
+ }
106
+
107
+ const body = await req.json();
108
+
109
+ const { data, error } = await supabaseAdmin
110
+ .from("companies")
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 updateCompany(apiKey: any, companyId: string, req: Request) {
129
+ if (!hasScope(apiKey, "companies:write")) {
130
+ return createErrorResponse(403, "Insufficient permissions");
131
+ }
132
+
133
+ const body = await req.json();
134
+
135
+ const { data, error } = await supabaseAdmin
136
+ .from("companies")
137
+ .update(body)
138
+ .eq("id", companyId)
139
+ .select()
140
+ .single();
141
+
142
+ if (error || !data) {
143
+ return createErrorResponse(404, "Company not found");
144
+ }
145
+
146
+ return new Response(JSON.stringify({ data }), {
147
+ headers: { "Content-Type": "application/json", ...corsHeaders },
148
+ });
149
+ }
150
+
151
+ async function deleteCompany(apiKey: any, companyId: string) {
152
+ if (!hasScope(apiKey, "companies:write")) {
153
+ return createErrorResponse(403, "Insufficient permissions");
154
+ }
155
+
156
+ const { error } = await supabaseAdmin
157
+ .from("companies")
158
+ .delete()
159
+ .eq("id", companyId);
160
+
161
+ if (error) {
162
+ return createErrorResponse(404, "Company not found");
163
+ }
164
+
165
+ return new Response(null, { status: 204, headers: corsHeaders });
166
+ }
@@ -0,0 +1,171 @@
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
+ // Handle CORS
15
+ if (req.method === "OPTIONS") {
16
+ return new Response(null, { status: 204, headers: corsHeaders });
17
+ }
18
+
19
+ // Validate API key
20
+ const authResult = await validateApiKey(req);
21
+ if ("status" in authResult) {
22
+ return authResult; // Error response
23
+ }
24
+ const { apiKey } = authResult;
25
+
26
+ // Check rate limit
27
+ const rateLimitError = checkRateLimit(apiKey.id);
28
+ if (rateLimitError) {
29
+ await logApiRequest(
30
+ apiKey.id,
31
+ "/v1/contacts",
32
+ req.method,
33
+ 429,
34
+ Date.now() - startTime,
35
+ req
36
+ );
37
+ return rateLimitError;
38
+ }
39
+
40
+ try {
41
+ const url = new URL(req.url);
42
+ const pathParts = url.pathname.split("/").filter(Boolean);
43
+ // pathParts: ["api-v1-contacts", "{id}"]
44
+ const contactId = pathParts[1];
45
+
46
+ let response: Response;
47
+
48
+ if (req.method === "GET" && contactId) {
49
+ response = await getContact(apiKey, contactId);
50
+ } else if (req.method === "POST") {
51
+ response = await createContact(apiKey, req);
52
+ } else if (req.method === "PATCH" && contactId) {
53
+ response = await updateContact(apiKey, contactId, req);
54
+ } else if (req.method === "DELETE" && contactId) {
55
+ response = await deleteContact(apiKey, contactId);
56
+ } else {
57
+ response = createErrorResponse(404, "Not found");
58
+ }
59
+
60
+ const responseTime = Date.now() - startTime;
61
+ await logApiRequest(
62
+ apiKey.id,
63
+ url.pathname,
64
+ req.method,
65
+ response.status,
66
+ responseTime,
67
+ req
68
+ );
69
+
70
+ return response;
71
+ } catch (error) {
72
+ const responseTime = Date.now() - startTime;
73
+ await logApiRequest(
74
+ apiKey.id,
75
+ new URL(req.url).pathname,
76
+ req.method,
77
+ 500,
78
+ responseTime,
79
+ req,
80
+ error.message
81
+ );
82
+ return createErrorResponse(500, "Internal server error");
83
+ }
84
+ });
85
+
86
+ async function getContact(apiKey: any, contactId: string) {
87
+ if (!hasScope(apiKey, "contacts:read")) {
88
+ return createErrorResponse(403, "Insufficient permissions");
89
+ }
90
+
91
+ const id = parseInt(contactId, 10);
92
+ const { data, error } = await supabaseAdmin
93
+ .from("contacts")
94
+ .select("*")
95
+ .eq("id", id)
96
+ .single();
97
+
98
+ if (error || !data) {
99
+ return createErrorResponse(404, "Contact not found");
100
+ }
101
+
102
+ return new Response(JSON.stringify({ data }), {
103
+ headers: { "Content-Type": "application/json", ...corsHeaders },
104
+ });
105
+ }
106
+
107
+ async function createContact(apiKey: any, req: Request) {
108
+ if (!hasScope(apiKey, "contacts:write")) {
109
+ return createErrorResponse(403, "Insufficient permissions");
110
+ }
111
+
112
+ const body = await req.json();
113
+
114
+ const { data, error } = await supabaseAdmin
115
+ .from("contacts")
116
+ .insert({
117
+ ...body,
118
+ sales_id: apiKey.sales_id, // Associate with API key owner
119
+ })
120
+ .select()
121
+ .single();
122
+
123
+ if (error) {
124
+ return createErrorResponse(400, error.message);
125
+ }
126
+
127
+ return new Response(JSON.stringify({ data }), {
128
+ status: 201,
129
+ headers: { "Content-Type": "application/json", ...corsHeaders },
130
+ });
131
+ }
132
+
133
+ async function updateContact(apiKey: any, contactId: string, req: Request) {
134
+ if (!hasScope(apiKey, "contacts:write")) {
135
+ return createErrorResponse(403, "Insufficient permissions");
136
+ }
137
+
138
+ const body = await req.json();
139
+
140
+ const { data, error } = await supabaseAdmin
141
+ .from("contacts")
142
+ .update(body)
143
+ .eq("id", parseInt(contactId, 10))
144
+ .select()
145
+ .single();
146
+
147
+ if (error || !data) {
148
+ return createErrorResponse(404, "Contact not found");
149
+ }
150
+
151
+ return new Response(JSON.stringify({ data }), {
152
+ headers: { "Content-Type": "application/json", ...corsHeaders },
153
+ });
154
+ }
155
+
156
+ async function deleteContact(apiKey: any, contactId: string) {
157
+ if (!hasScope(apiKey, "contacts:write")) {
158
+ return createErrorResponse(403, "Insufficient permissions");
159
+ }
160
+
161
+ const { error } = await supabaseAdmin
162
+ .from("contacts")
163
+ .delete()
164
+ .eq("id", parseInt(contactId, 10));
165
+
166
+ if (error) {
167
+ return createErrorResponse(404, "Contact not found");
168
+ }
169
+
170
+ return new Response(null, { status: 204, headers: corsHeaders });
171
+ }