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/dist/assets/{DealList-C_bZazrP.js → DealList-CFcN25AN.js} +2 -2
- package/dist/assets/{DealList-C_bZazrP.js.map → DealList-CFcN25AN.js.map} +1 -1
- package/dist/assets/{index-6eOQryrt.js → index-BxvvgMGy.js} +50 -50
- package/dist/assets/{index-6eOQryrt.js.map → index-BxvvgMGy.js.map} +1 -1
- package/dist/assets/index-DIJpijUP.css +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +1 -1
- package/src/components/atomic-crm/contacts/ContactAside.tsx +3 -0
- package/src/components/atomic-crm/contacts/ContactHealthCard.tsx +201 -0
- package/src/components/atomic-crm/contacts/ContactListFilter.tsx +47 -1
- package/src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts +37 -0
- package/src/components/atomic-crm/types.ts +20 -0
- package/supabase/migrations/20251226100000_add_contact_heartbeats.sql +237 -0
- package/supabase/migrations/20251226101500_backfill_contact_heartbeats.sql +12 -0
- package/dist/assets/index-BFJ29Og3.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.13.
|
|
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 $$;
|