Skip to content

Commit 05ffcfd

Browse files
authored
remove contacts button (#1982)
* Create 20250116152613_add_function_delete_contacts.sql * add remove contacts feature * Delete 20250116152613_add_function_delete_contacts.sql * use frontend only instead * function name typo * fix contacts are not getting deleted from persons * Update contacts.ts * add margin to the delete button * rename delete to remove * create component RemoveContactButton.vue * use RPC * rename migration file * refactor rpc functions - delete from `pointsofcontact` - remove unused frontned function `getContacts` - refactor rpc function `delete_user_data` & `delete_contacts` * remove contacts fixes * Update RemoveContactButton.vue * use realtime instead of reloadContact() * Enable delete policy for `pointsofcontact` * i18n * Update 20250116214647_add_delete_contacts_function.sql
1 parent 28e8bae commit 05ffcfd

File tree

8 files changed

+201
-30
lines changed

8 files changed

+201
-30
lines changed

backend/src/controllers/contacts.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { User } from '@supabase/supabase-js';
22
import { NextFunction, Request, Response } from 'express';
33
import { Contacts } from '../db/interfaces/Contacts';
44
import { Contact } from '../db/types';
5+
import Billing from '../utils/billing-plugin';
56
import {
67
exportContactsToCSV,
78
getLocalizedCsvSeparator
89
} from '../utils/helpers/csv';
9-
import Billing from '../utils/billing-plugin';
1010

1111
function validateRequest(req: Request, res: Response) {
1212
const userId = (res.locals.user as User).id;

backend/src/routes/contacts.routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function initializeContactsRoutes(
1313
const { exportContactsCSV } = initializeContactsController(contacts);
1414

1515
router.post(
16-
'/export/csv',
16+
'/contacts/export/csv',
1717
initializeAuthMiddleware(authResolver),
1818
exportContactsCSV
1919
);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<template>
2+
<Button
3+
id="remove-contact"
4+
v-tooltip.top="t('remove_contacts', contactsToDeleteLength)"
5+
icon="pi pi-times"
6+
:label="$screenStore.size.md ? t('remove') : undefined"
7+
severity="danger"
8+
:disabled="isRemoveDisabled || isRemovingContacts"
9+
:loading="isRemovingContacts"
10+
@click="showWarning()"
11+
/>
12+
<!-- Warning modal Section -->
13+
<Dialog
14+
v-model:visible="showRemoveContactModal"
15+
modal
16+
:header="t('remove_contacts', contactsToDeleteLength)"
17+
:style="{ width: '25rem' }"
18+
>
19+
<span class="p-text-secondary block mb-5">
20+
{{ t('remove_contacts_confirmation', contactsToDeleteLength) }}
21+
</span>
22+
<div class="flex flex-row-reverse justify-content-start gap-2">
23+
<Button
24+
id="remove-contact-confirm"
25+
type="button"
26+
:label="t('remove')"
27+
severity="danger"
28+
:loading="isRemovingContacts"
29+
@click="removeContacts()"
30+
>
31+
</Button>
32+
<Button
33+
type="button"
34+
:label="$t('common.cancel')"
35+
severity="secondary"
36+
@click="closeWarning"
37+
>
38+
</Button>
39+
</div>
40+
</Dialog>
41+
</template>
42+
43+
<script setup lang="ts">
44+
const $toast = useToast();
45+
const { t } = useI18n({
46+
useScope: 'local',
47+
});
48+
const $screenStore = useScreenStore();
49+
const props = defineProps<{
50+
contactsToDelete?: string[];
51+
contactsToDeleteLength: number;
52+
isRemoveDisabled: boolean;
53+
deselectContacts: () => void;
54+
}>();
55+
56+
const { deselectContacts } = props;
57+
58+
const contactsToDelete = computed(() => props.contactsToDelete);
59+
const contactsToDeleteLength = computed(() => props.contactsToDeleteLength);
60+
const isRemoveDisabled = computed(() => props.isRemoveDisabled);
61+
62+
const showRemoveContactModal = ref(false);
63+
const isRemovingContacts = ref(false);
64+
65+
function showWarning() {
66+
showRemoveContactModal.value = true;
67+
}
68+
function closeWarning() {
69+
showRemoveContactModal.value = false;
70+
}
71+
72+
async function removeContacts() {
73+
isRemovingContacts.value = true;
74+
try {
75+
await removeContactsFromDatabase(contactsToDelete.value);
76+
$toast.add({
77+
severity: 'success',
78+
summary: t('contacts_removed', contactsToDeleteLength.value),
79+
detail: t('contacts_removed_success', contactsToDeleteLength.value),
80+
life: 3000,
81+
});
82+
closeWarning();
83+
deselectContacts();
84+
isRemovingContacts.value = false;
85+
} catch (err) {
86+
isRemovingContacts.value = false;
87+
throw err;
88+
}
89+
}
90+
</script>
91+
92+
<i18n lang="json">
93+
{
94+
"en": {
95+
"remove": "Remove",
96+
"remove_contacts_confirmation": "Removing this contact is permanent. You will lose all its mining data.| Removing these {n} contacts is permanent. You will lose all their mining data.",
97+
"remove_contacts": "Remove contact|Remove {n} contacts",
98+
"contacts_removed": "Contact removed|Contacts removed",
99+
"contacts_removed_success": "Contact has been removed successfully.| {n} contacts have been removed successfully."
100+
},
101+
"fr": {
102+
"remove": "Supprimer",
103+
"remove_contacts_confirmation": "La suppression de ce contact est permanent. Vous perdrez toutes ses données d'extraction.| La suppression de ces {n} contacts est permanent. Vous perdrez toutes leurs données d'extraction.",
104+
"remove_contacts": "Supprimer le contact|Supprimer {n} contacts",
105+
"contacts_removed": "Contact supprimé|Contacts supprimés",
106+
"contacts_removed_success": "Le contact a été supprimé avec succès.| {n} contacts ont été supprimés avec succès."
107+
}
108+
}
109+
</i18n>

frontend/src/components/Mining/Table/MiningTable.vue

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
<!-- This is a workaround as tooltip doesn't work when component is `disabled`-->
6262
<div
6363
v-tooltip.top="isExportDisabled && t('select_at_least_one_contact')"
64+
class="flex items-center gap-1"
6465
>
6566
<Button
6667
id="export-csv"
@@ -69,6 +70,12 @@
6970
:disabled="isExportDisabled"
7071
@click="exportTable()"
7172
/>
73+
<RemoveContactButton
74+
:contacts-to-delete="contactsToTreat"
75+
:contacts-to-delete-length="implicitlySelectedContactsLength"
76+
:is-remove-disabled="isExportDisabled"
77+
:deselect-contacts="deselectContacts"
78+
/>
7279
</div>
7380
<div>
7481
<EnrichButton
@@ -707,14 +714,15 @@ import { saveCSVFile } from '~/utils/csv';
707714
import { getImageViaProxy } from '~/utils/images';
708715
709716
const TableSkeleton = defineAsyncComponent(() => import('./TableSkeleton.vue'));
710-
711717
const SocialLinks = defineAsyncComponent(
712718
() => import('../../icons/SocialLink.vue'),
713719
);
714-
715720
const EnrichButton = defineAsyncComponent(
716721
() => import('../Buttons/EnrichButton.vue'),
717722
);
723+
const RemoveContactButton = defineAsyncComponent(
724+
() => import('../Buttons/RemoveContactButton.vue'),
725+
);
718726
const ContactInformationSidebar = defineAsyncComponent(
719727
() => import('../ContactInformationSidebar.vue'),
720728
);
@@ -751,7 +759,6 @@ function openContactInformation(data: Contact) {
751759
}
752760
753761
/* *** Filters *** */
754-
755762
const filtersStore = useFiltersStore();
756763
757764
const filteredContacts = ref<Contact[]>([]);
@@ -801,14 +808,15 @@ const selectedContacts = ref<Contact[]>([]);
801808
const selectedContactsLength = computed(() => selectedContacts.value.length);
802809
const selectAll = ref(false);
803810
811+
function deselectContacts() {
812+
selectAll.value = false;
813+
selectedContacts.value = [];
814+
}
804815
const onSelectAllChange = (event: DataTableSelectAllChangeEvent) => {
805816
if (event.checked) {
806817
selectAll.value = true;
807818
selectedContacts.value = filteredContacts.value; // all data according to your needs
808-
} else {
809-
selectAll.value = false;
810-
selectedContacts.value = [];
811-
}
819+
} else deselectContacts();
812820
};
813821
const onRowSelect = () => {
814822
// This control can be completely managed by you.
@@ -848,16 +856,16 @@ const implicitSelectAll = computed(
848856
() => implicitlySelectedContactsLength.value === contactsLength.value,
849857
);
850858
851-
const contactsToExport = computed<string[] | undefined>(() =>
859+
const contactsToTreat = computed<string[] | undefined>(() =>
852860
implicitSelectAll.value
853861
? undefined
854862
: implicitlySelectedContacts.value.map((item: Contact) => item.email),
855863
);
856864
857865
watch(
858-
contactsToExport,
866+
contactsToTreat,
859867
() => {
860-
$contactsStore.selectedEmails = contactsToExport.value;
868+
$contactsStore.selectedEmails = contactsToTreat.value;
861869
},
862870
{ deep: true, immediate: true },
863871
);
@@ -912,12 +920,12 @@ const openCreditModel = (
912920
};
913921
914922
async function exportTable(partialExport = false) {
915-
await $api('/export/csv', {
923+
await $api('/contacts/export/csv', {
916924
method: 'POST',
917925
body: {
918926
partialExport,
919-
emails: contactsToExport.value,
920-
exportAllContacts: contactsToExport.value === undefined,
927+
emails: contactsToTreat.value,
928+
exportAllContacts: contactsToTreat.value === undefined,
921929
},
922930
onResponse({ response }) {
923931
if (response.status === 402 || response.status === 266) {

frontend/src/pages/account/settings.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
<LegalInformation />
122122
</Panel>
123123

124-
<!-- Warning model Section -->
124+
<!-- Warning modal Section -->
125125
<Dialog
126126
v-model:visible="showDeleteModal"
127127
modal

frontend/src/stores/contacts.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ export const useContactsStore = defineStore('contacts-store', () => {
137137
}
138138
}
139139

140+
function removeOldContact(email: string) {
141+
contactsList.value = contactsList.value?.filter(
142+
(contact) => contact.email !== email,
143+
);
144+
}
145+
140146
/**
141147
* Subscribes to real-time updates for contacts.
142148
*/
@@ -152,6 +158,10 @@ export const useContactsStore = defineStore('contacts-store', () => {
152158
filter: `user_id=eq.${$user.value?.id}`,
153159
},
154160
async (payload: RealtimePostgresChangesPayload<Contact>) => {
161+
if (payload.eventType === 'DELETE' && payload.old.email) {
162+
removeOldContact(payload.old.email);
163+
return;
164+
}
155165
const newContact = payload.new as Contact;
156166
await processNewContact(newContact);
157167
},

frontend/src/utils/contacts.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { User } from '@supabase/supabase-js';
12
import type { Contact } from '~/types/contact';
23
import type { Organization } from '~/types/organization';
34

@@ -19,20 +20,6 @@ export function convertDates(data: Contact[]) {
1920
});
2021
}
2122

22-
export async function getContacts(userId: string) {
23-
const $supabaseClient = useSupabaseClient();
24-
const { data, error } = await $supabaseClient
25-
// @ts-expect-error: Issue with nuxt/supabase
26-
.schema('private')
27-
.rpc('get_contacts_table', { user_id: userId });
28-
29-
if (error) {
30-
throw error;
31-
}
32-
33-
return data ? convertDates(data) : [];
34-
}
35-
3623
/**
3724
* Retrieves an organization by its name from the `organizations` table.
3825
*
@@ -193,3 +180,19 @@ export function isValidURL(url: string) {
193180
return false;
194181
}
195182
}
183+
184+
export async function removeContactsFromDatabase(
185+
emails?: string[],
186+
): Promise<void> {
187+
const $user = useSupabaseUser() as Ref<User>;
188+
const $supabaseClient = useSupabaseClient();
189+
const { error } = await $supabaseClient
190+
// @ts-expect-error: Issue with nuxt/supabase
191+
.schema('private')
192+
.rpc('delete_contacts', {
193+
user_id: $user.value.id,
194+
emails: emails ?? null,
195+
deleteallcontacts: emails === undefined,
196+
});
197+
if (error) throw error;
198+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
create policy "Enable delete for users based on user_id" on "private"."persons" as permissive for delete to authenticated using ((( SELECT auth.uid() AS uid) = user_id));
2+
3+
create policy "Enable delete for users based on user_id" on "private"."pointsofcontact" as permissive for delete to public using ((( SELECT auth.uid() AS uid) = user_id));
4+
5+
CREATE FUNCTION private.delete_contacts(user_id uuid, emails text[], deleteallcontacts boolean) RETURNS void
6+
LANGUAGE plpgsql
7+
SET search_path = ''
8+
AS $$
9+
DECLARE
10+
owner_id uuid;
11+
BEGIN
12+
owner_id = delete_contacts.user_id;
13+
IF deleteallcontacts THEN
14+
DELETE FROM private.persons p WHERE p.user_id = owner_id;
15+
DELETE FROM private.refinedpersons rp WHERE rp.user_id = owner_id;
16+
DELETE FROM private.pointsofcontact poc WHERE poc.user_id = owner_id;
17+
ELSE
18+
DELETE FROM private.persons p WHERE p.user_id = owner_id AND email = ANY(emails);
19+
DELETE FROM private.refinedpersons rp WHERE rp.user_id = owner_id AND email = ANY(emails);
20+
DELETE FROM private.pointsofcontact poc WHERE poc.user_id = owner_id AND person_email = ANY(emails);
21+
END IF;
22+
END;
23+
$$;
24+
25+
26+
CREATE OR REPLACE FUNCTION private.delete_user_data(user_id uuid) RETURNS void
27+
LANGUAGE plpgsql
28+
SET search_path = ''
29+
AS $$
30+
DECLARE
31+
owner_id uuid;
32+
BEGIN
33+
owner_id = delete_user_data.user_id;
34+
DELETE FROM private.messages msg WHERE msg.user_id = owner_id;
35+
DELETE FROM private.tags t WHERE t.user_id = owner_id;
36+
DELETE FROM private.mining_sources ms WHERE ms.user_id = owner_id;
37+
DELETE FROM private.engagement eg WHERE eg.user_id = owner_id;
38+
PERFORM private.delete_contacts(owner_id, NULL, TRUE);
39+
END;
40+
$$;
41+

0 commit comments

Comments
 (0)