realtimex-crm 0.13.3 → 0.13.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimex-crm",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "RealTimeX CRM - A full-featured CRM built with React, shadcn-admin-kit, and Supabase. Fork of Atomic CRM with RealTimeX App SDK integration.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,6 +21,7 @@ import { SaleName } from "../sales/SaleName";
21
21
  import type { Contact } from "../types";
22
22
  import { ContactMergeButton } from "./ContactMergeButton";
23
23
  import { ExportVCardButton } from "./ExportVCardButton";
24
+ import { ContactHealthCard } from "./ContactHealthCard";
24
25
 
25
26
  export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
26
27
  const { contactGender } = useConfigurationContext();
@@ -37,6 +38,8 @@ export const ContactAside = ({ link = "edit" }: { link?: "edit" | "show" }) => {
37
38
  )}
38
39
  </div>
39
40
 
41
+ <ContactHealthCard />
42
+
40
43
  <AsideSection title="Personal info">
41
44
  <ArrayField source="email_jsonb">
42
45
  <SingleFieldList className="flex-col">
@@ -0,0 +1,201 @@
1
+ import { formatDistance } from "date-fns";
2
+ import { Activity, HeartPulse, Mail, Linkedin } from "lucide-react";
3
+ import { useRecordContext } from "ra-core";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Progress } from "@/components/ui/progress";
6
+
7
+ import { AsideSection } from "../misc/AsideSection";
8
+ import type { Contact } from "../types";
9
+
10
+ const InternalStatusBadge = ({ status }: { status: string }) => {
11
+ let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
12
+ let className = "";
13
+
14
+ switch (status) {
15
+ case "strong":
16
+ variant = "default";
17
+ break;
18
+ case "active":
19
+ variant = "secondary";
20
+ className = "bg-green-100 text-green-800 hover:bg-green-100";
21
+ break;
22
+ case "cooling":
23
+ variant = "secondary";
24
+ className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
25
+ break;
26
+ case "cold":
27
+ variant = "secondary";
28
+ className = "bg-orange-100 text-orange-800 hover:bg-orange-100";
29
+ break;
30
+ case "dormant":
31
+ variant = "secondary";
32
+ break;
33
+ }
34
+
35
+ return (
36
+ <Badge variant={variant} className={className}>
37
+ {status.charAt(0).toUpperCase() + status.slice(1)}
38
+ </Badge>
39
+ );
40
+ };
41
+
42
+ const EmailStatusBadge = ({ status }: { status: string }) => {
43
+ let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
44
+ let className = "";
45
+
46
+ switch (status) {
47
+ case "valid":
48
+ variant = "secondary";
49
+ className = "bg-green-100 text-green-800 hover:bg-green-100";
50
+ break;
51
+ case "risky":
52
+ variant = "secondary";
53
+ className = "bg-yellow-100 text-yellow-800 hover:bg-yellow-100";
54
+ break;
55
+ case "invalid":
56
+ variant = "destructive";
57
+ break;
58
+ }
59
+
60
+ return (
61
+ <Badge variant={variant} className={className}>
62
+ {status.charAt(0).toUpperCase() + status.slice(1)}
63
+ </Badge>
64
+ );
65
+ };
66
+
67
+ const LinkedInStatusBadge = ({ status }: { status: string }) => {
68
+ let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
69
+ let className = "";
70
+
71
+ switch (status) {
72
+ case "active":
73
+ variant = "default";
74
+ className = "bg-[#0077b5] hover:bg-[#0077b5]"; // LinkedIn Blue
75
+ break;
76
+ case "inactive":
77
+ variant = "secondary";
78
+ break;
79
+ case "not_found":
80
+ variant = "destructive";
81
+ break;
82
+ }
83
+
84
+ return (
85
+ <Badge variant={variant} className={className}>
86
+ {status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}
87
+ </Badge>
88
+ );
89
+ };
90
+
91
+ export const ContactHealthCard = () => {
92
+ const record = useRecordContext<Contact>();
93
+ if (!record) return null;
94
+
95
+ const daysSince = record.days_since_last_activity ??
96
+ (record.last_seen ? Math.floor((new Date().getTime() - new Date(record.last_seen).getTime()) / (1000 * 60 * 60 * 24)) : undefined);
97
+
98
+ const hasInternalHealth =
99
+ record.internal_heartbeat_score != null ||
100
+ record.internal_heartbeat_status != null ||
101
+ daysSince != null;
102
+
103
+ const hasExternalHealth =
104
+ record.external_heartbeat_status != null ||
105
+ record.email_validation_status != null ||
106
+ record.linkedin_profile_status != null;
107
+
108
+ return (
109
+ <AsideSection title="Contact Health">
110
+ {!hasInternalHealth && !hasExternalHealth && (
111
+ <div className="text-xs text-muted-foreground italic">
112
+ No health data calculated yet.
113
+ </div>
114
+ )}
115
+
116
+ {/* Internal Heartbeat (Relationship Strength) */}
117
+ {hasInternalHealth && (
118
+ <div className="mb-4">
119
+ <div className="flex items-center gap-2 mb-2">
120
+ <Activity className="w-4 h-4 text-muted-foreground" />
121
+ <span className="font-medium text-sm">Relationship Strength</span>
122
+ </div>
123
+
124
+ {record.internal_heartbeat_score != null && (
125
+ <div className="mb-2">
126
+ <div className="flex items-center justify-between mb-1">
127
+ <span className="text-xs text-muted-foreground">
128
+ Engagement Score
129
+ </span>
130
+ <span className="text-xs font-medium">
131
+ {record.internal_heartbeat_score}/100
132
+ </span>
133
+ </div>
134
+ <Progress
135
+ value={record.internal_heartbeat_score}
136
+ className="h-2"
137
+ />
138
+ </div>
139
+ )}
140
+
141
+ {record.internal_heartbeat_status && (
142
+ <div className="mb-2">
143
+ <InternalStatusBadge status={record.internal_heartbeat_status} />
144
+ </div>
145
+ )}
146
+
147
+ {daysSince != null && (
148
+ <div className="text-xs text-muted-foreground">
149
+ Last activity:{" "}
150
+ {daysSince === 0
151
+ ? "Today"
152
+ : daysSince === 1
153
+ ? "Yesterday"
154
+ : `${daysSince} days ago`}
155
+ </div>
156
+ )}
157
+ </div>
158
+ )}
159
+
160
+ {/* External Heartbeat (Contact Validation) */}
161
+ {hasExternalHealth && (
162
+ <div>
163
+ <div className="flex items-center gap-2 mb-2">
164
+ <HeartPulse className="w-4 h-4 text-muted-foreground" />
165
+ <span className="font-medium text-sm">Contact Validation</span>
166
+ </div>
167
+
168
+ {record.email_validation_status && (
169
+ <div className="flex items-center gap-2 mb-2">
170
+ <Mail className="w-3 h-3 text-muted-foreground" />
171
+ <EmailStatusBadge status={record.email_validation_status} />
172
+ {record.email_last_bounced_at && (
173
+ <span className="text-xs text-destructive">
174
+ (bounced)
175
+ </span>
176
+ )}
177
+ </div>
178
+ )}
179
+
180
+ {record.linkedin_profile_status && (
181
+ <div className="flex items-center gap-2 mb-2">
182
+ <Linkedin className="w-3 h-3 text-muted-foreground" />
183
+ <LinkedInStatusBadge status={record.linkedin_profile_status} />
184
+ </div>
185
+ )}
186
+
187
+ {record.external_heartbeat_checked_at && (
188
+ <div className="text-xs text-muted-foreground">
189
+ Validated{" "}
190
+ {formatDistance(
191
+ new Date(record.external_heartbeat_checked_at),
192
+ new Date(),
193
+ { addSuffix: true },
194
+ )}
195
+ </div>
196
+ )}
197
+ </div>
198
+ )}
199
+ </AsideSection>
200
+ );
201
+ };
@@ -1,5 +1,5 @@
1
1
  import { endOfYesterday, startOfMonth, startOfWeek, subMonths } from "date-fns";
2
- import { CheckSquare, Clock, Tag, TrendingUp, Users } from "lucide-react";
2
+ import { Activity, CheckSquare, Clock, HeartPulse, Tag, TrendingUp, Users } from "lucide-react";
3
3
  import { FilterLiveForm, useGetIdentity, useGetList } from "ra-core";
4
4
  import { ToggleFilterButton } from "@/components/admin/toggle-filter-button";
5
5
  import { SearchInput } from "@/components/admin/search-input";
@@ -69,6 +69,52 @@ export const ContactListFilter = () => {
69
69
  />
70
70
  </FilterCategory>
71
71
 
72
+ <FilterCategory label="Engagement" icon={<Activity />}>
73
+ <ToggleFilterButton
74
+ className="w-full justify-between"
75
+ label="Strong"
76
+ value={{ internal_heartbeat_status: "strong" }}
77
+ />
78
+ <ToggleFilterButton
79
+ className="w-full justify-between"
80
+ label="Active"
81
+ value={{ internal_heartbeat_status: "active" }}
82
+ />
83
+ <ToggleFilterButton
84
+ className="w-full justify-between"
85
+ label="Cooling"
86
+ value={{ internal_heartbeat_status: "cooling" }}
87
+ />
88
+ <ToggleFilterButton
89
+ className="w-full justify-between"
90
+ label="Cold"
91
+ value={{ internal_heartbeat_status: "cold" }}
92
+ />
93
+ <ToggleFilterButton
94
+ className="w-full justify-between"
95
+ label="Dormant"
96
+ value={{ internal_heartbeat_status: "dormant" }}
97
+ />
98
+ </FilterCategory>
99
+
100
+ <FilterCategory label="Validation" icon={<HeartPulse />}>
101
+ <ToggleFilterButton
102
+ className="w-full justify-between"
103
+ label="Valid Email"
104
+ value={{ email_validation_status: "valid" }}
105
+ />
106
+ <ToggleFilterButton
107
+ className="w-full justify-between"
108
+ label="Risky Email"
109
+ value={{ email_validation_status: "risky" }}
110
+ />
111
+ <ToggleFilterButton
112
+ className="w-full justify-between"
113
+ label="Invalid Email"
114
+ value={{ email_validation_status: "invalid" }}
115
+ />
116
+ </FilterCategory>
117
+
72
118
  <FilterCategory label="Status" icon={<TrendingUp />}>
73
119
  {noteStatuses.map((status) => (
74
120
  <ToggleFilterButton
@@ -5,6 +5,8 @@ import {
5
5
  name,
6
6
  phone,
7
7
  random,
8
+ date,
9
+ datatype,
8
10
  } from "faker/locale/en_US";
9
11
 
10
12
  import {
@@ -75,6 +77,38 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
75
77
  const first_seen = randomDate(new Date(company.created_at)).toISOString();
76
78
  const last_seen = first_seen;
77
79
 
80
+ // Heartbeat generation
81
+ const daysInactive = datatype.number({ min: 0, max: 365 });
82
+
83
+ const computeScore = (days: number): number => {
84
+ if (days <= 7) return datatype.number({ min: 80, max: 100 });
85
+ if (days <= 30) return datatype.number({ min: 60, max: 79 });
86
+ if (days <= 90) return datatype.number({ min: 40, max: 59 });
87
+ if (days <= 180) return datatype.number({ min: 20, max: 39 });
88
+ return datatype.number({ min: 0, max: 19 });
89
+ };
90
+
91
+ const score = computeScore(daysInactive);
92
+
93
+ // Internal heartbeat (70% populated)
94
+ const internalHeartbeat = Math.random() > 0.3 ? {
95
+ internal_heartbeat_score: score,
96
+ internal_heartbeat_status:
97
+ score >= 80 ? 'strong' :
98
+ score >= 60 ? 'active' :
99
+ score >= 40 ? 'cooling' :
100
+ score >= 20 ? 'cold' : 'dormant',
101
+ internal_heartbeat_updated_at: date.recent(7).toISOString(),
102
+ } : {};
103
+
104
+ // External heartbeat (50% populated)
105
+ const externalHeartbeat = Math.random() > 0.5 ? {
106
+ external_heartbeat_status: random.arrayElement(['valid', 'warning', 'invalid', 'unknown']),
107
+ external_heartbeat_checked_at: date.recent(30).toISOString(),
108
+ email_validation_status: random.arrayElement(['valid', 'risky', 'invalid', 'unknown']),
109
+ linkedin_profile_status: random.arrayElement(['active', 'inactive', 'not_found', 'unknown']),
110
+ } : {};
111
+
78
112
  return {
79
113
  id,
80
114
  first_name,
@@ -98,6 +132,9 @@ export const generateContacts = (db: Db, size = 500): Required<Contact>[] => {
98
132
  sales_id: company.sales_id,
99
133
  nb_tasks: 0,
100
134
  linkedin_url: null,
135
+ ...internalHeartbeat,
136
+ ...externalHeartbeat,
137
+ days_since_last_activity: daysInactive,
101
138
  };
102
139
  });
103
140
  };
@@ -142,6 +142,26 @@ export type Contact = {
142
142
  phone_jsonb: PhoneNumberAndType[];
143
143
  nb_tasks?: number;
144
144
  company_name?: string;
145
+
146
+ // Internal Heartbeat
147
+ internal_heartbeat_score?: number;
148
+ internal_heartbeat_status?: string; // 'strong' | 'active' | 'cooling' | 'cold' | 'dormant'
149
+ internal_heartbeat_updated_at?: string;
150
+
151
+ // External Heartbeat
152
+ external_heartbeat_status?: string; // 'valid' | 'warning' | 'invalid' | 'unknown'
153
+ external_heartbeat_checked_at?: string;
154
+ email_validation_status?: string; // 'valid' | 'risky' | 'invalid' | 'unknown'
155
+ email_last_bounced_at?: string;
156
+ linkedin_profile_status?: string; // 'active' | 'inactive' | 'not_found' | 'unknown'
157
+ employment_verified_at?: string;
158
+
159
+ // View-computed fields
160
+ nb_completed_tasks?: number;
161
+ task_completion_rate?: number;
162
+ last_note_date?: string;
163
+ last_task_activity?: string;
164
+ days_since_last_activity?: number;
145
165
  } & Pick<RaRecord, "id">;
146
166
 
147
167
  export type ContactNote = {
@@ -0,0 +1,237 @@
1
+ -- ============================================================================
2
+ -- Contact Heartbeats Migration
3
+ -- ============================================================================
4
+
5
+ -- 1. Schema Changes
6
+ -- ============================================================================
7
+
8
+ -- Internal engagement tracking
9
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS internal_heartbeat_score integer;
10
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS internal_heartbeat_status text;
11
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS internal_heartbeat_updated_at timestamptz DEFAULT now();
12
+
13
+ -- External validation tracking
14
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS external_heartbeat_status text;
15
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS external_heartbeat_checked_at timestamptz;
16
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS email_validation_status text;
17
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS email_last_bounced_at timestamptz;
18
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS linkedin_profile_status text;
19
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS employment_verified_at timestamptz;
20
+
21
+ -- 2. Constraints and Indexes
22
+ -- ============================================================================
23
+
24
+ -- Score range constraint
25
+ DO $$
26
+ BEGIN
27
+ IF NOT EXISTS (
28
+ SELECT 1 FROM pg_constraint WHERE conname = 'chk_contact_internal_heartbeat_score'
29
+ ) THEN
30
+ ALTER TABLE contacts ADD CONSTRAINT chk_contact_internal_heartbeat_score
31
+ CHECK (internal_heartbeat_score IS NULL OR (internal_heartbeat_score BETWEEN 0 AND 100));
32
+ END IF;
33
+ END $$;
34
+
35
+ -- Indexes for filtering
36
+ CREATE INDEX IF NOT EXISTS idx_contacts_internal_heartbeat
37
+ ON contacts(internal_heartbeat_status, internal_heartbeat_score);
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_contacts_external_heartbeat
40
+ ON contacts(external_heartbeat_status);
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_contacts_email_validation
43
+ ON contacts(email_validation_status);
44
+
45
+ -- 3. Functions
46
+ -- ============================================================================
47
+
48
+ -- Function to auto-update timestamp
49
+ CREATE OR REPLACE FUNCTION update_contact_internal_heartbeat_timestamp()
50
+ RETURNS TRIGGER AS $$
51
+ BEGIN
52
+ IF (NEW.internal_heartbeat_score IS DISTINCT FROM OLD.internal_heartbeat_score)
53
+ OR (NEW.internal_heartbeat_status IS DISTINCT FROM OLD.internal_heartbeat_status) THEN
54
+ NEW.internal_heartbeat_updated_at = now();
55
+ END IF;
56
+ RETURN NEW;
57
+ END;
58
+ $$ LANGUAGE plpgsql;
59
+
60
+ DROP TRIGGER IF EXISTS set_contact_internal_heartbeat_timestamp ON contacts;
61
+ CREATE TRIGGER set_contact_internal_heartbeat_timestamp
62
+ BEFORE UPDATE ON contacts
63
+ FOR EACH ROW
64
+ EXECUTE FUNCTION update_contact_internal_heartbeat_timestamp();
65
+
66
+ -- Core computation function
67
+ CREATE OR REPLACE FUNCTION compute_contact_internal_heartbeat(target_contact_id bigint)
68
+ RETURNS void AS $$
69
+ DECLARE
70
+ v_nb_tasks integer;
71
+ v_nb_notes integer;
72
+ v_nb_completed_tasks integer;
73
+ v_last_activity_date timestamptz;
74
+
75
+ v_days_since_activity integer;
76
+ v_task_completion_rate numeric;
77
+
78
+ v_recency_score integer := 0;
79
+ v_frequency_score integer := 0;
80
+ v_quality_score integer := 0;
81
+ v_final_score integer := 0;
82
+ v_status text;
83
+ BEGIN
84
+ -- Gather metrics
85
+ SELECT
86
+ COUNT(DISTINCT t.id),
87
+ COUNT(DISTINCT cn.id),
88
+ COUNT(DISTINCT t.id) FILTER (WHERE t.done_date IS NOT NULL),
89
+ GREATEST(MAX(cn.date), MAX(t.due_date), MAX(c.last_seen))
90
+ INTO
91
+ v_nb_tasks,
92
+ v_nb_notes,
93
+ v_nb_completed_tasks,
94
+ v_last_activity_date
95
+ FROM contacts c
96
+ LEFT JOIN "contactNotes" cn ON c.id = cn.contact_id
97
+ LEFT JOIN tasks t ON c.id = t.contact_id
98
+ WHERE c.id = target_contact_id
99
+ GROUP BY c.id;
100
+
101
+ -- Handle NULLs
102
+ v_nb_tasks := COALESCE(v_nb_tasks, 0);
103
+ v_nb_notes := COALESCE(v_nb_notes, 0);
104
+ v_nb_completed_tasks := COALESCE(v_nb_completed_tasks, 0);
105
+
106
+ -- Calculate derived metrics
107
+ IF v_last_activity_date IS NOT NULL THEN
108
+ v_days_since_activity := EXTRACT(EPOCH FROM (now() - v_last_activity_date))/86400;
109
+ ELSE
110
+ v_days_since_activity := 999999;
111
+ END IF;
112
+
113
+ IF v_nb_tasks > 0 THEN
114
+ v_task_completion_rate := v_nb_completed_tasks::numeric / v_nb_tasks::numeric;
115
+ ELSE
116
+ v_task_completion_rate := 0;
117
+ END IF;
118
+
119
+ -- 1. Recency Score (0-50)
120
+ v_recency_score := CASE
121
+ WHEN v_days_since_activity <= 7 THEN 50
122
+ WHEN v_days_since_activity <= 30 THEN 40
123
+ WHEN v_days_since_activity <= 90 THEN 25
124
+ WHEN v_days_since_activity <= 180 THEN 10
125
+ ELSE 0
126
+ END;
127
+
128
+ -- 2. Frequency Score (0-30)
129
+ v_frequency_score := LEAST(30, (v_nb_tasks + v_nb_notes) * 2);
130
+
131
+ -- 3. Quality Score (0-20)
132
+ v_quality_score := CASE
133
+ WHEN v_task_completion_rate >= 0.8 THEN 20
134
+ WHEN v_task_completion_rate >= 0.5 THEN 15
135
+ WHEN v_task_completion_rate >= 0.3 THEN 10
136
+ ELSE 5
137
+ END;
138
+
139
+ -- Final Score
140
+ v_final_score := v_recency_score + v_frequency_score + v_quality_score;
141
+
142
+ -- Status Mapping
143
+ v_status := CASE
144
+ WHEN v_final_score >= 80 THEN 'strong'
145
+ WHEN v_final_score >= 60 THEN 'active'
146
+ WHEN v_final_score >= 40 THEN 'cooling'
147
+ WHEN v_final_score >= 20 THEN 'cold'
148
+ ELSE 'dormant'
149
+ END;
150
+
151
+ -- Update Contact
152
+ UPDATE contacts
153
+ SET
154
+ internal_heartbeat_score = v_final_score,
155
+ internal_heartbeat_status = v_status,
156
+ internal_heartbeat_updated_at = now()
157
+ WHERE id = target_contact_id;
158
+
159
+ END;
160
+ $$ LANGUAGE plpgsql;
161
+
162
+ -- 4. Triggers for Auto-Computation
163
+ -- ============================================================================
164
+
165
+ -- Trigger for Tasks
166
+ CREATE OR REPLACE FUNCTION trigger_recompute_contact_heartbeat_from_task()
167
+ RETURNS TRIGGER AS $$
168
+ BEGIN
169
+ IF NEW.contact_id IS NOT NULL THEN
170
+ PERFORM compute_contact_internal_heartbeat(NEW.contact_id);
171
+ END IF;
172
+ RETURN NEW;
173
+ END;
174
+ $$ LANGUAGE plpgsql;
175
+
176
+ DROP TRIGGER IF EXISTS trg_update_contact_heartbeat_on_task ON tasks;
177
+ CREATE TRIGGER trg_update_contact_heartbeat_on_task
178
+ AFTER INSERT OR UPDATE ON tasks
179
+ FOR EACH ROW
180
+ EXECUTE FUNCTION trigger_recompute_contact_heartbeat_from_task();
181
+
182
+ -- Trigger for Notes
183
+ CREATE OR REPLACE FUNCTION trigger_recompute_contact_heartbeat_from_note()
184
+ RETURNS TRIGGER AS $$
185
+ BEGIN
186
+ PERFORM compute_contact_internal_heartbeat(NEW.contact_id);
187
+ RETURN NEW;
188
+ END;
189
+ $$ LANGUAGE plpgsql;
190
+
191
+ DROP TRIGGER IF EXISTS trg_update_contact_heartbeat_on_note ON "contactNotes";
192
+ CREATE TRIGGER trg_update_contact_heartbeat_on_note
193
+ AFTER INSERT OR UPDATE ON "contactNotes"
194
+ FOR EACH ROW
195
+ EXECUTE FUNCTION trigger_recompute_contact_heartbeat_from_note();
196
+
197
+ -- 5. Update View
198
+ -- ============================================================================
199
+
200
+ DROP VIEW IF EXISTS contacts_summary CASCADE;
201
+
202
+ CREATE VIEW contacts_summary
203
+ WITH (security_invoker=on)
204
+ AS
205
+ SELECT
206
+ c.*,
207
+ comp.name as company_name,
208
+
209
+ -- Existing aggregations
210
+ COUNT(DISTINCT t.id) as nb_tasks,
211
+ COUNT(DISTINCT cn.id) as nb_notes,
212
+ COUNT(DISTINCT t.id) FILTER (WHERE t.done_date IS NULL) as nb_open_tasks,
213
+
214
+ -- New: Task completion metrics
215
+ COUNT(DISTINCT t.id) FILTER (WHERE t.done_date IS NOT NULL) as nb_completed_tasks,
216
+ CASE
217
+ WHEN COUNT(DISTINCT t.id) > 0
218
+ THEN ROUND(COUNT(DISTINCT t.id) FILTER (WHERE t.done_date IS NOT NULL)::numeric / COUNT(DISTINCT t.id), 2)
219
+ ELSE 0
220
+ END as task_completion_rate,
221
+
222
+ -- Activity timestamps
223
+ MAX(cn.date) as last_note_date,
224
+ MAX(t.due_date) as last_task_activity,
225
+
226
+ -- Computed engagement indicator (days since last activity)
227
+ LEAST(
228
+ COALESCE(EXTRACT(EPOCH FROM (now() - c.last_seen))/86400, 999999),
229
+ COALESCE(EXTRACT(EPOCH FROM (now() - MAX(cn.date)))/86400, 999999),
230
+ COALESCE(EXTRACT(EPOCH FROM (now() - MAX(t.due_date)))/86400, 999999)
231
+ )::integer as days_since_last_activity
232
+
233
+ FROM contacts c
234
+ LEFT JOIN "contactNotes" cn ON c.id = cn.contact_id
235
+ LEFT JOIN tasks t ON c.id = t.contact_id
236
+ LEFT JOIN companies comp ON c.company_id = comp.id
237
+ GROUP BY c.id, comp.name;
@@ -0,0 +1,12 @@
1
+ -- ============================================================================
2
+ -- Backfill Contact Heartbeats
3
+ -- ============================================================================
4
+
5
+ DO $$
6
+ DECLARE
7
+ r RECORD;
8
+ BEGIN
9
+ FOR r IN SELECT id FROM contacts LOOP
10
+ PERFORM compute_contact_internal_heartbeat(r.id);
11
+ END LOOP;
12
+ END $$;