realtimex-crm 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{DealList-B3alafQv.js → DealList-DnGVfS15.js} +2 -2
- package/dist/assets/{DealList-B3alafQv.js.map → DealList-DnGVfS15.js.map} +1 -1
- package/dist/assets/index-DPrpo5Xq.js +159 -0
- package/dist/assets/index-DPrpo5Xq.js.map +1 -0
- package/dist/assets/index-kM1Og1AS.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +2 -1
- package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
- package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +34 -0
- package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
- package/src/components/atomic-crm/layout/Header.tsx +14 -1
- package/src/components/atomic-crm/root/CRM.tsx +2 -0
- package/src/components/ui/alert-dialog.tsx +155 -0
- package/src/lib/api-key-utils.ts +22 -0
- package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
- package/supabase/functions/_shared/webhookSignature.ts +23 -0
- package/supabase/functions/api-v1-activities/index.ts +137 -0
- package/supabase/functions/api-v1-companies/index.ts +166 -0
- package/supabase/functions/api-v1-contacts/index.ts +171 -0
- package/supabase/functions/api-v1-deals/index.ts +166 -0
- package/supabase/functions/webhook-dispatcher/index.ts +133 -0
- package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +171 -0
- package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
- package/dist/assets/index-CHk72bAf.css +0 -1
- package/dist/assets/index-mhAjQ1_w.js +0 -153
- package/dist/assets/index-mhAjQ1_w.js.map +0 -1
|
@@ -0,0 +1,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
|
+
}
|