From bfbcd0a88542c952342effd0aba93b95fdca2f7c Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 14 Jul 2026 01:30:59 +0530 Subject: [PATCH] add contact deletion and data export for GDPR compliance First part of GDPR support (#244): the right to erasure and the right to access. - DELETE /api/v1/contacts/{id} permanently deletes a contact or visitor. Conversations, messages, notes, and participants go with it via DB cascades. The avatar file is removed too. - GET /api/v1/contacts/{id}/export downloads a JSON file with everything stored about the contact: profile, custom attributes, and all conversations with their messages. Private notes stay internal. - Both actions are gated by new permissions (contacts:delete, contacts:export), granted to Admin in migration v2.6.0, and recorded in the activity log with actor and IP. - Contact page gets Export data and Delete contact buttons, with a confirm dialog for delete. - The unlinked media cleaner now also removes attachment files whose message no longer exists. Before this, deleting a conversation left its attachments on disk forever. --- cmd/contacts.go | 69 ++++++++++++++++ cmd/handlers.go | 2 + cmd/upgrade.go | 1 + frontend/apps/main/src/api/index.js | 4 + .../src/composables/useActivityLogFilters.js | 6 ++ .../apps/main/src/constants/permissions.js | 2 + .../src/features/admin/roles/RoleForm.vue | 2 + .../src/views/contact/ContactDetailView.vue | 80 ++++++++++++++++++- i18n/en-US.json | 10 +++ internal/activity_log/activity_log.go | 34 ++++++++ internal/activity_log/models/models.go | 2 + internal/authz/models/models.go | 4 + internal/media/queries.sql | 8 +- internal/migrations/v2.6.0.go | 27 +++++++ internal/user/contact.go | 23 ++++++ internal/user/queries.sql | 47 +++++++++++ internal/user/user.go | 2 + schema.sql | 4 +- 18 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 internal/migrations/v2.6.0.go diff --git a/cmd/contacts.go b/cmd/contacts.go index c53ecc4b..89e61fe0 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "path/filepath" "strconv" "strings" @@ -9,6 +10,7 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/abhinavxd/libredesk/internal/user/models" + realip "github.com/ferluci/fast-realip" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" @@ -178,6 +180,73 @@ func handleUpdateContact(r *fastglue.Request) error { return r.SendEnvelope(contact) } +// handleDeleteContact permanently deletes a contact along with their conversations, messages, and notes. +func handleDeleteContact(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + contact, err := app.user.GetContactOrVisitor(id, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + app.lo.Info("deleting contact", "contact_id", id, "actor_id", auser.ID) + + if err := app.user.DeleteContact(id); err != nil { + return sendErrorEnvelope(r, err) + } + + if contact.AvatarURL.Valid { + fileName := filepath.Base(contact.AvatarURL.String) + app.media.Delete(fileName) + } + + if err := app.activityLog.ContactDeleted(auser.ID, auser.Email, realip.FromRequest(r.RequestCtx), id, contact.Email.String); err != nil { + app.lo.Error("error creating contact deleted activity log", "error", err) + } + + return r.SendEnvelope(true) +} + +// handleExportContact sends all stored data for a contact as a JSON file download. +func handleExportContact(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + if id <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + + contact, err := app.user.GetContactOrVisitor(id, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + data, err := app.user.ExportContactData(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + + if err := app.activityLog.ContactDataExported(auser.ID, auser.Email, realip.FromRequest(r.RequestCtx), id, contact.Email.String); err != nil { + app.lo.Error("error creating contact data exported activity log", "error", err) + } + + filename := fmt.Sprintf("contact-%d-data.json", id) + r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") + r.RequestCtx.SetContentType("application/json; charset=utf-8") + r.RequestCtx.SetBody(data) + return nil +} + // handleGetContactNotes returns all notes for a contact. func handleGetContactNotes(r *fastglue.Request) error { var ( diff --git a/cmd/handlers.go b/cmd/handlers.go index 266fe091..12a809f0 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -148,6 +148,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read")) g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write")) g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block")) + g.DELETE("/api/v1/contacts/{id}", perm(handleDeleteContact, "contacts:delete")) + g.GET("/api/v1/contacts/{id}/export", perm(handleExportContact, "contacts:export")) // Contact notes. g.GET("/api/v1/contacts/{id}/notes", perm(handleGetContactNotes, "contact_notes:read")) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 1185eaee..b013bdaa 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -45,6 +45,7 @@ var migList = []migFunc{ {"v2.3.0", migrations.V2_3_0}, {"v2.4.0", migrations.V2_4_0}, {"v2.5.0", migrations.V2_5_0}, + {"v2.6.0", migrations.V2_6_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 8d6e6616..a982c97d 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -212,6 +212,8 @@ const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data 'Content-Type': 'application/json' } }) +const deleteContact = (id) => http.delete(`/api/v1/contacts/${id}`) +const exportContact = (id) => http.get(`/api/v1/contacts/${id}/export`, { responseType: 'blob' }) const getTeam = (id) => http.get(`/api/v1/teams/${id}`) const getTeams = () => http.get('/api/v1/teams') const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, { @@ -666,6 +668,8 @@ export default { getContact, updateContact, blockContact, + deleteContact, + exportContact, getCustomAttributes, createCustomAttribute, updateCustomAttribute, diff --git a/frontend/apps/main/src/composables/useActivityLogFilters.js b/frontend/apps/main/src/composables/useActivityLogFilters.js index 822ac330..cd5973d7 100644 --- a/frontend/apps/main/src/composables/useActivityLogFilters.js +++ b/frontend/apps/main/src/composables/useActivityLogFilters.js @@ -38,6 +38,12 @@ export function useActivityLogFilters () { }, { label: t('activityLog.entryType.agentRolePermissionsChanged'), value: 'agent_role_permissions_changed' + }, { + label: t('activityLog.entryType.contactDeleted'), + value: 'contact_deleted' + }, { + label: t('activityLog.entryType.contactDataExported'), + value: 'contact_data_exported' }] }, })) diff --git a/frontend/apps/main/src/constants/permissions.js b/frontend/apps/main/src/constants/permissions.js index 7ecaad27..bc0e665b 100644 --- a/frontend/apps/main/src/constants/permissions.js +++ b/frontend/apps/main/src/constants/permissions.js @@ -37,6 +37,8 @@ export const permissions = { CONTACTS_READ: 'contacts:read', CONTACTS_WRITE: 'contacts:write', CONTACTS_BLOCK: 'contacts:block', + CONTACTS_DELETE: 'contacts:delete', + CONTACTS_EXPORT: 'contacts:export', CONTACT_NOTES_READ: 'contact_notes:read', CONTACT_NOTES_WRITE: 'contact_notes:write', CONTACT_NOTES_DELETE: 'contact_notes:delete', diff --git a/frontend/apps/main/src/features/admin/roles/RoleForm.vue b/frontend/apps/main/src/features/admin/roles/RoleForm.vue index 05994532..0820a08d 100644 --- a/frontend/apps/main/src/features/admin/roles/RoleForm.vue +++ b/frontend/apps/main/src/features/admin/roles/RoleForm.vue @@ -189,6 +189,8 @@ const permissions = ref([ { name: perms.CONTACTS_READ, label: t('admin.role.contacts.read') }, { name: perms.CONTACTS_WRITE, label: t('admin.role.contacts.write') }, { name: perms.CONTACTS_BLOCK, label: t('admin.role.contacts.block') }, + { name: perms.CONTACTS_DELETE, label: t('admin.role.contacts.delete') }, + { name: perms.CONTACTS_EXPORT, label: t('admin.role.contacts.export') }, { name: perms.CONTACT_NOTES_READ, label: t('admin.role.contactNotes.read') }, { name: perms.CONTACT_NOTES_WRITE, label: t('admin.role.contactNotes.write') }, { name: perms.CONTACT_NOTES_DELETE, label: t('admin.role.contactNotes.delete') } diff --git a/frontend/apps/main/src/views/contact/ContactDetailView.vue b/frontend/apps/main/src/views/contact/ContactDetailView.vue index 7ba5c058..3d117a02 100644 --- a/frontend/apps/main/src/views/contact/ContactDetailView.vue +++ b/frontend/apps/main/src/views/contact/ContactDetailView.vue @@ -47,7 +47,7 @@ {{ contact.created_at ? format(new Date(contact.created_at), 'PPP') : 'N/A' }} -
+
+ +
@@ -92,13 +110,30 @@ + + + + + {{ t('contact.deleteContact') }} + {{ t('contact.deleteConfirm') }} + +
+ + +
+
+