mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
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.
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}]
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{{ contact.created_at ? format(new Date(contact.created_at), 'PPP') : 'N/A' }}
|
||||
</div>
|
||||
|
||||
<div class="w-30 pt-3">
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
:variant="contact.enabled ? 'destructive' : 'outline'"
|
||||
@click="showBlockConfirmation = true"
|
||||
@@ -57,6 +57,24 @@
|
||||
<ShieldCheckIcon v-else size="18" />
|
||||
{{ t(contact.enabled ? 'globals.messages.block' : 'globals.messages.unblock') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="userStore.can('contacts:export')"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="exportContact"
|
||||
>
|
||||
<DownloadIcon size="18" />
|
||||
{{ t('contact.exportData') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="userStore.can('contacts:delete')"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="showDeleteConfirmation = true"
|
||||
>
|
||||
<Trash2Icon size="18" />
|
||||
{{ t('contact.deleteContact') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,13 +110,30 @@
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="showDeleteConfirmation" @update:open="showDeleteConfirmation = $event">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader class="gap-y-3">
|
||||
<DialogTitle>{{ t('contact.deleteContact') }}</DialogTitle>
|
||||
<DialogDescription>{{ t('contact.deleteConfirm') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex justify-end space-x-2 pt-4">
|
||||
<Button variant="outline" @click="showDeleteConfirmation = false">
|
||||
{{ t('globals.messages.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" @click="confirmDelete">
|
||||
{{ t('globals.messages.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</ContactDetail>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
@@ -114,7 +149,14 @@ import {
|
||||
DialogDescription
|
||||
} from '@shared-ui/components/ui/dialog'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { ShieldOffIcon, ShieldCheckIcon, IdCardIcon, CalendarIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ShieldOffIcon,
|
||||
ShieldCheckIcon,
|
||||
IdCardIcon,
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
Trash2Icon
|
||||
} from 'lucide-vue-next'
|
||||
import ContactDetail from '@/layouts/contact/ContactDetail.vue'
|
||||
import api from '../../api'
|
||||
import ContactForm from '@/features/contact/ContactForm.vue'
|
||||
@@ -129,9 +171,11 @@ import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
const { t } = useI18n()
|
||||
const emitter = useEmitter()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formLoading = ref(false)
|
||||
const contact = ref(null)
|
||||
const showBlockConfirmation = ref(false)
|
||||
const showDeleteConfirmation = ref(false)
|
||||
const userStore = useUserStore()
|
||||
|
||||
const form = useForm({
|
||||
@@ -183,6 +227,36 @@ async function toggleBlock() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
showDeleteConfirmation.value = false
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.deleteContact(contact.value.id)
|
||||
emitToast(t('contact.deletedSuccessfully'))
|
||||
router.push({ name: 'contacts' })
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportContact() {
|
||||
try {
|
||||
const response = await api.exportContact(contact.value.id)
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `contact-${contact.value.id}-data.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"activityLog.agentOnline": "{actorEmail} ({actorId}) changed {targetEmail} ({targetId}) status to online",
|
||||
"activityLog.agentOnlineSelf": "{actorEmail} ({actorId}) is online",
|
||||
"activityLog.agentPasswordSet": "{actorEmail} ({actorId}) set password for {targetEmail} ({targetId})",
|
||||
"activityLog.contactDataExported": "{actorEmail} ({actorId}) exported data of contact {contactEmail} ({contactId})",
|
||||
"activityLog.contactDeleted": "{actorEmail} ({actorId}) deleted contact {contactEmail} ({contactId})",
|
||||
"activityLog.entryType": "Log entry type",
|
||||
"activityLog.entryType.agentAway": "Agent away",
|
||||
"activityLog.entryType.agentAwayReassigned": "Agent away reassigned",
|
||||
@@ -45,6 +47,8 @@
|
||||
"activityLog.entryType.agentOnline": "Agent online",
|
||||
"activityLog.entryType.agentPasswordSet": "Agent password set",
|
||||
"activityLog.entryType.agentRolePermissionsChanged": "Agent role permissions changed",
|
||||
"activityLog.entryType.contactDataExported": "Contact data exported",
|
||||
"activityLog.entryType.contactDeleted": "Contact deleted",
|
||||
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) added permission(s) {permissions} to role {roleName} ({roleId})",
|
||||
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) removed permission(s) {removed} and added permission(s) {added} to role {roleName} ({roleId})",
|
||||
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) removed permission(s) {permissions} from role {roleName} ({roleId})",
|
||||
@@ -305,6 +309,8 @@
|
||||
"admin.role.contactNotes.read": "View contact notes",
|
||||
"admin.role.contactNotes.write": "Add contact notes",
|
||||
"admin.role.contacts.block": "Block contacts",
|
||||
"admin.role.contacts.delete": "Delete contacts",
|
||||
"admin.role.contacts.export": "Export contact data",
|
||||
"admin.role.contacts.read": "View contact details",
|
||||
"admin.role.contacts.readAll": "View all contacts",
|
||||
"admin.role.contacts.write": "Edit contact details",
|
||||
@@ -459,8 +465,12 @@
|
||||
"contact.blockConfirm": "Are you sure you want to block this contact? They won't be able to chat, and incoming emails from their address will be rejected. This also blocks incoming emails from any other contacts that use the same email.",
|
||||
"contact.blockContact": "Block contact",
|
||||
"contact.blockedSuccessfully": "Contact blocked successfully",
|
||||
"contact.deleteConfirm": "Are you sure you want to delete this contact? All their conversations, messages, notes, and attachments will be permanently deleted. This cannot be undone.",
|
||||
"contact.deleteContact": "Delete contact",
|
||||
"contact.deleteNote": "Delete note",
|
||||
"contact.deletedSuccessfully": "Contact deleted successfully",
|
||||
"contact.editContact": "Edit contact",
|
||||
"contact.exportData": "Export data",
|
||||
"contact.identityNotVerified": "Identity not verified",
|
||||
"contact.identityVerified": "Identity verified",
|
||||
"contact.newNote": "New note",
|
||||
|
||||
@@ -261,6 +261,40 @@ func (al *Manager) RolePermissionsChanged(actorID int, actorEmail, ip string, ro
|
||||
)
|
||||
}
|
||||
|
||||
// ContactDeleted records permanent deletion of a contact and their data.
|
||||
func (al *Manager) ContactDeleted(actorID int, actorEmail, ip string, contactID int, contactEmail string) error {
|
||||
description := al.i18n.Ts("activityLog.contactDeleted",
|
||||
"actorEmail", actorEmail,
|
||||
"actorId", fmt.Sprintf("#%d", actorID),
|
||||
"contactEmail", contactEmail,
|
||||
"contactId", fmt.Sprintf("#%d", contactID))
|
||||
return al.create(
|
||||
models.ContactDeleted,
|
||||
description,
|
||||
actorID,
|
||||
umodels.UserModel,
|
||||
contactID,
|
||||
ip,
|
||||
)
|
||||
}
|
||||
|
||||
// ContactDataExported records an export of a contact's stored data.
|
||||
func (al *Manager) ContactDataExported(actorID int, actorEmail, ip string, contactID int, contactEmail string) error {
|
||||
description := al.i18n.Ts("activityLog.contactDataExported",
|
||||
"actorEmail", actorEmail,
|
||||
"actorId", fmt.Sprintf("#%d", actorID),
|
||||
"contactEmail", contactEmail,
|
||||
"contactId", fmt.Sprintf("#%d", contactID))
|
||||
return al.create(
|
||||
models.ContactDataExported,
|
||||
description,
|
||||
actorID,
|
||||
umodels.UserModel,
|
||||
contactID,
|
||||
ip,
|
||||
)
|
||||
}
|
||||
|
||||
// create creates a new activity log in DB.
|
||||
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
|
||||
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
AgentOnline = "agent_online"
|
||||
AgentPasswordSet = "agent_password_set"
|
||||
AgentRolePermissionsChanged = "agent_role_permissions_changed"
|
||||
ContactDeleted = "contact_deleted"
|
||||
ContactDataExported = "contact_data_exported"
|
||||
)
|
||||
|
||||
type ActivityLog struct {
|
||||
|
||||
@@ -81,6 +81,8 @@ const (
|
||||
PermContactsRead = "contacts:read"
|
||||
PermContactsWrite = "contacts:write"
|
||||
PermContactsBlock = "contacts:block"
|
||||
PermContactsDelete = "contacts:delete"
|
||||
PermContactsExport = "contacts:export"
|
||||
|
||||
// Contact Notes
|
||||
PermContactNotesRead = "contact_notes:read"
|
||||
@@ -133,6 +135,8 @@ var validPermissions = map[string]struct{}{
|
||||
PermContactsRead: {},
|
||||
PermContactsWrite: {},
|
||||
PermContactsBlock: {},
|
||||
PermContactsDelete: {},
|
||||
PermContactsExport: {},
|
||||
PermContactNotesRead: {},
|
||||
PermContactNotesWrite: {},
|
||||
PermContactNotesDelete: {},
|
||||
|
||||
@@ -46,9 +46,11 @@ WHERE model_type = $1
|
||||
-- name: get-unlinked-message-media
|
||||
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
|
||||
FROM media
|
||||
WHERE model_type = 'messages'
|
||||
AND (model_id IS NULL OR model_id = 0)
|
||||
AND created_at < NOW() - INTERVAL '7 days';
|
||||
WHERE model_type = 'messages'
|
||||
AND (
|
||||
((model_id IS NULL OR model_id = 0) AND created_at < NOW() - INTERVAL '7 days')
|
||||
OR (model_id > 0 AND NOT EXISTS (SELECT 1 FROM conversation_messages cm WHERE cm.id = media.model_id))
|
||||
);
|
||||
|
||||
-- name: content-id-exists
|
||||
SELECT m.uuid
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
func V2_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
for _, permission := range []string{"contacts:delete", "contacts:export"} {
|
||||
if _, err := db.Exec(`
|
||||
UPDATE roles
|
||||
SET permissions = array_append(permissions, $1)
|
||||
WHERE name = 'Admin' AND NOT ($1 = ANY(permissions));
|
||||
`, permission); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TYPE activity_log_type ADD VALUE IF NOT EXISTS 'contact_deleted';`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TYPE activity_log_type ADD VALUE IF NOT EXISTS 'contact_data_exported';`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,6 +98,29 @@ func (u *Manager) UpdateContact(id int, user models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteContact permanently deletes a contact or visitor; conversations, messages, and notes are removed by DB cascades.
|
||||
func (u *Manager) DeleteContact(id int) error {
|
||||
res, err := u.q.DeleteContact.Exec(id)
|
||||
if err != nil {
|
||||
u.lo.Error("error deleting contact", "contact_id", id, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportContactData returns all stored personal data for a contact as JSON.
|
||||
func (u *Manager) ExportContactData(id int) ([]byte, error) {
|
||||
var data []byte
|
||||
if err := u.q.ExportContactData.Get(&data, id); err != nil {
|
||||
u.lo.Error("error exporting contact data", "contact_id", id, "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetAllContacts returns a list of all contacts.
|
||||
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON, location string) ([]models.UserCompact, error) {
|
||||
if pageSize > maxListPageSize {
|
||||
|
||||
@@ -429,3 +429,50 @@ SELECT
|
||||
|
||||
-- name: get-user-ids-by-role
|
||||
SELECT user_id FROM user_roles WHERE role_id = $1;
|
||||
|
||||
-- name: delete-contact
|
||||
DELETE FROM users
|
||||
WHERE id = $1 AND type IN ('contact', 'visitor');
|
||||
|
||||
-- name: export-contact-data
|
||||
SELECT jsonb_build_object(
|
||||
'contact', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'created_at', created_at,
|
||||
'first_name', first_name,
|
||||
'last_name', last_name,
|
||||
'email', email,
|
||||
'phone_number_country_code', phone_number_country_code,
|
||||
'phone_number', phone_number,
|
||||
'country', country,
|
||||
'avatar_url', avatar_url,
|
||||
'external_user_id', external_user_id,
|
||||
'custom_attributes', custom_attributes
|
||||
)
|
||||
FROM users
|
||||
WHERE id = $1 AND type IN ('contact', 'visitor')
|
||||
),
|
||||
'conversations', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'reference_number', c.reference_number,
|
||||
'created_at', c.created_at,
|
||||
'subject', c.subject,
|
||||
'status', cs.name,
|
||||
'custom_attributes', c.custom_attributes,
|
||||
'messages', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'created_at', m.created_at,
|
||||
'type', m.type,
|
||||
'sender_type', m.sender_type,
|
||||
'content', m.text_content
|
||||
) ORDER BY m.created_at), '[]'::jsonb)
|
||||
FROM conversation_messages m
|
||||
WHERE m.conversation_id = c.id AND m.private = false AND m.type IN ('incoming', 'outgoing')
|
||||
)
|
||||
) ORDER BY c.created_at), '[]'::jsonb)
|
||||
FROM conversations c
|
||||
LEFT JOIN conversation_statuses cs ON cs.id = c.status_id
|
||||
WHERE c.contact_id = $1
|
||||
)
|
||||
);
|
||||
|
||||
@@ -117,6 +117,8 @@ type queries struct {
|
||||
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
|
||||
|
||||
MergeVisitorToContact *sqlx.Stmt `query:"merge-visitor-to-contact"`
|
||||
DeleteContact *sqlx.Stmt `query:"delete-contact"`
|
||||
ExportContactData *sqlx.Stmt `query:"export-contact-data"`
|
||||
}
|
||||
|
||||
// New creates and returns a new instance of the Manager.
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_statu
|
||||
DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" AS ENUM ('pending', 'breached', 'met');
|
||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response');
|
||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed');
|
||||
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed', 'contact_deleted', 'contact_data_exported');
|
||||
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
|
||||
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach');
|
||||
DROP TYPE IF EXISTS "conversation_status_category" CASCADE; CREATE TYPE "conversation_status_category" AS ENUM ('open', 'waiting', 'resolved');
|
||||
@@ -771,7 +771,7 @@ VALUES
|
||||
(
|
||||
'Admin',
|
||||
'Role for users who have complete access to everything.',
|
||||
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
||||
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contacts:delete,contacts:export,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
|
||||
);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user