realtimex-crm 0.9.10 → 0.11.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.
- package/dist/assets/{DealList-Bfe4r2OW.js → DealList-B_Xs90US.js} +3 -3
- package/dist/assets/{DealList-Bfe4r2OW.js.map → DealList-B_Xs90US.js.map} +1 -1
- package/dist/assets/{index-Dat6OHMH.js → index-B-3Bhhk5.js} +63 -63
- package/dist/assets/{index-Dat6OHMH.js.map → index-B-3Bhhk5.js.map} +1 -1
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +1 -1
- package/src/components/atomic-crm/companies/CompanyAside.tsx +12 -6
- package/src/components/atomic-crm/companies/CompanyMergeButton.tsx +237 -0
- package/src/components/atomic-crm/deals/stages.test.ts +54 -0
- package/src/components/atomic-crm/deals/stages.ts +3 -1
- package/src/components/atomic-crm/providers/commons/mergeCompanies.ts +119 -0
- package/src/components/atomic-crm/providers/fakerest/dataProvider.ts +4 -0
- package/src/components/atomic-crm/providers/supabase/dataProvider.ts +13 -0
- package/supabase/functions/_shared/db.ts +23 -0
- package/supabase/functions/api-v1-companies/index.ts +42 -2
- package/supabase/functions/api-v1-contacts/index.ts +38 -2
- package/supabase/functions/mergeCompanies/index.ts +163 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimex-crm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.2",
|
|
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",
|
|
@@ -13,6 +13,7 @@ import { AsideSection } from "../misc/AsideSection";
|
|
|
13
13
|
import { SaleName } from "../sales/SaleName";
|
|
14
14
|
import type { Company } from "../types";
|
|
15
15
|
import { sizes } from "./sizes";
|
|
16
|
+
import { CompanyMergeButton } from "./CompanyMergeButton";
|
|
16
17
|
|
|
17
18
|
interface CompanyAsideProps {
|
|
18
19
|
link?: string;
|
|
@@ -41,12 +42,17 @@ export const CompanyAside = ({ link = "edit" }: CompanyAsideProps) => {
|
|
|
41
42
|
<AdditionalInfo record={record} />
|
|
42
43
|
|
|
43
44
|
{link !== "edit" && (
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
<>
|
|
46
|
+
<div className="mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start">
|
|
47
|
+
<CompanyMergeButton />
|
|
48
|
+
</div>
|
|
49
|
+
<div className="mt-6 pt-6 border-t hidden sm:flex flex-col gap-2 items-start">
|
|
50
|
+
<DeleteButton
|
|
51
|
+
className="h-6 cursor-pointer hover:bg-destructive/10! text-destructive! border-destructive! focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40"
|
|
52
|
+
size="sm"
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
</>
|
|
50
56
|
)}
|
|
51
57
|
</div>
|
|
52
58
|
);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Merge, CircleX, AlertTriangle, ArrowDown } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
useDataProvider,
|
|
5
|
+
useRecordContext,
|
|
6
|
+
useGetList,
|
|
7
|
+
useGetManyReference,
|
|
8
|
+
required,
|
|
9
|
+
Form,
|
|
10
|
+
useNotify,
|
|
11
|
+
useRedirect,
|
|
12
|
+
} from "ra-core";
|
|
13
|
+
import type { Identifier } from "ra-core";
|
|
14
|
+
import { useMutation } from "@tanstack/react-query";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
} from "@/components/ui/dialog";
|
|
23
|
+
import { Button } from "@/components/ui/button";
|
|
24
|
+
import { ReferenceInput } from "@/components/admin/reference-input";
|
|
25
|
+
import { AutocompleteInput } from "@/components/admin/autocomplete-input";
|
|
26
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
27
|
+
import type { Company } from "../types";
|
|
28
|
+
|
|
29
|
+
export const CompanyMergeButton = () => {
|
|
30
|
+
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<Button
|
|
34
|
+
variant="outline"
|
|
35
|
+
className="h-6 cursor-pointer"
|
|
36
|
+
size="sm"
|
|
37
|
+
onClick={() => setMergeDialogOpen(true)}
|
|
38
|
+
>
|
|
39
|
+
<Merge className="w-4 h-4" />
|
|
40
|
+
Merge with another company
|
|
41
|
+
</Button>
|
|
42
|
+
<CompanyMergeDialog
|
|
43
|
+
open={mergeDialogOpen}
|
|
44
|
+
onClose={() => setMergeDialogOpen(false)}
|
|
45
|
+
/>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface CompanyMergeDialogProps {
|
|
51
|
+
open: boolean;
|
|
52
|
+
onClose: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CompanyMergeDialog = ({ open, onClose }: CompanyMergeDialogProps) => {
|
|
56
|
+
const loserCompany = useRecordContext<Company>();
|
|
57
|
+
const notify = useNotify();
|
|
58
|
+
const redirect = useRedirect();
|
|
59
|
+
const dataProvider = useDataProvider();
|
|
60
|
+
const [winnerId, setWinnerId] = useState<Identifier | null>(null);
|
|
61
|
+
const [suggestedWinnerId, setSuggestedWinnerId] = useState<Identifier | null>(
|
|
62
|
+
null,
|
|
63
|
+
);
|
|
64
|
+
const [isMerging, setIsMerging] = useState(false);
|
|
65
|
+
const { mutateAsync } = useMutation({
|
|
66
|
+
mutationKey: ["companies", "merge", { loserId: loserCompany?.id, winnerId }],
|
|
67
|
+
mutationFn: async () => {
|
|
68
|
+
return dataProvider.mergeCompanies(loserCompany?.id, winnerId);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Find potential companies with matching name
|
|
73
|
+
const { data: matchingCompanies } = useGetList(
|
|
74
|
+
"companies",
|
|
75
|
+
{
|
|
76
|
+
filter: {
|
|
77
|
+
name: loserCompany?.name,
|
|
78
|
+
"id@neq": `${loserCompany?.id}`, // Exclude current company
|
|
79
|
+
},
|
|
80
|
+
pagination: { page: 1, perPage: 10 },
|
|
81
|
+
},
|
|
82
|
+
{ enabled: open && !!loserCompany },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Get counts of items to be merged
|
|
86
|
+
const canFetchCounts = open && !!loserCompany && !!winnerId;
|
|
87
|
+
const { total: contactsCount } = useGetManyReference(
|
|
88
|
+
"contacts",
|
|
89
|
+
{
|
|
90
|
+
target: "company_id",
|
|
91
|
+
id: loserCompany?.id,
|
|
92
|
+
pagination: { page: 1, perPage: 1 },
|
|
93
|
+
},
|
|
94
|
+
{ enabled: canFetchCounts },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const { total: dealsCount } = useGetManyReference(
|
|
98
|
+
"deals",
|
|
99
|
+
{
|
|
100
|
+
target: "company_id",
|
|
101
|
+
id: loserCompany?.id,
|
|
102
|
+
pagination: { page: 1, perPage: 1 },
|
|
103
|
+
},
|
|
104
|
+
{ enabled: canFetchCounts },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (matchingCompanies && matchingCompanies.length > 0) {
|
|
109
|
+
const suggestedWinnerId = matchingCompanies[0].id;
|
|
110
|
+
setSuggestedWinnerId(suggestedWinnerId);
|
|
111
|
+
setWinnerId(suggestedWinnerId);
|
|
112
|
+
}
|
|
113
|
+
}, [matchingCompanies]);
|
|
114
|
+
|
|
115
|
+
const handleMerge = async () => {
|
|
116
|
+
if (!winnerId || !loserCompany) {
|
|
117
|
+
notify("Please select a company to merge with", { type: "warning" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
setIsMerging(true);
|
|
123
|
+
await mutateAsync();
|
|
124
|
+
setIsMerging(false);
|
|
125
|
+
notify("Companies merged successfully", { type: "success" });
|
|
126
|
+
redirect(`/companies/${winnerId}/show`);
|
|
127
|
+
onClose();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
setIsMerging(false);
|
|
130
|
+
notify("Failed to merge companies", { type: "error" });
|
|
131
|
+
console.error("Merge failed:", error);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (!loserCompany) return null;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Dialog open={open} onOpenChange={onClose}>
|
|
139
|
+
<DialogContent className="md:min-w-lg max-w-2xl">
|
|
140
|
+
<DialogHeader>
|
|
141
|
+
<DialogTitle>Merge Company</DialogTitle>
|
|
142
|
+
<DialogDescription>
|
|
143
|
+
Merge this company with another one.
|
|
144
|
+
</DialogDescription>
|
|
145
|
+
</DialogHeader>
|
|
146
|
+
|
|
147
|
+
<div className="space-y-4">
|
|
148
|
+
<div className="p-4 bg-primary/5 rounded-lg border border-primary/20">
|
|
149
|
+
<p className="font-medium text-sm">
|
|
150
|
+
Current Company (will be deleted)
|
|
151
|
+
</p>
|
|
152
|
+
<div className="font-medium text-sm mt-4">{loserCompany.name}</div>
|
|
153
|
+
|
|
154
|
+
<div className="flex justify-center my-4">
|
|
155
|
+
<ArrowDown className="h-5 w-5 text-muted-foreground" />
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<p className="font-medium text-sm mb-2">
|
|
159
|
+
Target Company (will be kept)
|
|
160
|
+
</p>
|
|
161
|
+
<Form>
|
|
162
|
+
<ReferenceInput
|
|
163
|
+
source="winner_id"
|
|
164
|
+
reference="companies"
|
|
165
|
+
filter={{ "id@neq": loserCompany.id }}
|
|
166
|
+
>
|
|
167
|
+
<AutocompleteInput
|
|
168
|
+
label=""
|
|
169
|
+
optionText="name"
|
|
170
|
+
validate={required()}
|
|
171
|
+
onChange={setWinnerId}
|
|
172
|
+
defaultValue={suggestedWinnerId}
|
|
173
|
+
helperText={false}
|
|
174
|
+
/>
|
|
175
|
+
</ReferenceInput>
|
|
176
|
+
</Form>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{winnerId && (
|
|
180
|
+
<>
|
|
181
|
+
<div className="space-y-2">
|
|
182
|
+
<p className="font-medium text-sm">What will be merged:</p>
|
|
183
|
+
<ul className="text-sm text-muted-foreground space-y-1 ml-4">
|
|
184
|
+
{contactsCount != null && contactsCount > 0 && (
|
|
185
|
+
<li>
|
|
186
|
+
• {contactsCount} contact
|
|
187
|
+
{contactsCount !== 1 ? "s" : ""} will be reassigned
|
|
188
|
+
</li>
|
|
189
|
+
)}
|
|
190
|
+
{dealsCount != null && dealsCount > 0 && (
|
|
191
|
+
<li>
|
|
192
|
+
• {dealsCount} deal
|
|
193
|
+
{dealsCount !== 1 ? "s" : ""} will be reassigned
|
|
194
|
+
</li>
|
|
195
|
+
)}
|
|
196
|
+
{loserCompany.context_links?.length > 0 && (
|
|
197
|
+
<li>
|
|
198
|
+
• {loserCompany.context_links.length} context link
|
|
199
|
+
{loserCompany.context_links.length !== 1 ? "s" : ""} will
|
|
200
|
+
be added
|
|
201
|
+
</li>
|
|
202
|
+
)}
|
|
203
|
+
{!contactsCount &&
|
|
204
|
+
!dealsCount &&
|
|
205
|
+
!loserCompany.context_links?.length && (
|
|
206
|
+
<li className="text-muted-foreground/60">
|
|
207
|
+
No additional data to merge
|
|
208
|
+
</li>
|
|
209
|
+
)}
|
|
210
|
+
</ul>
|
|
211
|
+
</div>
|
|
212
|
+
<Alert variant="destructive">
|
|
213
|
+
<AlertTriangle className="h-4 w-4" />
|
|
214
|
+
<AlertTitle>Warning: Destructive Operation</AlertTitle>
|
|
215
|
+
<AlertDescription>
|
|
216
|
+
All data will be transferred to the second company. This
|
|
217
|
+
action cannot be undone.
|
|
218
|
+
</AlertDescription>
|
|
219
|
+
</Alert>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<DialogFooter>
|
|
225
|
+
<Button variant="ghost" onClick={onClose} disabled={isMerging}>
|
|
226
|
+
<CircleX />
|
|
227
|
+
Cancel
|
|
228
|
+
</Button>
|
|
229
|
+
<Button onClick={handleMerge} disabled={!winnerId || isMerging}>
|
|
230
|
+
<Merge />
|
|
231
|
+
{isMerging ? "Merging..." : "Merge Companies"}
|
|
232
|
+
</Button>
|
|
233
|
+
</DialogFooter>
|
|
234
|
+
</DialogContent>
|
|
235
|
+
</Dialog>
|
|
236
|
+
);
|
|
237
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getDealsByStage } from './stages';
|
|
3
|
+
import type { Deal } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('getDealsByStage', () => {
|
|
6
|
+
const mockDealStages = [
|
|
7
|
+
{ value: 'stage1', label: 'Stage 1' },
|
|
8
|
+
{ value: 'stage2', label: 'Stage 2' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
it('should group deals by stage', () => {
|
|
12
|
+
const deals = [
|
|
13
|
+
{ id: 1, stage: 'stage1', index: 0, name: 'Deal 1' },
|
|
14
|
+
{ id: 2, stage: 'stage2', index: 0, name: 'Deal 2' },
|
|
15
|
+
{ id: 3, stage: 'stage1', index: 1, name: 'Deal 3' },
|
|
16
|
+
] as unknown as Deal[];
|
|
17
|
+
|
|
18
|
+
const result = getDealsByStage(deals, mockDealStages);
|
|
19
|
+
|
|
20
|
+
expect(result['stage1']).toHaveLength(2);
|
|
21
|
+
expect(result['stage2']).toHaveLength(1);
|
|
22
|
+
expect(result['stage1'].map(d => d.name)).toEqual(['Deal 1', 'Deal 3']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should ignore deals with unknown stages instead of crashing', () => {
|
|
26
|
+
const deals = [
|
|
27
|
+
{ id: 1, stage: 'stage1', index: 0, name: 'Deal 1' },
|
|
28
|
+
{ id: 4, stage: 'unknown_stage', index: 0, name: 'Deal 4' }, // Malformed data
|
|
29
|
+
] as unknown as Deal[];
|
|
30
|
+
|
|
31
|
+
const result = getDealsByStage(deals, mockDealStages);
|
|
32
|
+
|
|
33
|
+
expect(result['stage1']).toHaveLength(1);
|
|
34
|
+
expect(result).not.toHaveProperty('unknown_stage');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should handle empty deal list', () => {
|
|
38
|
+
const deals: Deal[] = [];
|
|
39
|
+
const result = getDealsByStage(deals, mockDealStages);
|
|
40
|
+
expect(result['stage1']).toEqual([]);
|
|
41
|
+
expect(result['stage2']).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should sort deals by index', () => {
|
|
45
|
+
const deals = [
|
|
46
|
+
{ id: 1, stage: 'stage1', index: 2, name: 'Deal 1' },
|
|
47
|
+
{ id: 3, stage: 'stage1', index: 1, name: 'Deal 3' },
|
|
48
|
+
] as unknown as Deal[];
|
|
49
|
+
|
|
50
|
+
const result = getDealsByStage(deals, mockDealStages);
|
|
51
|
+
expect(result['stage1'][0].index).toBe(1);
|
|
52
|
+
expect(result['stage1'][1].index).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -10,7 +10,9 @@ export const getDealsByStage = (
|
|
|
10
10
|
if (!dealStages) return {};
|
|
11
11
|
const dealsByStage: Record<Deal["stage"], Deal[]> = unorderedDeals.reduce(
|
|
12
12
|
(acc, deal) => {
|
|
13
|
-
acc[deal.stage]
|
|
13
|
+
if (acc[deal.stage]) {
|
|
14
|
+
acc[deal.stage].push(deal);
|
|
15
|
+
}
|
|
14
16
|
return acc;
|
|
15
17
|
},
|
|
16
18
|
dealStages.reduce(
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Identifier, DataProvider } from "ra-core";
|
|
2
|
+
|
|
3
|
+
import type { Company, Contact, Deal } from "../../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Merge one company (loser) into another company (winner).
|
|
7
|
+
*
|
|
8
|
+
* This function copies properties from the loser to the winner company,
|
|
9
|
+
* transfers all associated data (contacts, deals) from the loser to the winner,
|
|
10
|
+
* and deletes the loser company.
|
|
11
|
+
*/
|
|
12
|
+
export const mergeCompanies = async (
|
|
13
|
+
loserId: Identifier,
|
|
14
|
+
winnerId: Identifier,
|
|
15
|
+
dataProvider: DataProvider,
|
|
16
|
+
) => {
|
|
17
|
+
// Fetch both companies using dataProvider to get fresh data
|
|
18
|
+
const { data: winnerCompany } = await dataProvider.getOne<Company>(
|
|
19
|
+
"companies",
|
|
20
|
+
{ id: winnerId },
|
|
21
|
+
);
|
|
22
|
+
const { data: loserCompany } = await dataProvider.getOne<Company>(
|
|
23
|
+
"companies",
|
|
24
|
+
{ id: loserId },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!winnerCompany || !loserCompany) {
|
|
28
|
+
throw new Error("Could not fetch companies");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 1. Reassign all contacts from loser to winner
|
|
32
|
+
const { data: loserContacts } = await dataProvider.getManyReference<Contact>(
|
|
33
|
+
"contacts",
|
|
34
|
+
{
|
|
35
|
+
target: "company_id",
|
|
36
|
+
id: loserId,
|
|
37
|
+
pagination: { page: 1, perPage: 1000 },
|
|
38
|
+
sort: { field: "id", order: "ASC" },
|
|
39
|
+
filter: {},
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const contactUpdates =
|
|
44
|
+
loserContacts?.map((contact) =>
|
|
45
|
+
dataProvider.update("contacts", {
|
|
46
|
+
id: contact.id,
|
|
47
|
+
data: { company_id: winnerId },
|
|
48
|
+
previousData: contact,
|
|
49
|
+
}),
|
|
50
|
+
) || [];
|
|
51
|
+
|
|
52
|
+
// 2. Reassign all deals from loser to winner
|
|
53
|
+
const { data: loserDeals } = await dataProvider.getManyReference<Deal>(
|
|
54
|
+
"deals",
|
|
55
|
+
{
|
|
56
|
+
target: "company_id",
|
|
57
|
+
id: loserId,
|
|
58
|
+
pagination: { page: 1, perPage: 1000 },
|
|
59
|
+
sort: { field: "id", order: "ASC" },
|
|
60
|
+
filter: {},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const dealUpdates =
|
|
65
|
+
loserDeals?.map((deal) =>
|
|
66
|
+
dataProvider.update<Deal>("deals", {
|
|
67
|
+
id: deal.id,
|
|
68
|
+
data: { company_id: winnerId },
|
|
69
|
+
previousData: deal,
|
|
70
|
+
}),
|
|
71
|
+
) || [];
|
|
72
|
+
|
|
73
|
+
// 3. Update winner company with loser data
|
|
74
|
+
const mergedContextLinks = mergeArraysUnique(
|
|
75
|
+
winnerCompany.context_links || [],
|
|
76
|
+
loserCompany.context_links || [],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const winnerUpdate = dataProvider.update<Company>("companies", {
|
|
80
|
+
id: winnerId,
|
|
81
|
+
data: {
|
|
82
|
+
logo:
|
|
83
|
+
winnerCompany.logo && winnerCompany.logo.src
|
|
84
|
+
? winnerCompany.logo
|
|
85
|
+
: loserCompany.logo,
|
|
86
|
+
sector: winnerCompany.sector ?? loserCompany.sector,
|
|
87
|
+
size: winnerCompany.size ?? loserCompany.size,
|
|
88
|
+
linkedin_url: winnerCompany.linkedin_url || loserCompany.linkedin_url,
|
|
89
|
+
website: winnerCompany.website || loserCompany.website,
|
|
90
|
+
phone_number: winnerCompany.phone_number ?? loserCompany.phone_number,
|
|
91
|
+
address: winnerCompany.address ?? loserCompany.address,
|
|
92
|
+
zipcode: winnerCompany.zipcode ?? loserCompany.zipcode,
|
|
93
|
+
city: winnerCompany.city ?? loserCompany.city,
|
|
94
|
+
stateAbbr: winnerCompany.stateAbbr ?? loserCompany.stateAbbr,
|
|
95
|
+
country: winnerCompany.country ?? loserCompany.country,
|
|
96
|
+
description: winnerCompany.description ?? loserCompany.description,
|
|
97
|
+
revenue: winnerCompany.revenue ?? loserCompany.revenue,
|
|
98
|
+
tax_identifier:
|
|
99
|
+
winnerCompany.tax_identifier ?? loserCompany.tax_identifier,
|
|
100
|
+
sales_id: winnerCompany.sales_id ?? loserCompany.sales_id,
|
|
101
|
+
context_links: mergedContextLinks,
|
|
102
|
+
},
|
|
103
|
+
previousData: winnerCompany,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Execute all updates
|
|
107
|
+
await Promise.all([...contactUpdates, ...dealUpdates, winnerUpdate]);
|
|
108
|
+
|
|
109
|
+
// 4. Delete the loser company
|
|
110
|
+
await dataProvider.delete<Company>("companies", {
|
|
111
|
+
id: loserId,
|
|
112
|
+
previousData: loserCompany,
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Helper function to merge arrays and remove duplicates
|
|
117
|
+
const mergeArraysUnique = <T>(arr1: T[], arr2: T[]): T[] => [
|
|
118
|
+
...new Set([...arr1, ...arr2]),
|
|
119
|
+
];
|
|
@@ -21,6 +21,7 @@ import { getActivityLog } from "../commons/activity";
|
|
|
21
21
|
import { getCompanyAvatar } from "../commons/getCompanyAvatar";
|
|
22
22
|
import { getContactAvatar } from "../commons/getContactAvatar";
|
|
23
23
|
import { mergeContacts } from "../commons/mergeContacts";
|
|
24
|
+
import { mergeCompanies } from "../commons/mergeCompanies";
|
|
24
25
|
import type { CrmDataProvider } from "../types";
|
|
25
26
|
import { authProvider, USER_STORAGE_KEY } from "./authProvider";
|
|
26
27
|
import generateData from "./dataGenerator";
|
|
@@ -226,6 +227,9 @@ const dataProviderWithCustomMethod: CrmDataProvider = {
|
|
|
226
227
|
mergeContacts: async (sourceId: Identifier, targetId: Identifier) => {
|
|
227
228
|
return mergeContacts(sourceId, targetId, baseDataProvider);
|
|
228
229
|
},
|
|
230
|
+
mergeCompanies: async (sourceId: Identifier, targetId: Identifier) => {
|
|
231
|
+
return mergeCompanies(sourceId, targetId, baseDataProvider);
|
|
232
|
+
},
|
|
229
233
|
};
|
|
230
234
|
|
|
231
235
|
async function updateCompany(
|
|
@@ -265,6 +265,19 @@ const dataProviderWithCustomMethods = {
|
|
|
265
265
|
throw new Error("Failed to merge contacts");
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
return data;
|
|
269
|
+
},
|
|
270
|
+
async mergeCompanies(sourceId: Identifier, targetId: Identifier) {
|
|
271
|
+
const { data, error } = await supabase.functions.invoke("mergeCompanies", {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: { loserId: sourceId, winnerId: targetId },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (error) {
|
|
277
|
+
console.error("mergeCompanies.error", error);
|
|
278
|
+
throw new Error("Failed to merge companies");
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
return data;
|
|
269
282
|
},
|
|
270
283
|
} satisfies DataProvider;
|
|
@@ -74,8 +74,31 @@ interface DealsTable {
|
|
|
74
74
|
index: number | null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface CompaniesTable {
|
|
78
|
+
id: Generated<number>;
|
|
79
|
+
created_at: Date;
|
|
80
|
+
name: string;
|
|
81
|
+
sector: string | null;
|
|
82
|
+
size: number | null;
|
|
83
|
+
linkedin_url: string | null;
|
|
84
|
+
website: string | null;
|
|
85
|
+
phone_number: string | null;
|
|
86
|
+
address: string | null;
|
|
87
|
+
zipcode: string | null;
|
|
88
|
+
city: string | null;
|
|
89
|
+
stateAbbr: string | null;
|
|
90
|
+
sales_id: number | null;
|
|
91
|
+
context_links: unknown | null; // JSON array
|
|
92
|
+
country: string | null;
|
|
93
|
+
description: string | null;
|
|
94
|
+
revenue: string | null;
|
|
95
|
+
tax_identifier: string | null;
|
|
96
|
+
logo: unknown | null; // JSONB
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
interface Database {
|
|
78
100
|
contacts: ContactsTable;
|
|
101
|
+
companies: CompaniesTable;
|
|
79
102
|
tasks: TasksTable;
|
|
80
103
|
contactNotes: ContactNotesTable;
|
|
81
104
|
deals: DealsTable;
|
|
@@ -41,8 +41,12 @@ Deno.serve(async (req: Request) => {
|
|
|
41
41
|
|
|
42
42
|
let response: Response;
|
|
43
43
|
|
|
44
|
-
if (req.method === "GET"
|
|
45
|
-
|
|
44
|
+
if (req.method === "GET") {
|
|
45
|
+
if (companyId) {
|
|
46
|
+
response = await getCompany(apiKey, companyId);
|
|
47
|
+
} else {
|
|
48
|
+
response = await listCompanies(apiKey, req);
|
|
49
|
+
}
|
|
46
50
|
} else if (req.method === "POST") {
|
|
47
51
|
response = await createCompany(apiKey, req);
|
|
48
52
|
} else if (req.method === "PATCH" && companyId) {
|
|
@@ -79,6 +83,42 @@ Deno.serve(async (req: Request) => {
|
|
|
79
83
|
}
|
|
80
84
|
});
|
|
81
85
|
|
|
86
|
+
async function listCompanies(apiKey: any, req: Request) {
|
|
87
|
+
if (!hasScope(apiKey, "companies:read")) {
|
|
88
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const url = new URL(req.url);
|
|
92
|
+
const name = url.searchParams.get("name");
|
|
93
|
+
const website = url.searchParams.get("website");
|
|
94
|
+
const domain = url.searchParams.get("domain"); // Alias for website
|
|
95
|
+
|
|
96
|
+
let query = supabaseAdmin.from("companies").select("*");
|
|
97
|
+
|
|
98
|
+
if (name) {
|
|
99
|
+
query = query.ilike("name", `%${name}%`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (website || domain) {
|
|
103
|
+
// Exact match for website/domain
|
|
104
|
+
query = query.eq("website", website || domain);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!name && !website && !domain) {
|
|
108
|
+
query = query.limit(50);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { data, error } = await query;
|
|
112
|
+
|
|
113
|
+
if (error) {
|
|
114
|
+
return createErrorResponse(500, error.message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new Response(JSON.stringify({ data }), {
|
|
118
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
82
122
|
async function getCompany(apiKey: any, companyId: string) {
|
|
83
123
|
if (!hasScope(apiKey, "companies:read")) {
|
|
84
124
|
return createErrorResponse(403, "Insufficient permissions");
|
|
@@ -45,8 +45,12 @@ Deno.serve(async (req: Request) => {
|
|
|
45
45
|
|
|
46
46
|
let response: Response;
|
|
47
47
|
|
|
48
|
-
if (req.method === "GET"
|
|
49
|
-
|
|
48
|
+
if (req.method === "GET") {
|
|
49
|
+
if (contactId) {
|
|
50
|
+
response = await getContact(apiKey, contactId);
|
|
51
|
+
} else {
|
|
52
|
+
response = await listContacts(apiKey, req);
|
|
53
|
+
}
|
|
50
54
|
} else if (req.method === "POST") {
|
|
51
55
|
response = await createContact(apiKey, req);
|
|
52
56
|
} else if (req.method === "PATCH" && contactId) {
|
|
@@ -83,6 +87,38 @@ Deno.serve(async (req: Request) => {
|
|
|
83
87
|
}
|
|
84
88
|
});
|
|
85
89
|
|
|
90
|
+
async function listContacts(apiKey: any, req: Request) {
|
|
91
|
+
if (!hasScope(apiKey, "contacts:read")) {
|
|
92
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const url = new URL(req.url);
|
|
96
|
+
const email = url.searchParams.get("email");
|
|
97
|
+
|
|
98
|
+
let query = supabaseAdmin.from("contacts").select("*");
|
|
99
|
+
|
|
100
|
+
if (email) {
|
|
101
|
+
// Search within the email_jsonb array
|
|
102
|
+
// email_jsonb structure: [{ "email": "test@example.com", "type": "Work" }]
|
|
103
|
+
// We look for any object in the array that matches the email
|
|
104
|
+
query = query.contains("email_jsonb", JSON.stringify([{ email }]));
|
|
105
|
+
} else {
|
|
106
|
+
// If no specific search is provided, we default to a limit to prevent dumping the whole DB
|
|
107
|
+
// The apiKey owner can see all contacts, but let's limit it to 50 for safety if not searching
|
|
108
|
+
query = query.limit(50);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { data, error } = await query;
|
|
112
|
+
|
|
113
|
+
if (error) {
|
|
114
|
+
return createErrorResponse(500, error.message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new Response(JSON.stringify({ data }), {
|
|
118
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
async function getContact(apiKey: any, contactId: string) {
|
|
87
123
|
if (!hasScope(apiKey, "contacts:read")) {
|
|
88
124
|
return createErrorResponse(403, "Insufficient permissions");
|