Skip to content

Commit a0c77bc

Browse files
committed
feat: contact notes
refactor: split code in internal/users/users.go into following files internal/users/notes.go internal/users/agent.go internal/users/contact.go
1 parent 8bc5115 commit a0c77bc

File tree

16 files changed

+609
-152
lines changed

16 files changed

+609
-152
lines changed

cmd/contacts.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"strconv"
66
"strings"
77

8+
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
89
"github.com/abhinavxd/libredesk/internal/envelope"
910
"github.com/abhinavxd/libredesk/internal/stringutil"
1011
"github.com/abhinavxd/libredesk/internal/user/models"
@@ -155,3 +156,78 @@ func handleUpdateContact(r *fastglue.Request) error {
155156
}
156157
return r.SendEnvelope(true)
157158
}
159+
160+
// handleGetContactNotes returns all notes for a contact.
161+
func handleGetContactNotes(r *fastglue.Request) error {
162+
var (
163+
app = r.Context.(*App)
164+
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
165+
)
166+
if contactID <= 0 {
167+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
168+
}
169+
notes, err := app.user.GetNotes(contactID)
170+
if err != nil {
171+
return sendErrorEnvelope(r, err)
172+
}
173+
return r.SendEnvelope(notes)
174+
}
175+
176+
// handleCreateContactNote creates a note for a contact.
177+
func handleCreateContactNote(r *fastglue.Request) error {
178+
var (
179+
app = r.Context.(*App)
180+
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
181+
auser = r.RequestCtx.UserValue("user").(amodels.User)
182+
note = string(r.RequestCtx.PostArgs().Peek("note"))
183+
)
184+
if len(note) == 0 {
185+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
186+
}
187+
if err := app.user.CreateNote(contactID, auser.ID, note); err != nil {
188+
return sendErrorEnvelope(r, err)
189+
}
190+
return r.SendEnvelope(true)
191+
}
192+
193+
// handleUpdateContactNote updates a note for a contact.
194+
func handleUpdateContactNote(r *fastglue.Request) error {
195+
var (
196+
app = r.Context.(*App)
197+
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
198+
noteID, _ = strconv.Atoi(r.RequestCtx.UserValue("note_id").(string))
199+
note = string(r.RequestCtx.PostArgs().Peek("note"))
200+
)
201+
if contactID <= 0 {
202+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
203+
}
204+
if noteID <= 0 {
205+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`note_id`"), nil, envelope.InputError)
206+
}
207+
if len(note) == 0 {
208+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
209+
}
210+
if err := app.user.UpdateNote(noteID, note, contactID); err != nil {
211+
return sendErrorEnvelope(r, err)
212+
}
213+
return r.SendEnvelope(true)
214+
}
215+
216+
// handleDeleteContactNote deletes a note for a contact.
217+
func handleDeleteContactNote(r *fastglue.Request) error {
218+
var (
219+
app = r.Context.(*App)
220+
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
221+
noteID, _ = strconv.Atoi(r.RequestCtx.UserValue("note_id").(string))
222+
)
223+
if contactID <= 0 {
224+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
225+
}
226+
if noteID <= 0 {
227+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`note_id`"), nil, envelope.InputError)
228+
}
229+
if err := app.user.DeleteNote(noteID, contactID); err != nil {
230+
return sendErrorEnvelope(r, err)
231+
}
232+
return r.SendEnvelope(true)
233+
}

cmd/handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
115115
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:manage"))
116116
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:manage"))
117117
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:manage"))
118+
g.GET("/api/v1/contacts/{id}/notes", perm(handleGetContactNotes, "contacts:manage"))
119+
g.POST("/api/v1/contacts/{id}/notes", perm(handleCreateContactNote, "contacts:manage"))
120+
g.PUT("/api/v1/contacts/{id}/notes/{note_id}", perm(handleUpdateContactNote, "contacts:manage"))
121+
g.DELETE("/api/v1/contacts/{id}/notes/{note_id}", perm(handleDeleteContactNote, "contacts:manage"))
118122

119123
// Teams.
120124
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))

frontend/src/api/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
311311
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
312312
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
313313
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
314+
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
315+
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
316+
const updateContactNote = (id, noteId, data) => http.put(`/api/v1/contacts/${id}/notes/${noteId}`, data)
317+
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
314318

315319
export default {
316320
login,
@@ -435,4 +439,8 @@ export default {
435439
updateCustomAttribute,
436440
deleteCustomAttribute,
437441
getCustomAttribute,
442+
getContactNotes,
443+
createContactNote,
444+
updateContactNote,
445+
deleteContactNote
438446
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<template>
2+
<div class="w-full space-y-6 pb-8 relative">
3+
<!-- Header -->
4+
<div class="flex items-center justify-between mb-4">
5+
<span class="text-xl font-semibold text-gray-900">{{ $t('globals.terms.note', 2) }}</span>
6+
<Button
7+
variant="outline"
8+
size="sm"
9+
@click="startAddingNote"
10+
v-if="!isAddingNote && !isLoading && notes.length !== 0"
11+
class="transition-all hover:bg-primary/10 hover:border-primary/30"
12+
>
13+
<PlusIcon class="mr-2" size="18" />
14+
{{ $t('globals.messages.new', { name: $t('globals.terms.note') }) }}
15+
</Button>
16+
</div>
17+
18+
<div class="h-52" v-if="isLoading">
19+
<Spinner />
20+
</div>
21+
22+
<!-- Note input -->
23+
<div v-if="isAddingNote">
24+
<form @submit.prevent="addOrUpdateNote" @keydown.ctrl.enter="addOrUpdateNote">
25+
<div class="space-y-4">
26+
<div class="box p-2 h-52 min-h-52">
27+
<Editor
28+
v-model:htmlContent="newNote"
29+
@update:htmlContent="(value) => (newNote = value)"
30+
:placeholder="t('editor.placeholder')"
31+
/>
32+
</div>
33+
<div class="flex justify-end space-x-3 pt-2">
34+
<Button
35+
variant="outline"
36+
@click="cancelAddNote"
37+
class="transition-all hover:bg-gray-100"
38+
>
39+
Cancel
40+
</Button>
41+
<Button type="submit" :disabled="!newNote.trim()">
42+
{{
43+
editingNoteId
44+
? $t('globals.buttons.update') + ' ' + $t('globals.terms.note').toLowerCase()
45+
: $t('globals.buttons.save') + ' ' + $t('globals.terms.note').toLowerCase()
46+
}}
47+
</Button>
48+
</div>
49+
</div>
50+
</form>
51+
</div>
52+
53+
<!-- Notes card list -->
54+
<div class="space-y-4">
55+
<Card
56+
v-for="note in notes"
57+
:key="note.id"
58+
class="overflow-hidden border-gray-2 00 hover:border-gray-300 transition-all duration-200 box hover:shadow"
59+
>
60+
<!-- Header -->
61+
<CardHeader class="bg-gray-50/50">
62+
<div class="flex items-center justify-between">
63+
<div class="flex items-center space-x-3">
64+
<Avatar class="border border-gray-200 shadow-sm">
65+
<AvatarImage :src="note.avatar_url" />
66+
<AvatarFallback>
67+
{{ getInitials(note.first_name, note.last_name) }}
68+
</AvatarFallback>
69+
</Avatar>
70+
<div>
71+
<p class="text-sm font-medium text-gray-900">{{ note.first_name }}</p>
72+
<p class="text-xs text-muted-foreground flex items-center">
73+
<ClockIcon class="h-3 w-3 mr-1 inline-block opacity-70" />
74+
{{ formatDate(note.created_at) }}
75+
</p>
76+
</div>
77+
</div>
78+
<DropdownMenu>
79+
<DropdownMenuTrigger asChild>
80+
<Button variant="ghost" size="icon" class="h-8 w-8 rounded-full">
81+
<MoreVerticalIcon class="h-4 w-4" />
82+
<span class="sr-only">Open menu</span>
83+
</Button>
84+
</DropdownMenuTrigger>
85+
<DropdownMenuContent align="end" class="w-[180px]">
86+
<DropdownMenuItem @click="editNote(note)" class="cursor-pointer">
87+
<PencilIcon class="mr-2" size="15" />
88+
{{ $t('globals.buttons.edit', { name: $t('globals.terms.note').toLowerCase() }) }}
89+
</DropdownMenuItem>
90+
<DropdownMenuSeparator />
91+
<DropdownMenuItem
92+
@click="deleteNote(note.id)"
93+
class="text-destructive cursor-pointer"
94+
>
95+
<TrashIcon class="mr-2" size="15" />
96+
{{
97+
$t('globals.buttons.delete', { name: $t('globals.terms.note').toLowerCase() })
98+
}}
99+
</DropdownMenuItem>
100+
</DropdownMenuContent>
101+
</DropdownMenu>
102+
</div>
103+
</CardHeader>
104+
105+
<!-- Note content -->
106+
<CardContent class="pt-4 pb-5 text-gray-700">
107+
<p class="whitespace-pre-wrap text-sm leading-relaxed" v-dompurify-html="note.note"></p>
108+
</CardContent>
109+
</Card>
110+
</div>
111+
112+
<!-- No notes message -->
113+
<div
114+
v-if="notes.length === 0 && !isAddingNote && !isLoading"
115+
class="box border-dashed p-10 text-center bg-gray-50/50 mt-6"
116+
>
117+
<div class="flex flex-col items-center">
118+
<div class="rounded-full bg-gray-100 p-4 mb-2">
119+
<MessageSquareIcon class="text-gray-400" size="25" />
120+
</div>
121+
<h3 class="mt-2 text-base font-medium text-gray-900">{{ $t('contact.noNotes') }}</h3>
122+
<p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
123+
{{ $t('contact.notes.help') }}
124+
</p>
125+
<Button variant="outline" class="mt-3 border-gray-300" @click="startAddingNote">
126+
<PlusIcon class="mr-2" size="15" />
127+
{{ $t('globals.messages.add', { name: $t('globals.terms.note').toLowerCase() }) }}
128+
</Button>
129+
</div>
130+
</div>
131+
</div>
132+
</template>
133+
134+
<script setup>
135+
import { ref, onMounted } from 'vue'
136+
import { format } from 'date-fns'
137+
import { Button } from '@/components/ui/button'
138+
import { Card, CardHeader, CardContent } from '@/components/ui/card'
139+
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
140+
import { Spinner } from '@/components/ui/spinner'
141+
import {
142+
DropdownMenu,
143+
DropdownMenuTrigger,
144+
DropdownMenuContent,
145+
DropdownMenuItem,
146+
DropdownMenuSeparator
147+
} from '@/components/ui/dropdown-menu'
148+
import {
149+
PlusIcon,
150+
MoreVerticalIcon,
151+
PencilIcon,
152+
TrashIcon,
153+
ClockIcon,
154+
MessageSquareIcon
155+
} from 'lucide-vue-next'
156+
import Editor from '@/features/conversation/ConversationTextEditor.vue'
157+
import { useI18n } from 'vue-i18n'
158+
import { useEmitter } from '@/composables/useEmitter'
159+
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
160+
import { handleHTTPError } from '@/utils/http'
161+
import { getInitials } from '@/utils/strings'
162+
import api from '@/api'
163+
164+
const props = defineProps({ contactId: Number })
165+
const { t } = useI18n()
166+
const emitter = useEmitter()
167+
168+
const notes = ref([])
169+
const isAddingNote = ref(false)
170+
const newNote = ref('')
171+
const editingNoteId = ref(null)
172+
const isLoading = ref(false)
173+
174+
const fetchNotes = async () => {
175+
try {
176+
isLoading.value = true
177+
const { data } = await api.getContactNotes(props.contactId)
178+
notes.value = data.data
179+
} catch (error) {
180+
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
181+
variant: 'destructive',
182+
description: handleHTTPError(error).message
183+
})
184+
} finally {
185+
isLoading.value = false
186+
}
187+
}
188+
189+
onMounted(fetchNotes)
190+
191+
const formatDate = (date) => format(new Date(date), 'PPP p')
192+
193+
const startAddingNote = () => {
194+
isAddingNote.value = true
195+
}
196+
197+
const cancelAddNote = () => {
198+
isAddingNote.value = false
199+
newNote.value = ''
200+
editingNoteId.value = null
201+
}
202+
203+
const addOrUpdateNote = async () => {
204+
try {
205+
if (editingNoteId.value) {
206+
await api.updateContactNote(props.contactId, editingNoteId.value, { note: newNote.value })
207+
} else {
208+
await api.createContactNote(props.contactId, { note: newNote.value })
209+
}
210+
await fetchNotes()
211+
cancelAddNote()
212+
} catch (error) {
213+
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
214+
variant: 'destructive',
215+
description: handleHTTPError(error).message
216+
})
217+
}
218+
}
219+
220+
const editNote = (note) => {
221+
editingNoteId.value = note.id
222+
newNote.value = note.note
223+
isAddingNote.value = true
224+
}
225+
226+
const deleteNote = async (noteId) => {
227+
await api.deleteContactNote(props.contactId, noteId)
228+
await fetchNotes()
229+
}
230+
</script>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-12 lg:px-12 xl:px-72 pt-6">
2+
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-12 lg:px-24 xl:px-24 2xl:px-96 pt-6">
33
<slot />
44
</div>
55
</template>

frontend/src/utils/strings.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,10 @@ export function getTextFromHTML (htmlString) {
5757
console.error('Error converting HTML to text:', error)
5858
return ''
5959
}
60+
}
61+
62+
export function getInitials(firstName = '', lastName = '') {
63+
const firstInitial = firstName.charAt(0).toUpperCase() || ''
64+
const lastInitial = lastName.charAt(0).toUpperCase() || ''
65+
return `${firstInitial}${lastInitial}`
6066
}

frontend/src/views/contact/ContactDetailView.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
</div>
77

88
<div v-if="contact" class="flex justify-center space-y-4 w-full">
9-
<div class="flex flex-col w-full">
10-
<div class="h-16"></div>
11-
9+
<div class="flex flex-col w-full mt-12">
1210
<div class="flex flex-col space-y-2">
1311
<AvatarUpload
1412
@upload="onUpload"
@@ -42,8 +40,9 @@
4240
</div>
4341
</div>
4442

45-
<div class="mt-12">
43+
<div class="mt-12 space-y-10">
4644
<ContactForm :formLoading="formLoading" :onSubmit="onSubmit" />
45+
<ContactNotes :contactId="contact.id" />
4746
</div>
4847
</div>
4948
</div>
@@ -101,6 +100,7 @@ import { ShieldOffIcon, ShieldCheckIcon } from 'lucide-vue-next'
101100
import ContactDetail from '@/layouts/contact/ContactDetail.vue'
102101
import api from '@/api'
103102
import ContactForm from '@/features/contact/ContactForm.vue'
103+
import ContactNotes from '@/features/contact/ContactNotes.vue'
104104
import { createFormSchema } from '@/features/contact/formSchema.js'
105105
import { useEmitter } from '@/composables/useEmitter'
106106
import { EMITTER_EVENTS } from '@/constants/emitterEvents'

0 commit comments

Comments
 (0)