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.
Files changed (55) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
  4. package/dist/assets/index-C__S90Gb.css +1 -0
  5. package/dist/assets/index-mE-upBfc.js +166 -0
  6. package/dist/assets/index-mE-upBfc.js.map +1 -0
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +3 -1
  10. package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
  11. package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
  12. package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
  13. package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
  14. package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
  15. package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
  16. package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
  17. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  18. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
  19. package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
  20. package/src/components/atomic-crm/layout/Header.tsx +14 -1
  21. package/src/components/atomic-crm/root/CRM.tsx +2 -0
  22. package/src/components/ui/alert-dialog.tsx +155 -0
  23. package/src/lib/api-key-utils.ts +22 -0
  24. package/supabase/fix_webhook_hardcoded.sql +34 -0
  25. package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
  26. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  27. package/supabase/functions/_shared/utils.ts +1 -1
  28. package/supabase/functions/_shared/webhookSignature.ts +23 -0
  29. package/supabase/functions/api-v1-activities/index.ts +137 -0
  30. package/supabase/functions/api-v1-companies/index.ts +166 -0
  31. package/supabase/functions/api-v1-contacts/index.ts +171 -0
  32. package/supabase/functions/api-v1-deals/index.ts +166 -0
  33. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  34. package/supabase/functions/ingest-activity/index.ts +261 -0
  35. package/supabase/functions/webhook-dispatcher/index.ts +133 -0
  36. package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
  37. package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
  38. package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
  39. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  40. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  41. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  42. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  43. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  44. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  45. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  46. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  47. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  48. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  49. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  50. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  51. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  52. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  53. package/dist/assets/index-CHk72bAf.css +0 -1
  54. package/dist/assets/index-mhAjQ1_w.js +0 -153
  55. package/dist/assets/index-mhAjQ1_w.js.map +0 -1
@@ -0,0 +1,155 @@
1
+ import * as React from "react"
2
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3
+
4
+ import { cn } from "@/lib/utils"
5
+ import { buttonVariants } from "@/components/ui/button"
6
+
7
+ function AlertDialog({
8
+ ...props
9
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
10
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
11
+ }
12
+
13
+ function AlertDialogTrigger({
14
+ ...props
15
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
16
+ return (
17
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
18
+ )
19
+ }
20
+
21
+ function AlertDialogPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
24
+ return (
25
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
26
+ )
27
+ }
28
+
29
+ function AlertDialogOverlay({
30
+ className,
31
+ ...props
32
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
33
+ return (
34
+ <AlertDialogPrimitive.Overlay
35
+ data-slot="alert-dialog-overlay"
36
+ className={cn(
37
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
38
+ className
39
+ )}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ function AlertDialogContent({
46
+ className,
47
+ ...props
48
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
49
+ return (
50
+ <AlertDialogPortal>
51
+ <AlertDialogOverlay />
52
+ <AlertDialogPrimitive.Content
53
+ data-slot="alert-dialog-content"
54
+ className={cn(
55
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
56
+ className
57
+ )}
58
+ {...props}
59
+ />
60
+ </AlertDialogPortal>
61
+ )
62
+ }
63
+
64
+ function AlertDialogHeader({
65
+ className,
66
+ ...props
67
+ }: React.ComponentProps<"div">) {
68
+ return (
69
+ <div
70
+ data-slot="alert-dialog-header"
71
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+
77
+ function AlertDialogFooter({
78
+ className,
79
+ ...props
80
+ }: React.ComponentProps<"div">) {
81
+ return (
82
+ <div
83
+ data-slot="alert-dialog-footer"
84
+ className={cn(
85
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
86
+ className
87
+ )}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function AlertDialogTitle({
94
+ className,
95
+ ...props
96
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
97
+ return (
98
+ <AlertDialogPrimitive.Title
99
+ data-slot="alert-dialog-title"
100
+ className={cn("text-lg font-semibold", className)}
101
+ {...props}
102
+ />
103
+ )
104
+ }
105
+
106
+ function AlertDialogDescription({
107
+ className,
108
+ ...props
109
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
110
+ return (
111
+ <AlertDialogPrimitive.Description
112
+ data-slot="alert-dialog-description"
113
+ className={cn("text-muted-foreground text-sm", className)}
114
+ {...props}
115
+ />
116
+ )
117
+ }
118
+
119
+ function AlertDialogAction({
120
+ className,
121
+ ...props
122
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
123
+ return (
124
+ <AlertDialogPrimitive.Action
125
+ className={cn(buttonVariants(), className)}
126
+ {...props}
127
+ />
128
+ )
129
+ }
130
+
131
+ function AlertDialogCancel({
132
+ className,
133
+ ...props
134
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
135
+ return (
136
+ <AlertDialogPrimitive.Cancel
137
+ className={cn(buttonVariants({ variant: "outline" }), className)}
138
+ {...props}
139
+ />
140
+ )
141
+ }
142
+
143
+ export {
144
+ AlertDialog,
145
+ AlertDialogPortal,
146
+ AlertDialogOverlay,
147
+ AlertDialogTrigger,
148
+ AlertDialogContent,
149
+ AlertDialogHeader,
150
+ AlertDialogFooter,
151
+ AlertDialogTitle,
152
+ AlertDialogDescription,
153
+ AlertDialogAction,
154
+ AlertDialogCancel,
155
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Generate a new API key in format: ak_live_<32 hex chars>
3
+ */
4
+ export function generateApiKey(): string {
5
+ const array = new Uint8Array(16);
6
+ crypto.getRandomValues(array);
7
+ const hex = Array.from(array)
8
+ .map((b) => b.toString(16).padStart(2, "0"))
9
+ .join("");
10
+ return `ak_live_${hex}`;
11
+ }
12
+
13
+ /**
14
+ * Hash API key using SHA-256
15
+ */
16
+ export async function hashApiKey(apiKey: string): Promise<string> {
17
+ const encoder = new TextEncoder();
18
+ const data = encoder.encode(apiKey);
19
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
20
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
21
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
22
+ }
@@ -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,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,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
 
@@ -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
+ }