realtimex-crm 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/realtimex-crm.js +56 -32
  2. package/dist/assets/{DealList-DnGVfS15.js → DealList-DbwJCRGl.js} +2 -2
  3. package/dist/assets/{DealList-DnGVfS15.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-DPrpo5Xq.js.map → index-mE-upBfc.js.map} +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/stats.html +1 -1
  9. package/package.json +2 -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/CreateChannelDialog.tsx +139 -0
  15. package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
  16. package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +15 -3
  17. package/supabase/fix_webhook_hardcoded.sql +34 -0
  18. package/supabase/functions/_shared/ingestionGuard.ts +128 -0
  19. package/supabase/functions/_shared/utils.ts +1 -1
  20. package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
  21. package/supabase/functions/ingest-activity/index.ts +261 -0
  22. package/supabase/migrations/20251219120100_webhook_triggers.sql +10 -5
  23. package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
  24. package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
  25. package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
  26. package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
  27. package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
  28. package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
  29. package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
  30. package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
  31. package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
  32. package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
  33. package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
  34. package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
  35. package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
  36. package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
  37. package/dist/assets/index-DPrpo5Xq.js +0 -159
  38. package/dist/assets/index-kM1Og1AS.css +0 -1
@@ -0,0 +1,261 @@
1
+ import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
+ import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
3
+ import { createErrorResponse, corsHeaders } from "../_shared/utils.ts";
4
+ import { validateIngestionRequest, validateTwilioWebhook } from "../_shared/ingestionGuard.ts";
5
+
6
+ /**
7
+ * Sanitize filename to remove special characters that break storage paths
8
+ * Preserves extension and basic readability while keeping original in metadata
9
+ */
10
+ function sanitizeFilename(filename: string): string {
11
+ // Get file extension
12
+ const lastDotIndex = filename.lastIndexOf('.');
13
+ const name = lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
14
+ const ext = lastDotIndex > 0 ? filename.substring(lastDotIndex) : '';
15
+
16
+ // Replace special characters with underscores, keep alphanumeric and basic punctuation
17
+ const safeName = name
18
+ .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
19
+ .replace(/_+/g, '_') // Collapse multiple underscores
20
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
21
+
22
+ // Limit length to avoid path issues (200 chars for name + extension)
23
+ const maxLength = 200 - ext.length;
24
+ const truncatedName = safeName.length > maxLength
25
+ ? safeName.substring(0, maxLength)
26
+ : safeName;
27
+
28
+ return truncatedName + ext;
29
+ }
30
+
31
+ Deno.serve(async (req) => {
32
+ // Handle CORS
33
+ if (req.method === "OPTIONS") {
34
+ return new Response(null, { status: 204, headers: corsHeaders });
35
+ }
36
+
37
+ try {
38
+ // 1. Validate & Identify Provider
39
+ const validation = await validateIngestionRequest(req);
40
+ if (validation.error) return validation.error;
41
+
42
+ const { provider } = validation;
43
+
44
+ // 2. Parse Body based on Content-Type
45
+ const contentType = req.headers.get("content-type") || "";
46
+ let rawBody: any;
47
+ const uploadedFiles: Array<{ fieldName: string; storagePath: string; size: number; type: string }> = [];
48
+
49
+ if (contentType.includes("application/json")) {
50
+ rawBody = await req.json();
51
+ } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
52
+ const formData = await req.formData();
53
+ rawBody = {};
54
+
55
+ // Process FormData entries (files + fields)
56
+ for (const [key, value] of formData.entries()) {
57
+ if (value instanceof File) {
58
+ // Handle file upload: upload to storage immediately
59
+ const file = value as File;
60
+ const timestamp = Date.now();
61
+
62
+ // Sanitize filename to remove special characters that break storage paths
63
+ // Keep original filename in metadata, use safe version for storage path
64
+ const sanitizedName = sanitizeFilename(file.name);
65
+ const storagePath = `incoming/${timestamp}_${sanitizedName}`;
66
+
67
+ console.log(`Uploading file: ${file.name} → ${sanitizedName} (${file.size} bytes, ${file.type})`);
68
+
69
+ const { error: uploadError } = await supabaseAdmin.storage
70
+ .from('activity-payloads')
71
+ .upload(storagePath, file, {
72
+ contentType: file.type || 'application/octet-stream',
73
+ upsert: false,
74
+ });
75
+
76
+ if (uploadError) {
77
+ console.error(`Failed to upload file ${file.name}:`, uploadError);
78
+ return createErrorResponse(500, `File upload failed: ${uploadError.message}`);
79
+ }
80
+
81
+ console.log(`File uploaded successfully: ${storagePath}`);
82
+
83
+ // Store file reference instead of file content
84
+ uploadedFiles.push({
85
+ fieldName: key,
86
+ storagePath,
87
+ size: file.size,
88
+ type: file.type || 'application/octet-stream',
89
+ });
90
+
91
+ // Add storage reference to rawBody
92
+ rawBody[key] = {
93
+ _type: 'file_ref',
94
+ storage_path: storagePath,
95
+ filename: file.name,
96
+ size: file.size,
97
+ mime_type: file.type,
98
+ };
99
+ } else {
100
+ // Regular form field
101
+ rawBody[key] = value;
102
+ }
103
+ }
104
+ } else {
105
+ rawBody = await req.text();
106
+ }
107
+
108
+ // 2.5. Validate Twilio Signature (after body is parsed)
109
+ if (provider.provider_code === "twilio" && provider.config?.auth_token) {
110
+ const isValid = await validateTwilioWebhook(req, provider.config.auth_token, rawBody);
111
+ if (!isValid) {
112
+ console.error("Invalid Twilio signature");
113
+ return createErrorResponse(401, "Invalid Twilio Signature");
114
+ }
115
+ }
116
+
117
+ // 3. Normalize Data (The "Normalizer" Pattern)
118
+ const normalized = normalizeActivity(provider.provider_code, rawBody);
119
+
120
+ // 4. Add file upload metadata if files were uploaded
121
+ const activityMetadata = {
122
+ ...normalized.metadata,
123
+ provider_code: provider.provider_code,
124
+ };
125
+
126
+ if (uploadedFiles.length > 0) {
127
+ activityMetadata.uploaded_files = uploadedFiles;
128
+ activityMetadata.has_attachments = true;
129
+ }
130
+
131
+ // 5. Insert into 'activities' table
132
+ const { data, error } = await supabaseAdmin
133
+ .from("activities")
134
+ .insert({
135
+ type: normalized.type,
136
+ direction: "inbound",
137
+ processing_status: "raw", // Ready for Local Agent to steal
138
+ raw_data: normalized.raw_data,
139
+ metadata: activityMetadata,
140
+ provider_id: provider.id,
141
+ sales_id: provider.sales_id, // Auto-assign if provider has an owner
142
+ payload_storage_status: uploadedFiles.length > 0 ? 'in_storage' : undefined, // Files uploaded directly to storage
143
+ })
144
+ .select()
145
+ .single();
146
+
147
+ if (error) {
148
+ console.error("DB Insert Error:", error);
149
+ console.error("Error details:", JSON.stringify(error, null, 2));
150
+ return createErrorResponse(500, `Failed to persist activity: ${error.message || JSON.stringify(error)}`);
151
+ }
152
+
153
+ return new Response(JSON.stringify({ success: true, id: data.id }), {
154
+ status: 202, // Accepted
155
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
156
+ });
157
+
158
+ } catch (err) {
159
+ console.error("Ingestion Error:", err);
160
+ return createErrorResponse(500, "Internal Server Error");
161
+ }
162
+ });
163
+
164
+ /**
165
+ * Normalizes provider-specific payloads into the standard RealTimeX schema.
166
+ */
167
+ function normalizeActivity(providerCode: string, payload: any) {
168
+ let type = "other";
169
+ let raw_data = {};
170
+ let metadata = {};
171
+
172
+ if (providerCode === "postmark") {
173
+ type = "email";
174
+ raw_data = {
175
+ source_type: "text",
176
+ content: payload.TextBody || payload.HtmlBody || "",
177
+ subject: payload.Subject,
178
+ sender: payload.From,
179
+ };
180
+ metadata = {
181
+ message_id: payload.MessageID,
182
+ to: payload.To,
183
+ };
184
+ } else if (providerCode === "twilio") {
185
+ // Detect SMS vs Voice
186
+ if (payload.RecordingUrl) {
187
+ type = "call";
188
+ raw_data = {
189
+ source_type: "url",
190
+ content: payload.RecordingUrl, // The Local Agent will download this
191
+ format: "audio/wav",
192
+ };
193
+ metadata = {
194
+ call_sid: payload.CallSid,
195
+ from: payload.From,
196
+ duration: payload.CallDuration,
197
+ };
198
+ } else {
199
+ type = "sms";
200
+ raw_data = {
201
+ source_type: "text",
202
+ content: payload.Body,
203
+ };
204
+ metadata = {
205
+ message_sid: payload.MessageSid,
206
+ from: payload.From,
207
+ };
208
+ }
209
+ } else {
210
+ // Generic / Manual / Multipart
211
+ type = payload.type || "note";
212
+
213
+ // Check if payload contains file references from multipart upload
214
+ const fileFields = Object.entries(payload).filter(([_, value]) =>
215
+ typeof value === 'object' && value !== null && value._type === 'file_ref'
216
+ );
217
+
218
+ if (fileFields.length > 0) {
219
+ // Handle uploaded files
220
+ if (fileFields.length === 1) {
221
+ // Single file - store in raw_data
222
+ const [_fieldName, fileRef] = fileFields[0];
223
+ raw_data = {
224
+ source_type: "storage_ref",
225
+ storage_path: (fileRef as any).storage_path,
226
+ filename: (fileRef as any).filename,
227
+ format: (fileRef as any).mime_type,
228
+ size: (fileRef as any).size,
229
+ };
230
+ } else {
231
+ // Multiple files - store array in raw_data
232
+ raw_data = {
233
+ source_type: "storage_refs",
234
+ files: fileFields.map(([fieldName, fileRef]) => ({
235
+ storage_path: (fileRef as any).storage_path,
236
+ filename: (fileRef as any).filename,
237
+ format: (fileRef as any).mime_type,
238
+ size: (fileRef as any).size,
239
+ field_name: fieldName,
240
+ })),
241
+ };
242
+ }
243
+
244
+ // Include other form fields in metadata
245
+ metadata = {
246
+ ...payload.metadata,
247
+ form_data: Object.fromEntries(
248
+ Object.entries(payload).filter(([key]) =>
249
+ !key.startsWith('_') && typeof payload[key] !== 'object'
250
+ )
251
+ ),
252
+ };
253
+ } else {
254
+ // Regular JSON payload
255
+ raw_data = payload.raw_data || { source_type: "text", content: JSON.stringify(payload) };
256
+ metadata = payload.metadata || {};
257
+ }
258
+ }
259
+
260
+ return { type, raw_data, metadata };
261
+ }
@@ -5,7 +5,7 @@ create or replace function enqueue_webhook_event(
5
5
  ) returns void
6
6
  language plpgsql
7
7
  security definer
8
- set search_path = ''
8
+ set search_path = public
9
9
  as $$
10
10
  begin
11
11
  -- Find all active webhooks that listen to this event
@@ -26,7 +26,7 @@ create or replace function trigger_contact_webhooks()
26
26
  returns trigger
27
27
  language plpgsql
28
28
  security definer
29
- set search_path = ''
29
+ set search_path = public
30
30
  as $$
31
31
  declare
32
32
  event_type text;
@@ -58,7 +58,7 @@ create or replace function trigger_company_webhooks()
58
58
  returns trigger
59
59
  language plpgsql
60
60
  security definer
61
- set search_path = ''
61
+ set search_path = public
62
62
  as $$
63
63
  declare
64
64
  event_type text;
@@ -90,7 +90,7 @@ create or replace function trigger_deal_webhooks()
90
90
  returns trigger
91
91
  language plpgsql
92
92
  security definer
93
- set search_path = ''
93
+ set search_path = public
94
94
  as $$
95
95
  declare
96
96
  event_type text;
@@ -142,7 +142,7 @@ create or replace function trigger_task_webhooks()
142
142
  returns trigger
143
143
  language plpgsql
144
144
  security definer
145
- set search_path = ''
145
+ set search_path = public
146
146
  as $$
147
147
  begin
148
148
  if (TG_OP = 'UPDATE' and OLD.done_date is null and NEW.done_date is not null) then
@@ -169,3 +169,8 @@ create trigger deal_webhook_trigger
169
169
  create trigger task_webhook_trigger
170
170
  after update on public.tasks
171
171
  for each row execute function trigger_task_webhooks();
172
+
173
+ -- Grant execute permissions on webhook functions
174
+ grant execute on function enqueue_webhook_event(text, jsonb) to authenticated;
175
+ grant execute on function enqueue_webhook_event(text, jsonb) to service_role;
176
+ grant execute on function enqueue_webhook_event(text, jsonb) to anon;
@@ -0,0 +1,154 @@
1
+ -- Migration: Create Activities Table and Ingestion Logic
2
+
3
+ -- 1. Create the 'activities' table (The Unified Event Store)
4
+ create table "public"."activities" (
5
+ "id" uuid not null default gen_random_uuid(),
6
+ "created_at" timestamp with time zone not null default now(),
7
+ "type" text not null check (type in ('email', 'call', 'sms', 'meeting', 'note', 'task', 'whatsapp', 'other')),
8
+ "direction" text not null check (direction in ('inbound', 'outbound')),
9
+ "sales_id" bigint, -- References sales(id). If NULL, it is Global/Unassigned.
10
+ "contact_id" bigint, -- References contacts(id).
11
+
12
+ -- Processing State Machine
13
+ "processing_status" text not null default 'raw' check (processing_status in ('raw', 'processing', 'completed', 'failed')),
14
+ "locked_by" uuid, -- The ID of the generic user (auth.users) or specific agent (sales.id) processing it.
15
+ "locked_at" timestamp with time zone,
16
+
17
+ -- Data Payloads
18
+ "raw_data" jsonb, -- Stores URL or Text content. { "source_type": "url"|"text", "content": "..." }
19
+ "processed_data" jsonb, -- Stores AI results. { "transcript": "...", "summary": "..." }
20
+ "metadata" jsonb, -- Provider info. { "twilio_sid": "...", "from": "..." }
21
+ "provider_id" uuid, -- Link to ingestion_providers(id) for future flexibility
22
+
23
+ constraint "activities_pkey" primary key ("id")
24
+ );
25
+
26
+ -- Enable RLS
27
+ alter table "public"."activities" enable row level security;
28
+
29
+ -- 2. Create the 'ingestion_providers' table (Configuration Registry)
30
+ create table "public"."ingestion_providers" (
31
+ "id" uuid not null default gen_random_uuid(),
32
+ "created_at" timestamp with time zone not null default now(),
33
+ "provider_code" text not null check (provider_code in ('twilio', 'postmark', 'gmail', 'generic')),
34
+ "name" text not null, -- Friendly name e.g. "US Support Line"
35
+ "is_active" boolean not null default true,
36
+ "config" jsonb not null default '{}'::jsonb, -- Encrypted secrets go here
37
+ "sales_id" bigint, -- Default Owner. If NULL, leads are Unassigned.
38
+ "ingestion_key" text unique, -- Public identifier used in Webhook URLs e.g. /ingest?key=xyz
39
+
40
+ constraint "ingestion_providers_pkey" primary key ("id")
41
+ );
42
+
43
+ alter table "public"."ingestion_providers" enable row level security;
44
+
45
+ -- 3. Update 'sales' table with Processing Rules
46
+ alter table "public"."sales"
47
+ add column "stale_threshold_minutes" integer default 15,
48
+ add column "allow_remote_processing" boolean default true;
49
+
50
+ -- 4. Foreign Keys
51
+ alter table "public"."activities"
52
+ add constraint "activities_sales_id_fkey" foreign key ("sales_id") references "public"."sales"("id") on delete set null,
53
+ add constraint "activities_contact_id_fkey" foreign key ("contact_id") references "public"."contacts"("id") on delete cascade,
54
+ add constraint "activities_provider_id_fkey" foreign key ("provider_id") references "public"."ingestion_providers"("id") on delete set null;
55
+
56
+ alter table "public"."ingestion_providers"
57
+ add constraint "ingestion_providers_sales_id_fkey" foreign key ("sales_id") references "public"."sales"("id") on delete set null;
58
+
59
+ -- 5. Indexes for Performance (Crucial for Work Stealing)
60
+ create index "activities_processing_queue_idx" on "public"."activities" ("processing_status", "created_at") where processing_status = 'raw';
61
+ create index "activities_sales_id_idx" on "public"."activities" ("sales_id");
62
+
63
+ -- 6. RPC: The "Work Stealing" Function
64
+ -- Note: p_agent_sales_id must be the BIGINT id from the 'sales' table, not auth.uid()
65
+ create or replace function claim_next_pending_activity(
66
+ p_agent_sales_id bigint
67
+ )
68
+ returns table (
69
+ id uuid,
70
+ raw_data jsonb,
71
+ type text,
72
+ is_global boolean
73
+ )
74
+ language plpgsql
75
+ security definer -- Runs with elevated privileges to update the row
76
+ as $$
77
+ begin
78
+ return query
79
+ update "public"."activities" a
80
+ set
81
+ processing_status = 'processing',
82
+ locked_by = (select user_id from sales where id = p_agent_sales_id), -- Store the Auth UUID for auditing
83
+ locked_at = now()
84
+ from "public"."sales" s_owner
85
+ where a.id = (
86
+ select act.id
87
+ from "public"."activities" act
88
+ left join "public"."sales" owner on act.sales_id = owner.id
89
+ where
90
+ act.processing_status = 'raw'
91
+ and (
92
+ -- CRITERIA 1: IT IS MINE
93
+ act.sales_id = p_agent_sales_id
94
+
95
+ -- CRITERIA 2: IT IS GLOBAL
96
+ or act.sales_id is null
97
+
98
+ -- CRITERIA 3: IT IS STALE AND STEALABLE
99
+ or (
100
+ act.sales_id != p_agent_sales_id -- Not mine
101
+ and (owner.allow_remote_processing is true or owner.allow_remote_processing is null) -- Owner allows stealing (default true)
102
+ and act.created_at < now() - ((coalesce(owner.stale_threshold_minutes, 15) || ' minutes')::interval)
103
+ )
104
+ )
105
+ order by
106
+ (act.sales_id = p_agent_sales_id) desc, -- My tasks first
107
+ (act.sales_id is null) desc, -- Global tasks second
108
+ act.created_at asc -- Oldest stale tasks last
109
+ limit 1
110
+ for update skip locked
111
+ )
112
+ and (a.sales_id = s_owner.id or a.sales_id is null) -- Join condition
113
+ returning a.id, a.raw_data, a.type, (a.sales_id is null) as is_global;
114
+ end;
115
+ $$;
116
+
117
+ -- 7. Trigger: Link Ingestion Providers -> Sales ID on Insert
118
+ -- If we insert an activity with a provider_id but no sales_id, try to auto-assign it based on the provider config.
119
+ create or replace function auto_assign_activity_owner()
120
+ returns trigger
121
+ language plpgsql
122
+ as $$
123
+ begin
124
+ if new.sales_id is null and new.provider_id is not null then
125
+ select sales_id into new.sales_id
126
+ from ingestion_providers
127
+ where id = new.provider_id;
128
+ end if;
129
+ return new;
130
+ end;
131
+ $$;
132
+
133
+ create trigger "before_insert_activity_assign_owner"
134
+ before insert on "public"."activities"
135
+ for each row execute function auto_assign_activity_owner();
136
+
137
+ -- 8. Enable pg_cron (if available, this might fail on some Supabase tiers so we wrap it)
138
+ do $$
139
+ begin
140
+ create extension if not exists pg_cron;
141
+ exception when others then
142
+ raise notice 'pg_cron extension could not be enabled - skipping cron setup';
143
+ end
144
+ $$;
145
+
146
+ -- 9. RLS Policies
147
+ -- Activities: Authenticated users can read all (for Team view). Only Owner or Global can be updated (unless claiming).
148
+ create policy "Enable read access for authenticated users" on "public"."activities" for select to authenticated using (true);
149
+ create policy "Enable insert for authenticated users" on "public"."activities" for insert to authenticated with check (true);
150
+ create policy "Enable update for authenticated users" on "public"."activities" for update to authenticated using (true);
151
+
152
+ -- Ingestion Providers: Admins only? For now, authenticated read.
153
+ create policy "Enable read access for authenticated users" on "public"."ingestion_providers" for select to authenticated using (true);
154
+
@@ -0,0 +1,94 @@
1
+ -- Migration: Add Contact Matching for Activities
2
+ -- Automatically links activities to contacts based on email or phone number
3
+
4
+ -- Function: Normalize phone number to E.164 format for matching
5
+ -- Strips formatting and ensures consistent format
6
+ CREATE OR REPLACE FUNCTION normalize_phone(phone_input text)
7
+ RETURNS text
8
+ LANGUAGE plpgsql
9
+ IMMUTABLE
10
+ AS $$
11
+ BEGIN
12
+ -- Remove all non-digit characters except leading +
13
+ RETURN regexp_replace(phone_input, '[^+0-9]', '', 'g');
14
+ END;
15
+ $$;
16
+
17
+ -- Function: Auto-link activity to contact based on email or phone
18
+ -- Priority: Email match > Phone match (E.164) > Fuzzy phone match (last 10 digits)
19
+ CREATE OR REPLACE FUNCTION auto_link_contact()
20
+ RETURNS TRIGGER
21
+ LANGUAGE plpgsql
22
+ AS $$
23
+ DECLARE
24
+ matched_contact_id bigint;
25
+ BEGIN
26
+ -- Only attempt matching if contact_id is not already set
27
+ IF NEW.contact_id IS NOT NULL THEN
28
+ RETURN NEW;
29
+ END IF;
30
+
31
+ -- Strategy 1: Try exact email match
32
+ -- Note: email is stored as email_jsonb (array of email objects)
33
+ IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '%@%' THEN
34
+ SELECT id INTO matched_contact_id
35
+ FROM contacts
36
+ WHERE email_jsonb @> jsonb_build_array(jsonb_build_object('email', NEW.metadata->>'from'))
37
+ LIMIT 1;
38
+
39
+ IF matched_contact_id IS NOT NULL THEN
40
+ NEW.contact_id := matched_contact_id;
41
+ RETURN NEW;
42
+ END IF;
43
+ END IF;
44
+
45
+ -- Strategy 2: Try exact phone match (E.164 normalized)
46
+ -- Note: phones are stored as phone_jsonb (array of phone objects)
47
+ IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '+%' THEN
48
+ SELECT id INTO matched_contact_id
49
+ FROM contacts
50
+ WHERE EXISTS (
51
+ SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
52
+ WHERE normalize_phone(phone->>'number') = normalize_phone(NEW.metadata->>'from')
53
+ )
54
+ LIMIT 1;
55
+
56
+ IF matched_contact_id IS NOT NULL THEN
57
+ NEW.contact_id := matched_contact_id;
58
+ RETURN NEW;
59
+ END IF;
60
+ END IF;
61
+
62
+ -- Strategy 3: Try fuzzy phone match (last 10 digits for US numbers)
63
+ IF NEW.metadata ? 'from' AND length(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g')) >= 10 THEN
64
+ SELECT id INTO matched_contact_id
65
+ FROM contacts
66
+ WHERE EXISTS (
67
+ SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
68
+ WHERE right(regexp_replace(phone->>'number', '[^0-9]', '', 'g'), 10) =
69
+ right(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g'), 10)
70
+ )
71
+ LIMIT 1;
72
+
73
+ IF matched_contact_id IS NOT NULL THEN
74
+ NEW.contact_id := matched_contact_id;
75
+ RETURN NEW;
76
+ END IF;
77
+ END IF;
78
+
79
+ -- No match found - activity will be created as "orphan" with contact_id = NULL
80
+ RETURN NEW;
81
+ END;
82
+ $$;
83
+
84
+ -- Trigger: Auto-link contacts before inserting activities
85
+ CREATE TRIGGER before_insert_activity_link_contact
86
+ BEFORE INSERT ON "public"."activities"
87
+ FOR EACH ROW
88
+ EXECUTE FUNCTION auto_link_contact();
89
+
90
+ -- Comment for documentation
91
+ COMMENT ON FUNCTION auto_link_contact() IS
92
+ 'Automatically links activities to contacts based on email or phone number in metadata.from field.
93
+ Priority: Email match > Phone E.164 match > Fuzzy phone match (last 10 digits).
94
+ Activities without matches become orphans (contact_id = NULL).';
@@ -0,0 +1,23 @@
1
+ -- Migration: Fix RLS policies for ingestion_providers table
2
+ -- Add missing INSERT, UPDATE, DELETE policies for authenticated users
3
+
4
+ -- Allow authenticated users to create ingestion channels
5
+ create policy "Enable insert for authenticated users"
6
+ on "public"."ingestion_providers"
7
+ for insert
8
+ to authenticated
9
+ with check (true);
10
+
11
+ -- Allow authenticated users to update their ingestion channels
12
+ create policy "Enable update for authenticated users"
13
+ on "public"."ingestion_providers"
14
+ for update
15
+ to authenticated
16
+ using (true);
17
+
18
+ -- Allow authenticated users to delete their ingestion channels
19
+ create policy "Enable delete for authenticated users"
20
+ on "public"."ingestion_providers"
21
+ for delete
22
+ to authenticated
23
+ using (true);
@@ -0,0 +1,67 @@
1
+ -- Migration: Fix contact matching to work with email_jsonb and phone_jsonb schema
2
+
3
+ -- Drop and recreate the auto_link_contact function with correct JSONB queries
4
+ CREATE OR REPLACE FUNCTION auto_link_contact()
5
+ RETURNS TRIGGER
6
+ LANGUAGE plpgsql
7
+ AS $$
8
+ DECLARE
9
+ matched_contact_id bigint;
10
+ BEGIN
11
+ -- Only attempt matching if contact_id is not already set
12
+ IF NEW.contact_id IS NOT NULL THEN
13
+ RETURN NEW;
14
+ END IF;
15
+
16
+ -- Strategy 1: Try exact email match
17
+ -- Note: email is stored as email_jsonb (array of email objects)
18
+ IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '%@%' THEN
19
+ SELECT id INTO matched_contact_id
20
+ FROM contacts
21
+ WHERE email_jsonb @> jsonb_build_array(jsonb_build_object('email', NEW.metadata->>'from'))
22
+ LIMIT 1;
23
+
24
+ IF matched_contact_id IS NOT NULL THEN
25
+ NEW.contact_id := matched_contact_id;
26
+ RETURN NEW;
27
+ END IF;
28
+ END IF;
29
+
30
+ -- Strategy 2: Try exact phone match (E.164 normalized)
31
+ -- Note: phones are stored as phone_jsonb (array of phone objects)
32
+ IF NEW.metadata ? 'from' AND NEW.metadata->>'from' LIKE '+%' THEN
33
+ SELECT id INTO matched_contact_id
34
+ FROM contacts
35
+ WHERE EXISTS (
36
+ SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
37
+ WHERE normalize_phone(phone->>'number') = normalize_phone(NEW.metadata->>'from')
38
+ )
39
+ LIMIT 1;
40
+
41
+ IF matched_contact_id IS NOT NULL THEN
42
+ NEW.contact_id := matched_contact_id;
43
+ RETURN NEW;
44
+ END IF;
45
+ END IF;
46
+
47
+ -- Strategy 3: Try fuzzy phone match (last 10 digits for US numbers)
48
+ IF NEW.metadata ? 'from' AND length(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g')) >= 10 THEN
49
+ SELECT id INTO matched_contact_id
50
+ FROM contacts
51
+ WHERE EXISTS (
52
+ SELECT 1 FROM jsonb_array_elements(phone_jsonb) AS phone
53
+ WHERE right(regexp_replace(phone->>'number', '[^0-9]', '', 'g'), 10) =
54
+ right(regexp_replace(NEW.metadata->>'from', '[^0-9]', '', 'g'), 10)
55
+ )
56
+ LIMIT 1;
57
+
58
+ IF matched_contact_id IS NOT NULL THEN
59
+ NEW.contact_id := matched_contact_id;
60
+ RETURN NEW;
61
+ END IF;
62
+ END IF;
63
+
64
+ -- No match found - activity will be created as "orphan" with contact_id = NULL
65
+ RETURN NEW;
66
+ END;
67
+ $$;