wip: canned responses admin section.

This commit is contained in:
Abhinav Raut
2024-08-26 01:35:21 +05:30
parent d647d88502
commit 7056ed04db
42 changed files with 1045 additions and 199 deletions

View File

@@ -1,16 +1,98 @@
package main
import (
"strconv"
cmodels "github.com/abhinavxd/artemis/internal/cannedresp/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func handleGetCannedResponses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
c []cmodels.CannedResponse
)
c, err := app.cannedResp.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}
func handleCreateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
err := app.cannedResp.Create(cannedResponse.Title, cannedResponse.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}
func handleDeleteCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := app.cannedResp.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleUpdateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
if err = app.cannedResp.Update(id, cannedResponse.Title, cannedResponse.Content); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}

View File

@@ -72,7 +72,7 @@ func handleGetConversation(r *fastglue.Request) error {
return r.SendEnvelope(c)
}
func handleUpdateAssigneeLastSeen(r *fastglue.Request) error {
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
@@ -96,7 +96,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
return r.SendEnvelope(p)
}
func handleUpdateUserAssignee(r *fastglue.Request) error {
func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
@@ -128,7 +128,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
func handleUpdatePriority(r *fastglue.Request) error {
func handleUpdateConversationPriority(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
@@ -142,7 +142,7 @@ func handleUpdatePriority(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
func handleUpdateStatus(r *fastglue.Request) error {
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
@@ -224,26 +224,3 @@ func handleDashboardCharts(r *fastglue.Request) error {
}
return r.SendEnvelope(stats)
}
func handleGetAllStatuses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
out, err := app.conversation.GetAllStatuses()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
func handleGetAllPriorities(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
out, err := app.conversation.GetAllPriorities()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}

View File

@@ -38,17 +38,17 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/oidc/{id}", perm(handleUpdateOIDC, "login:manage"))
g.DELETE("/api/oidc/{id}", perm(handleDeleteOIDC, "login:manage"))
// Conversation.
// Conversation & message.
g.GET("/api/conversations/all", perm(handleGetAllConversations, "conversations:all"))
g.GET("/api/conversations/team", perm(handleGetTeamConversations, "conversations:team"))
g.GET("/api/conversations/assigned", perm(handleGetAssignedConversations, "conversations:assigned"))
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateUserAssignee, "conversations:edit_user"))
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations:edit_user"))
g.PUT("/api/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations:edit_team"))
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdatePriority, "conversations:edit_priority"))
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateStatus, "conversations:edit_status"))
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:edit_priority"))
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:edit_status"))
g.GET("/api/conversations/{uuid}", perm(handleGetConversation))
g.GET("/api/conversations/{uuid}/participants", perm(handleGetConversationParticipants))
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateAssigneeLastSeen))
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen))
g.POST("/api/conversations/{uuid}/tags", perm(handleAddConversationTags))
g.GET("/api/conversations/{uuid}/messages", perm(handleGetMessages))
g.POST("/api/conversations/{uuid}/messages", perm(handleSendMessage))
@@ -56,17 +56,28 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/message/{uuid}", perm(handleGetMessage))
// Conversation statuses.
g.GET("/api/conversation/statuses", perm(handleGetAllStatuses))
g.GET("/api/statuses", perm(handleGetStatuses))
g.POST("/api/statuses", perm(handleCreateStatus, "statuses:manage"))
g.DELETE("/api/statuses/{id}", perm(handleDeleteStatus, "statuses:manage"))
g.PUT("/api/statuses/{id}", perm(handleUpdateStatus, "statuses:manage"))
// Conversation priorities.
g.GET("/api/conversation/priorities", perm(handleGetAllPriorities))
g.GET("/api/priorities", perm(handleGetPriorities))
// Conversation tags.
g.GET("/api/tags", perm(handleGetTags))
g.POST("/api/tags", perm(handleCreateTag, "tags:manage"))
g.DELETE("/api/tags/{id}", perm(handleDeleteTag, "tags:manage"))
g.PUT("/api/tags/{id}", perm(handleUpdateTag, "tags:manage"))
// Media.
g.POST("/api/media", perm(handleMediaUpload))
// Canned response.
g.GET("/api/canned-responses", perm(handleGetCannedResponses))
// TODO: add create, update endponints.
g.POST("/api/canned-responses", perm(handleCreateCannedResponse, "canned_responses:manage"))
g.PUT("/api/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses:manage"))
g.DELETE("/api/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses:manage"))
// User.
g.GET("/api/users/me", perm(handleGetCurrentUser))
@@ -83,14 +94,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
g.POST("/api/teams", perm(handleCreateTeam, "teams:manage"))
// Tags.
g.GET("/api/tags", perm(handleGetTags))
g.POST("/api/tags", perm(handleCreateTag, "tags:manage"))
g.DELETE("/api/tags/{id}", perm(handleDeleteTag, "tags:manage"))
g.PUT("/api/tags/{id}", perm(handleUpdateTag, "tags:manage"))
// TODO: Add conversation create status, priority of conversation.
// i18n.
g.GET("/api/lang/{lang}", handleGetI18nLang)
@@ -203,19 +206,7 @@ func sendErrorEnvelope(r *fastglue.Request, err error) error {
return r.SendErrorEnvelope(e.Code, e.Error(), e.Data, fastglue.ErrorType(e.ErrorType))
}
// handleHealthCheck handles the health check endpoint by pinging the PostgreSQL and Redis.
// handleHealthCheck handles the health check.
func handleHealthCheck(r *fastglue.Request) error {
var app = r.Context.(*App)
// Ping DB.
if err := app.db.Ping(); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "DB ping failed.", nil, envelope.GeneralError)
}
// Ping Redis.
if err := app.rdb.Ping(r.RequestCtx).Err(); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Redis ping failed.", nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/abhinavxd/artemis/internal/oidc"
"github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/status"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
@@ -511,6 +512,17 @@ func initRole(db *sqlx.DB) *role.Manager {
return r
}
func initStatus(db *sqlx.DB) *status.Manager {
manager, err := status.New(status.Opts{
DB: db,
Lo: initLogger("status-manager"),
})
if err != nil {
log.Fatalf("error initializing status manager: %v", err)
}
return manager
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -18,6 +18,7 @@ import (
"github.com/abhinavxd/artemis/internal/oidc"
"github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/status"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
@@ -53,6 +54,7 @@ type App struct {
contact *contact.Manager
user *user.Manager
team *team.Manager
status *status.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
@@ -120,7 +122,6 @@ func main() {
// Init the app
var app = &App{
lo: lo,
rdb: rdb,
auth: auth,
fs: fs,
i18n: i18n,
@@ -134,8 +135,9 @@ func main() {
conversation: conversation,
automation: automation,
oidc: oidc,
role: initRole(db),
constant: initConstants(),
status: initStatus(db),
role: initRole(db),
tag: initTags(db),
cannedResp: initCannedResponse(db),
}

22
cmd/priorities.go Normal file
View File

@@ -0,0 +1,22 @@
package main
import (
"time"
"github.com/zerodha/fastglue"
)
type Priority struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
}
func handleGetPriorities(r *fastglue.Request) error {
priorities := []Priority{
{ID: 1, Name: "Low"},
{ID: 2, Name: "Medium"},
{ID: 3, Name: "High"},
}
return r.SendEnvelope(priorities)
}

93
cmd/statuses.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"strconv"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func handleGetStatuses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
out, err := app.status.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
func handleCreateStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
status = cmodels.Status{}
)
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
}
err := app.status.Create(status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeleteStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `ID`", nil, envelope.InputError)
}
err = app.status.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleUpdateStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
status = cmodels.Status{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
}
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
}
err = app.status.Update(id, status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

Binary file not shown.

View File

@@ -38,7 +38,6 @@
"add": "^2.0.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
"date-fns": "^3.6.0",
"install": "^0.13.0",
"lucide-vue-next": "^0.378.0",
@@ -52,10 +51,8 @@
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"textarea": "^0.3.0",
"tiptap-extension-resize-image": "^1.1.5",
"vee-validate": "^4.13.2",
"vue": "^3.4.37",
"vue-draggable-resizable": "^3.0.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",

View File

@@ -16,8 +16,11 @@ http.interceptors.request.use((request) => {
return request
})
const getAllStatuses = () => http.get('/api/conversation/statuses')
const getAllPriorities = () => http.get('/api/conversation/priorities')
const getPriorities = () => http.get('/api/priorities')
const getStatuses = () => http.get('/api/statuses')
const createStatus = (data) => http.post('/api/statuses', data)
const updateStatus = (id, data) => http.put(`/api/statuses/${id}`, data)
const deleteStatus = (id) => http.delete(`/api/statuses/${id}`)
const createTag = (data) => http.post('/api/tags', data)
const updateTag = (id, data) => http.put(`/api/tags/${id}`, data)
const deleteTag = (id) => http.delete(`/api/tags/${id}`)
@@ -108,8 +111,8 @@ const getTags = () => http.get('/api/tags')
const upsertTags = (uuid, data) => http.post(`/api/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/conversations/${uuid}/assignee/${assignee_type}`, data)
const updateStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
const updatePriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
const updateConversationStatus = (uuid, data) => http.put(`/api/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversations/${uuid}/last-seen`)
const getMessage = (uuid) => http.get(`/api/message/${uuid}`)
const retryMessage = (uuid) => http.get(`/api/message/${uuid}/retry`)
@@ -126,6 +129,9 @@ const sendMessage = (uuid, data) =>
const getConversation = (uuid) => http.get(`/api/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/canned-responses')
const createCannedResponse = (data) => http.post('/api/canned-responses', data)
const updateCannedResponse = (id, data) => http.put(`/api/canned-responses/${id}`, data)
const deleteCannedResponse = (id) => http.delete(`/api/canned-responses/${id}`)
const getAssignedConversations = (page, filter) =>
http.get(`/api/conversations/assigned?page=${page}&filter=${filter}`)
const getTeamConversations = (page, filter) =>
@@ -204,10 +210,13 @@ export default {
getMessages,
getCurrentUser,
getCannedResponses,
createCannedResponse,
updateCannedResponse,
deleteCannedResponse,
updateCurrentUser,
updateAssignee,
updateStatus,
updatePriority,
updateConversationStatus,
updateConversationPriority,
upsertTags,
uploadMedia,
updateAutomationRule,
@@ -240,6 +249,9 @@ export default {
createTag,
updateTag,
deleteTag,
getAllStatuses,
getAllPriorities
getStatuses,
getPriorities,
createStatus,
updateStatus,
deleteStatus,
}

View File

@@ -3,7 +3,7 @@
@tailwind utilities;
// App default font-size.
// Default: 16px, 15px looked very wide.
// Default: 16px, 15px looks wide.
:root {
font-size: 14px;
}
@@ -162,14 +162,6 @@ body {
background: rgba(0, 0, 0, 0.6);
}
.title {
@apply text-xl font-semibold;
}
.sub-title {
@apply text-xl font-medium;
}
.text-sm-muted {
@apply text-muted-foreground text-sm;
}

View File

@@ -16,7 +16,7 @@ const allNavItems = [
{
title: 'Conversations',
href: '/admin/conversations',
description: 'Manage conversation tags, statuses and priorities.',
description: 'Manage tags, canned responses and statuses.',
permission: null
},
{

View File

@@ -4,14 +4,8 @@
<PageHeader title="Conversation" description="Manage conversation settings" />
</div>
<div class="flex space-x-5">
<AdminMenuCard
v-for="card in cards"
:key="card.title"
:onClick="card.onClick"
:title="card.title"
:subTitle="card.subTitle"
:icon="card.icon"
>
<AdminMenuCard v-for="card in cards" :key="card.title" :onClick="card.onClick" :title="card.title"
:subTitle="card.subTitle" :icon="card.icon">
</AdminMenuCard>
</div>
</div>
@@ -20,7 +14,7 @@
<script setup>
import { useRouter } from 'vue-router'
import { Tag, TrendingUp, Activity } from 'lucide-vue-next'
import { Tag, TrendingUp, MessageCircleReply } from 'lucide-vue-next'
import AdminMenuCard from '@/components/admin/common/MenuCard.vue'
import PageHeader from '../common/PageHeader.vue'
@@ -31,11 +25,11 @@ const navigateToTags = () => {
}
const navigateToStatus = () => {
router.push('/admin/conversations/status')
router.push('/admin/conversations/statuses')
}
const navigateToPriority = () => {
router.push('/admin/conversations/priority')
const navigateToCannedResponse = () => {
router.push('/admin/conversations/canned-responses')
}
const cards = [
@@ -46,10 +40,10 @@ const cards = [
icon: Tag
},
{
title: 'Priority',
subTitle: 'Manage conversation priorities.',
onClick: navigateToPriority,
icon: Activity
title: 'Canned response',
subTitle: 'Manage canned responses.',
onClick: navigateToCannedResponse,
icon: MessageCircleReply
},
{
title: 'Status',

View File

@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Canned responses" description="Manage canned responses" />
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button size="sm">New canned response</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add a canned response</DialogTitle>
<DialogDescription> Set canned response name. Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ field }" name="title">
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="content">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea v-bind="componentField" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm">Save Changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="cannedResponses" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { useEmitter } from '@/composables/useEmitter'
import api from '@/api'
const cannedResponses = ref([])
const emit = useEmitter()
const dialogOpen = ref(false)
onMounted(() => {
getCannedResponses()
emit.on('refresh-list', refreshList)
})
onUnmounted(() => {
emit.off('refresh-list', refreshList)
})
const refreshList = (data) => {
if (data?.name === 'canned_responses') getCannedResponses()
}
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const getCannedResponses = async () => {
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
}
const onSubmit = form.handleSubmit(async (values) => {
try {
await api.createCannedResponse(values)
dialogOpen.value = false
getCannedResponses()
} catch (error) {
console.error('Failed to create canned response:', error)
}
})
</script>

View File

@@ -0,0 +1,47 @@
import { h } from 'vue'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
{
accessorKey: 'title',
header: function () {
return h('div', { class: 'text-center' }, 'Title')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('title'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, 'Created at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Updated at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const canned_response = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
canned_response
})
)
}
}
]

View File

@@ -0,0 +1,130 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem> Edit </DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteCannedResponse"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit canned response</DialogTitle>
<DialogDescription>Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="content">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea v-bind="componentField" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm"> Save changes </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useEmitter } from '@/composables/useEmitter'
import api from '@/api/index.js'
const dialogOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
canned_response: {
type: Object,
required: true,
default: () => ({
id: '',
name: ''
})
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit(async (values) => {
await api.updateCannedResponse(props.canned_response.id, values)
dialogOpen.value = false
emitRefreshCannedResponseList()
})
const deleteCannedResponse = async () => {
await api.deleteCannedResponse(props.canned_response.id)
dialogOpen.value = false
emitRefreshCannedResponseList()
}
const emitRefreshCannedResponseList = () => {
emit.emit('refresh-list', {
name: 'canned_responses'
})
}
// Watch for changes in initialValues and update the form.
watch(
() => props.canned_response,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
)
</script>

View File

@@ -0,0 +1,18 @@
import * as z from 'zod'
export const formSchema = z.object({
title: z
.string({
required_error: 'Title is required.'
})
.min(1, {
message: 'Title must be at least 1 character.'
}),
content: z
.string({
required_error: 'Content is required.'
})
.min(1, {
message: 'Content must be atleast 3 characters.'
})
})

View File

@@ -1,3 +0,0 @@
<template>Priority</template>
<script setup></script>

View File

@@ -1,3 +1,99 @@
<template>Status</template>
<template>
<div>
<div class="flex justify-between mb-5">
<PageHeader title="Status" description="Manage conversation statuses" />
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button size="sm">New Status</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>New status</DialogTitle>
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ field }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Processing" v-bind="field" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm">Save Changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div class="w-full">
<DataTable :columns="columns" :data="statuses" />
</div>
</div>
</template>
<script setup></script>
<script setup>
import { ref, onMounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import PageHeader from '@/components/admin/common/PageHeader.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { useEmitter } from '@/composables/useEmitter'
import api from '@/api'
const statuses = ref([])
const emit = useEmitter()
const dialogOpen = ref(false)
onMounted(() => {
getStatuses()
emit.on('refresh-list', (data) => {
if (data?.name === 'status') getStatuses()
})
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const getStatuses = async () => {
const resp = await api.getStatuses()
statuses.value = resp.data.data
}
const onSubmit = form.handleSubmit(async (values) => {
try {
await api.createStatus(values)
dialogOpen.value = false
getStatuses()
} catch (error) {
console.error('Failed to create status:', error)
}
})
</script>

View File

@@ -0,0 +1,38 @@
import { h } from 'vue'
import dropdown from './dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, 'Created at')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const status = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
status
})
)
}
}
]

View File

@@ -0,0 +1,119 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem> Edit </DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteStatus"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit status</DialogTitle>
<DialogDescription> Change the status name. Click save when you're done. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="billing, tech" v-bind="componentField" />
</FormControl>
<FormDescription>Renaming the status will rename it across all conversations.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button type="submit" size="sm"> Save changes </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
import api from '@/api/index.js'
const dialogOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
status: {
type: Object,
required: true,
default: () => ({
id: '',
name: ''
})
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit(async (values) => {
await api.updateStatus(props.status.id, values)
dialogOpen.value = false
emitRefreshStatusList()
})
const deleteStatus = async () => {
await api.deleteStatus(props.status.id)
dialogOpen.value = false
emitRefreshStatusList()
}
const emitRefreshStatusList = () => {
emit.emit('refresh-list', {
name: 'status'
})
}
// Watch for changes in initialValues and update the form.
watch(
() => props.status,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
)
</script>

View File

@@ -0,0 +1,11 @@
import * as z from 'zod'
export const formSchema = z.object({
name: z
.string({
required_error: 'Status name is required.'
})
.min(1, {
message: 'Status must be at least 1 character.'
})
})

View File

@@ -5,7 +5,7 @@
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button @click="newTag" size="sm">New Tag</Button>
<Button size="sm">New Tag</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>

View File

@@ -6,6 +6,6 @@ export const formSchema = z.object({
required_error: 'Tag name is required.'
})
.min(1, {
message: 'First name must be at least 1 character.'
message: 'Tag name must be at least 1 character.'
})
})

View File

@@ -1,6 +1,6 @@
<template>
<div>
<span class="title">
<span class="text-2xl">
{{ title }}
</span>
<p class="text-sm-muted">

View File

@@ -64,11 +64,11 @@ const conversationStore = useConversationStore()
const statuses = ref([])
onMounted(() => {
getAllStatuses()
getStatuses()
})
const getAllStatuses = async () => {
const resp = await api.getAllStatuses()
const getStatuses = async () => {
const resp = await api.getStatuses()
statuses.value = resp.data.data
}

View File

@@ -459,7 +459,7 @@ onMounted(() => {
})
const getPrioritites = async () => {
const resp = await api.getAllPriorities()
const resp = await api.getPriorities()
priorities.value = resp.data.data.map((priority) => priority.name)
}

View File

@@ -162,12 +162,12 @@ const routes = [
component: () => import('@/components/admin/conversation/tags/TagsPage.vue')
},
{
path: 'conversations/priority',
component: () => import('@/components/admin/conversation/priority/PriorityPage.vue')
path: 'conversations/statuses',
component: () => import('@/components/admin/conversation/status/StatusPage.vue')
},
{
path: 'conversations/status',
component: () => import('@/components/admin/conversation/status/StatusPage.vue')
path: 'conversations/canned-responses',
component: () => import('@/components/admin/conversation/canned_responses/CannedResponsesPage.vue')
}
]
},

View File

@@ -242,7 +242,7 @@ export const useConversationStore = defineStore('conversation', () => {
async function updatePriority(v) {
try {
await api.updatePriority(conversation.data.uuid, { priority: v })
await api.updateConversationPriority(conversation.data.uuid, { priority: v })
} catch (error) {
// Pass.
}
@@ -250,7 +250,7 @@ export const useConversationStore = defineStore('conversation', () => {
async function updateStatus(v) {
try {
await api.updateStatus(conversation.data.uuid, { status: v })
await api.updateConversationStatus(conversation.data.uuid, { status: v })
} catch (error) {
toast({
title: 'Uh oh! Could not update status, Please try again.',

View File

@@ -1787,11 +1787,6 @@
resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404"
integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==
"@types/prismjs@^1.9.1":
version "1.26.4"
resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.4.tgz#1a9e1074619ce1d7322669e5b46fbe823925103a"
integrity sha512-rlAnzkW2sZOjbqZ743IHUhFcvzaGbqijwOu8QZnZCjfQzBqFE3s4lOTJEsxikImav9uzz/42I+O7YUs1mWgMlg==
"@types/sinonjs__fake-timers@8.1.1":
version "8.1.1"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3"
@@ -3020,14 +3015,6 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==
codeflask@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/codeflask/-/codeflask-1.4.1.tgz#c5229854e3f648377922a75f1145f7316030d3db"
integrity sha512-4vb2IbE/iwvP0Uubhd2ixVeysm3KNC2pl7SoDaisxq1juhZzvap3qbaX7B2CtpQVvv5V9sjcQK8hO0eTcY0V9Q==
dependencies:
"@types/prismjs" "^1.9.1"
prismjs "^1.14.0"
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -7297,11 +7284,6 @@ pretty-bytes@^5.6.0:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
prismjs@^1.14.0:
version "1.29.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12"
integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==
proc-log@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8"
@@ -8857,11 +8839,6 @@ tippy.js@^6.3.7:
dependencies:
"@popperjs/core" "^2.9.0"
tiptap-extension-resize-image@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/tiptap-extension-resize-image/-/tiptap-extension-resize-image-1.1.5.tgz#b150ec91a0adfa85f42415cf25fb353523c6cf51"
integrity sha512-ZXadHII5Y9ZNVwg7QSQhJyNg2113/NUYzGrOalDSLTAG+fydG//rLIJ8cfmqZeLBkqvew7PN0qI5kSXPRtmX5Q==
tmp@~0.2.1:
version "0.2.3"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
@@ -9253,11 +9230,6 @@ vue-demi@>=0.14.8:
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a"
integrity sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==
vue-draggable-resizable@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/vue-draggable-resizable/-/vue-draggable-resizable-3.0.0.tgz#82146819bfff8007d66a001d39a3227253d5d3f2"
integrity sha512-kABSggcXPoDB8dOTVE09ZkccxiMdNDUaLl7STTeghsFE3o+mk653iumrkmjKLfIBA/w5ZXWJmarBqoqsmWLzEg==
vue-eslint-parser@^9.4.2:
version "9.4.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d"

View File

@@ -30,6 +30,9 @@ type Opts struct {
type queries struct {
GetAll *sqlx.Stmt `query:"get-all"`
Create *sqlx.Stmt `query:"create"`
Update *sqlx.Stmt `query:"update"`
Delete *sqlx.Stmt `query:"delete"`
}
// New initializes a new Manager.
@@ -55,3 +58,40 @@ func (t *Manager) GetAll() ([]models.CannedResponse, error) {
}
return c, nil
}
// Create adds a new canned response.
func (t *Manager) Create(title, content string) error {
if _, err := t.q.Create.Exec(title, content); err != nil {
t.lo.Error("error creating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating canned response", nil)
}
return nil
}
// Update modifies an existing canned response.
func (t *Manager) Update(id int, title, content string) error {
result, err := t.q.Update.Exec(id, title, content)
if err != nil {
t.lo.Error("error updating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}
// Delete removes a canned response by ID.
func (t *Manager) Delete(id int) error {
result, err := t.q.Delete.Exec(id)
if err != nil {
t.lo.Error("error deleting canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}

View File

@@ -1,8 +1,11 @@
package models
// CannedResponse represents a canned response with an ID, title, and content.
import "time"
type CannedResponse struct {
ID string `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
ID string `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
}

View File

@@ -1,2 +1,13 @@
-- name: get-all
SELECT id, title, content from "canned_responses" order by title;
SELECT id, title, content, created_at, updated_at FROM canned_responses order by updated_at desc;
-- name: create
INSERT INTO canned_responses (title, content)
VALUES ($1, $2);
-- name: update
UPDATE canned_responses
SET title = $2, content = $3, updated_at = now() where id = $1;
-- name: delete
DELETE FROM canned_responses WHERE id = $1;

View File

@@ -174,12 +174,6 @@ type queries struct {
UpdateMessageContent *sqlx.Stmt `query:"update-message-content"`
UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"`
MessageExists *sqlx.Stmt `query:"message-exists"`
// Status
GetAllStatuses *sqlx.Stmt `query:"get-all-statuses"`
// Priority
GetAllPriorities *sqlx.Stmt `query:"get-all-priorities"`
}
// CreateConversation creates a new conversation and returns its ID and UUID.

View File

@@ -1,16 +0,0 @@
package conversation
import (
"github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/envelope"
)
// GetAllPriorities retrieves all priorities.
func (t *Manager) GetAllPriorities() ([]models.Priority, error) {
var priorities []models.Priority
if err := t.q.GetAllPriorities.Select(&priorities); err != nil {
t.lo.Error("error fetching priorities", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching priorities", nil)
}
return priorities, nil
}

View File

@@ -433,11 +433,3 @@ where id = $2;
-- name: update-message-status
update messages set status = $1 where uuid = $2;
-- Status queries.
-- name: get-all-statuses
SELECT * from status;
-- Priority queries.
-- name: get-all-priorities
SELECT * from priority;

View File

@@ -1,16 +0,0 @@
package conversation
import (
"github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/envelope"
)
// GetAllStatuses retrieves all statuses.
func (t *Manager) GetAllStatuses() ([]models.Status, error) {
var statuses []models.Status
if err := t.q.GetAllStatuses.Select(&statuses); err != nil {
t.lo.Error("error fetching statuses", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching statuses", nil)
}
return statuses, nil
}

View File

@@ -9,11 +9,14 @@ import (
// API errors.
const (
GeneralError = "GeneralException"
PermissionError = "PermissionException"
InputError = "InputException"
DataError = "DataException"
NetworkError = "NetworkException"
GeneralError = "GeneralException"
PermissionError = "PermissionException"
InputError = "InputException"
DataError = "DataException"
NetworkError = "NetworkException"
NotFoundError = "NotFoundException"
ConflictError = "ConflictException"
UnauthorizedError = "UnauthorizedException"
)
// Error is the error type used for all API errors.
@@ -48,6 +51,12 @@ func NewError(etype string, message string, data interface{}) error {
err.Code = http.StatusBadGateway
case NetworkError:
err.Code = http.StatusGatewayTimeout
case NotFoundError:
err.Code = fasthttp.StatusNotFound
case ConflictError:
err.Code = fasthttp.StatusConflict
case UnauthorizedError:
err.Code = fasthttp.StatusUnauthorized
default:
err.Code = fasthttp.StatusInternalServerError
err.ErrorType = GeneralError

View File

@@ -0,0 +1,9 @@
package models
import "time"
type Status struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"`
}

View File

@@ -0,0 +1,14 @@
-- name: get-all-statuses
select id,
created_at,
name
from status;
-- name: insert-status
INSERT into status(name) values ($1);
-- name: delete-status
DELETE from status where id = $1;
-- name: update-status
UPDATE status set name = $2 where id = $1;

88
internal/status/status.go Normal file
View File

@@ -0,0 +1,88 @@
// Package status handles the management of conversation statuses.
package status
import (
"embed"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/status/models"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager handles changes to statuses.
type Manager struct {
q queries
lo *logf.Logger
}
// Opts contains options for initializing the Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
// queries contains prepared SQL queries.
type queries struct {
GetAllStatuses *sqlx.Stmt `query:"get-all-statuses"`
InsertStatus *sqlx.Stmt `query:"insert-status"`
DeleteStatus *sqlx.Stmt `query:"delete-status"`
UpdateStatus *sqlx.Stmt `query:"update-status"`
}
// New creates and returns a new instance of the Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
}, nil
}
// GetAll retrieves all statuses.
func (m *Manager) GetAll() ([]models.Status, error) {
var statuses []models.Status
if err := m.q.GetAllStatuses.Select(&statuses); err != nil {
m.lo.Error("error fetching statuses", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching statuses", nil)
}
return statuses, nil
}
// Create creates a new status.
func (m *Manager) Create(name string) error {
if _, err := m.q.InsertStatus.Exec(name); err != nil {
m.lo.Error("error inserting status", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating status", nil)
}
return nil
}
// Delete deletes a status by ID.
func (m *Manager) Delete(id int) error {
if _, err := m.q.DeleteStatus.Exec(id); err != nil {
m.lo.Error("error deleting status", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting status", nil)
}
return nil
}
// Update updates a status by id.
func (m *Manager) Update(id int, name string) error {
if _, err := m.q.UpdateStatus.Exec(id, name); err != nil {
m.lo.Error("error updating status", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating status", nil)
}
return nil
}

View File

@@ -16,7 +16,7 @@ var (
efs embed.FS
)
// Manager handles tag-related operations.
// Manager manages tags.
type Manager struct {
q queries
lo *logf.Logger