This commit is contained in:
Abhinav Raut
2024-07-05 02:54:57 +05:30
parent d0326cfedd
commit e1c32afcf2
43 changed files with 4293 additions and 228 deletions

View File

@@ -5,12 +5,20 @@ VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
BIN_ARTEMIS := artemis.bin
DIST := dist
STATIC := frontend/dist
GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
$(STUFFBIN):
@echo "Installing stuffbin."
go install github.com/knadh/stuffbin/...
.PHONY: $(STUFFBIN)
.PHONY: $(BIN_ARTEMIS)
$(BIN_ARTEMIS):
$(BIN_ARTEMIS): $(STUFFBIN)
CGO_ENABLED=0 go build -a -ldflags="-X 'main.buildVersion=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" -o ${BIN_ARTEMIS} cmd/*.go
@echo "Build successful. Current build version: $(VERSION)"
$(STUFFBIN) -a stuff -in ${BIN_ARTEMIS} -out ${BIN_ARTEMIS} ${STATIC}
test:
@go test -v ./...

View File

@@ -206,3 +206,12 @@ func handleAssigneeStats(r *fastglue.Request) error {
}
return r.SendEnvelope(stats)
}
func handleNewConversationsStats(r *fastglue.Request) error {
var app = r.Context.(*App)
stats, err := app.conversationMgr.GetNewConversationsStats()
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error fetching conversation stats", nil, "")
}
return r.SendEnvelope(stats)
}

View File

@@ -1,6 +1,11 @@
package main
import (
"fmt"
"mime"
"net/http"
"path/filepath"
"github.com/abhinavxd/artemis/internal/ws"
"github.com/zerodha/fastglue"
)
@@ -13,6 +18,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/conversations/assigned", auth(handleGetAssignedConversations, "conversations.assigned"))
g.GET("/api/conversations/unassigned", auth(handleGetUnassignedConversations, "conversations.unassigned"))
g.GET("/api/conversations/assignee/stats", auth(handleAssigneeStats))
g.GET("/api/conversations/new/stats", auth(handleNewConversationsStats))
g.GET("/api/conversation/{conversation_uuid}", auth(handleGetConversation))
g.PUT("/api/conversation/{conversation_uuid}/last-seen", auth(handleUpdateAssigneeLastSeen))
g.GET("/api/conversation/{conversation_uuid}/participants", auth(handleGetConversationParticipants))
@@ -26,7 +32,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/message/{message_uuid}/retry", auth(handleRetryMessage))
g.GET("/api/message/{message_uuid}", auth(handleGetMessage))
g.GET("/api/canned-responses", auth(handleGetCannedResponses))
g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment))
g.POST("/api/upload", auth(handleFileUpload))
g.POST("/api/upload/view/{file_uuid}", auth(handleViewFile))
g.GET("/api/users/me", auth(handleGetCurrentUser))
g.GET("/api/users", auth(handleGetUsers))
g.GET("/api/teams", auth(handleGetTeams))
@@ -34,4 +41,60 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
}))
g.GET("/", sess(noAuthPage(serveIndexPage)))
g.GET("/dashboard", sess(authPage(serveIndexPage)))
g.GET("/conversations", sess(authPage(serveIndexPage)))
g.GET("/conversations/{all:*}", sess(authPage(serveIndexPage)))
g.GET("/account/profile", sess(authPage(serveIndexPage)))
g.GET("/assets/{all:*}", serveStaticFiles)
}
// serveIndexPage serves app's default index page.
func serveIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Add no-caching headers.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
r.RequestCtx.Response.Header.Add("Expires", "-1")
// Serve the index.html file from the Stuffbin archive.
file, err := app.fs.Get("/frontend/dist/index.html")
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "Page not found", nil, "InputException")
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveStaticFiles serves static files from the Stuffbin archive.
func serveStaticFiles(r *fastglue.Request) error {
fmt.Println("static here.")
app := r.Context.(*App)
// Get the requested path
filePath := string(r.RequestCtx.Path())
fmt.Println("file path ", filePath)
// Serve the file from the Stuffbin archive
finalPath := filepath.Join("frontend/dist", filePath)
fmt.Println("final path ", finalPath)
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, "InputException")
}
// Detect and set the appropriate Content-Type
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}

View File

@@ -31,6 +31,7 @@ import (
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/rawbytes"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
flag "github.com/spf13/pflag"
"github.com/vividvilla/simplesessions"
sessredisstore "github.com/vividvilla/simplesessions/stores/goredis"
@@ -73,6 +74,39 @@ func initConstants() consts {
}
}
// initFS initializes the stuffbin FileSystem.
func initFS() stuffbin.FileSystem {
var files = []string{
"frontend/dist:/dist",
}
// Get self executable path.
path, err := os.Executable()
if err != nil {
log.Fatalf("error initializing FS: %v", err)
}
fmt.Println("path -> ", path)
// Load embedded files in the executable.
fs, err := stuffbin.UnStuff(path)
if err != nil {
if err == stuffbin.ErrNoID {
// The embed failed or the binary's already unstuffed or running in local / dev mode, use the local filesystem.
log.Println("unstuff failed, using local FS")
fs, err = stuffbin.NewLocalFS("/", files...)
if err != nil {
log.Fatalf("error initializing local FS: %v", err)
}
} else {
log.Fatalf("error initializing FS: %v", err)
}
}
fmt.Println(fs.List())
return fs
}
// initSessionManager initializes and returns a simplesessions.Manager instance.
func initSessionManager(rd *redis.Client) *simplesessions.Manager {
ttl := ko.Duration("app.session.cookie_ttl")

View File

@@ -18,9 +18,11 @@ import (
"github.com/abhinavxd/artemis/internal/message"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/upload"
"github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/ws"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
"github.com/vividvilla/simplesessions"
"github.com/zerodha/fastglue"
@@ -40,6 +42,7 @@ const (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
constants consts
fs stuffbin.FileSystem
lo *logf.Logger
cntctMgr *contact.Manager
userMgr *user.Manager
@@ -49,12 +52,16 @@ type App struct {
msgMgr *message.Manager
rbac *uauth.Engine
inboxMgr *inbox.Manager
uploadMgr *upload.Manager
attachmentMgr *attachment.Manager
cannedRespMgr *cannedresp.Manager
conversationMgr *conversation.Manager
}
func main() {
// Load stuffbin fs.
fs := initFS()
// Load command line flags into Koanf.
initFlags()
@@ -107,6 +114,7 @@ func main() {
// Init the app
var app = &App{
lo: lo,
fs: fs,
cntctMgr: cntctMgr,
inboxMgr: inboxMgr,
userMgr: userMgr,

View File

@@ -3,6 +3,7 @@ package main
import (
"net/http"
"github.com/valyala/fasthttp"
"github.com/vividvilla/simplesessions"
"github.com/zerodha/fastglue"
)
@@ -62,3 +63,91 @@ func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastReq
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
}
}
// authPage middleware makes sure user is logged in to access the page
// else redirects to login page.
func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
// Check if user is logged in. If logged in return next handler.
userID, ok := getAuthUserFromSess(r)
if ok && userID > 0 {
return handler(r)
}
nextURI := r.RequestCtx.QueryArgs().Peek("next")
if len(nextURI) == 0 {
nextURI = r.RequestCtx.RequestURI()
}
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
"next": string(nextURI),
}, "")
}
}
// getAuthUserFromSess retrives authUser from request context set by the sess() middleware.
func getAuthUserFromSess(r *fastglue.Request) (int, bool) {
userID, ok := r.RequestCtx.UserValue("user_id").(int)
if userID == 0 || !ok {
return userID, false
}
return userID, true
}
func sess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
sess, err = app.sessMgr.Acquire(r, r, nil)
)
if err != nil {
app.lo.Error("error acquiring session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
}
// User email in session?
email, err := sess.String(sess.Get("user_email"))
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
app.lo.Error("error fetching session session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
}
// User ID in session?
userID, err := sess.Int(sess.Get("user_id"))
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
app.lo.Error("error fetching session session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
}
userUUID, err := sess.String(sess.Get("user_uuid"))
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
app.lo.Error("error fetching session session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
}
if email != "" && userID > 0 {
// Set both in request context so they can be accessed in the handlers.
r.RequestCtx.SetUserValue("user_email", email)
r.RequestCtx.SetUserValue("user_id", userID)
r.RequestCtx.SetUserValue("user_uuid", userUUID)
}
return handler(r)
}
}
func noAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
_, ok := getAuthUserFromSess(r)
if !ok {
return handler(r)
}
// User is logged in direct if `next` is available else redirect to the dashboard.
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
if len(nextURI) == 0 {
nextURI = "/dashboard"
}
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
}
}

72
cmd/upload.go Normal file
View File

@@ -0,0 +1,72 @@
package main
import (
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/zerodha/fastglue"
)
func handleFileUpload(r *fastglue.Request) error {
var (
app = r.Context.(*App)
form, err = r.RequestCtx.MultipartForm()
)
if err != nil {
app.lo.Error("error parsing media form data.", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error parsing data", nil, "GeneralException")
}
if files, ok := form.File["files"]; !ok || len(files) == 0 {
return r.SendErrorEnvelope(http.StatusBadRequest, "File not found", nil, "InputException")
}
// Read file into the memory
file, err := form.File["files"][0].Open()
srcFileName := form.File["files"][0].Filename
srcContentType := form.File["files"][0].Header.Get("Content-Type")
srcFileSize := strconv.FormatInt(form.File["files"][0].Size, 10)
if err != nil {
app.lo.Error("reading file into the memory", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error reading file", nil, "GeneralException")
}
defer file.Close()
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
// Checking if file type is allowed.
if !slices.Contains(app.constants.AllowedFileUploadExtensions, "*") {
if !slices.Contains(app.constants.AllowedFileUploadExtensions, ext) {
return r.SendErrorEnvelope(http.StatusBadRequest, "Unsupported file type", nil, "GeneralException")
}
}
// Reset the ptr.
file.Seek(0, 0)
url, err := app.uploadMgr.Upload(srcFileName, srcContentType, file)
if err != nil {
app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException")
}
return r.SendEnvelope(map[string]string{
"url": url,
"content_type": srcContentType,
"name": srcFileName,
"size": srcFileSize,
})
}
func handleViewFile(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("file_uuid").(string)
)
url := app.uploadMgr.Store.GetURL(uuid)
r.RequestCtx.Response.Header.Set("Location", url)
return r.Redirect(url, http.StatusFound, nil, "")
}

Binary file not shown.

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<!-- <link rel="icon" href="/favicon.ico"> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">

View File

@@ -15,6 +15,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@heroicons/vue": "^2.1.1",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
@@ -28,9 +29,11 @@
"@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.1",
"@unovis/vue": "^1.4.1",
"@vee-validate/zod": "^4.13.1",
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueuse/core": "^10.11.0",
"add": "^2.0.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
@@ -38,17 +41,22 @@
"install": "^0.13.0",
"lucide-vue-next": "^0.378.0",
"npm": "^10.4.0",
"npx": "^10.2.2",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"radix-vue": "^1.8.0",
"shadcn-vue": "latest",
"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.1",
"vue": "^3.4.15",
"vue-draggable-resizable": "^3.0.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@iconify/vue": "^4.1.2",

View File

@@ -2,7 +2,7 @@
<Toaster />
<TooltipProvider :delay-duration=200>
<div class="bg-background text-foreground">
<div v-if="$route.path !== '/login'">
<div v-if="$route.path !== '/'">
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizablePanel id="resize-panel-1" collapsible :default-size="10" :collapsed-size="1" :min-size="7"
:max-size="20" :class="cn(isCollapsed && 'min-w-[50px] transition-all duration-200 ease-in-out')"

View File

@@ -33,17 +33,23 @@ const sendMessage = (uuid, data) => http.post(`/api/conversation/${uuid}/message
const getConversation = (uuid) => http.get(`/api/conversation/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/conversation/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/canned-responses')
const getAssigneeStats = () => http.get('/api/conversations/assignee/stats')
const getAssignedConversations = (page, preDefinedFilter) => http.get(`/api/conversations/assigned?page=${page}&predefinedfilter=${preDefinedFilter}`)
const getUnassignedConversations = (page, preDefinedFilter) => http.get(`/api/conversations/unassigned?page=${page}&predefinedfilter=${preDefinedFilter}`)
const getAllConversations = (page, preDefinedFilter) => http.get(`/api/conversations/all?page=${page}&predefinedfilter=${preDefinedFilter}`)
const uploadAttachment = (data) => http.post('/api/attachment', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const uploadFile = (data) => http.post('/api/attachment', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const getAssigneeStats = () => http.get('/api/conversations/assignee/stats')
const getNewConversationsStats = () => http.get('/api/conversations/new/stats');
export default {
login,
@@ -58,12 +64,14 @@ export default {
getConversationParticipants,
getMessage,
getMessages,
getNewConversationsStats,
sendMessage,
getCurrentUser,
updateAssignee,
updateStatus,
updatePriority,
upsertTags,
uploadFile,
retryMessage,
updateAssigneeLastSeen,
getCannedResponses,

View File

@@ -5,11 +5,11 @@
// App default font-size.
// Default: 16px, 15px looked very wide.
:root {
font-size: 14px;
font-size: 16px;
}
.tab-container-default {
padding: 2rem 2rem;
.page-content {
padding: 1.3rem 1.3rem;
}
.bg-success {
@@ -20,86 +20,71 @@
color: #fff; /* example text color for success */
}
$editorContainerId: 'editor-container';
// Theme.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border:214.3 31.8% 91.4%;
--input:214.3 31.8% 91.4%;
--ring:222.2 84% 4.9%;
--radius: 0.25rem;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
}
.dark {
--background:222.2 84% 4.9%;
--foreground:210 40% 98%;
--card:222.2 84% 4.9%;
--card-foreground:210 40% 98%;
--popover:222.2 84% 4.9%;
--popover-foreground:210 40% 98%;
--primary:210 40% 98%;
--primary-foreground:222.2 47.4% 11.2%;
--secondary:217.2 32.6% 17.5%;
--secondary-foreground:210 40% 98%;
--muted:217.2 32.6% 17.5%;
--muted-foreground:215 20.2% 65.1%;
--accent:217.2 32.6% 17.5%;
--accent-foreground:210 40% 98%;
--destructive:0 62.8% 30.6%;
--destructive-foreground:210 40% 98%;
--border:217.2 32.6% 17.5%;
--input:217.2 32.6% 17.5%;
--ring:212.7 26.8% 83.9;
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
}
}
// @layer base {
// * {
// @apply border-border;
// }
// body {
// @apply bg-background text-foreground;
// }
// }
// charts
@layer base {
:root {

View File

@@ -16,13 +16,7 @@ const currentPage = computed(() => {
</script>
<template>
<div class="md:hidden">
<VPImage alt="Forms" width="1280" height="1214" class="block" :image="{
dark: '/examples/forms-dark.png',
light: '/examples/forms-light.png',
}" />
</div>
<div class="hidden space-y-6 md:block tab-container-default">
<div class="space-y-6 md:block page-content">
<div class="space-y-0.5">
<h2 class="text-2xl font-bold tracking-tight">
Profile settings

View File

@@ -0,0 +1,74 @@
<script setup>
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { ref } from 'vue'
defineProps({
avatarURL: {
type: String,
default: '',
}
})
const emit = defineEmits(['change', 'onAvatarDelete'])
const fileInput = ref(null)
const handleImageUpload = (event) => {
const file = event.target.files[0];
emit('change', {
file: file,
url: file ? URL.createObjectURL(file) : null,
});
}
const triggerFileInput = () => {
fileInput.value.click();
}
const onAvatarDelete = () => {
fileInput.value.value = null;
emit('onAvatarDelete');
}
</script>
<template>
<div class="space-y-2">
<div class="text-3xl font-semibold">
Public avatar
</div>
<p class="text-lg text-muted-foreground">
Change your avatar here
</p>
<div class="flex flex-row gap-5">
<Avatar class="size-20">
<AvatarImage :src="avatarURL" alt="Current avatar" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div class="flex flex-col gap-2">
<FormField v-slot="{ componentField }" name="file">
<FormItem v-auto-animate>
<FormLabel class="font-semi-bold">Upload new avatar</FormLabel>
<FormControl>
<Input ref="fileInput" type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button variant="destructive" class="w-32" @click="onAvatarDelete">
Remove avatar
</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { h, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useUserStore } from '@/stores/user'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import AvatarUploader from '@/components/account/AvatarUploader.vue'
const formSchema = toTypedSchema(z.object({
username: z.string().min(2).max(50),
bio: z.string().optional(),
file: z.any().optional(),
}))
const userStore = useUserStore()
const { isFieldDirty, handleSubmit } = useForm({
validationSchema: formSchema,
})
const avatarImageFile = ref(null)
const avatarImageURL = ref(null)
const onAvatarImageChange = ({ file, url }) => {
avatarImageFile.value = file
avatarImageURL.value = url
}
const onAvatarImageDelete = () => {
avatarImageURL.value = null
avatarImageFile.value = null
}
const onSubmit = handleSubmit((values) => {
toast({
title: 'You submitted the following values:',
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
})
})
</script>
<template>
<form class="w-2/3 space-y-6" @submit="onSubmit">
<AvatarUploader @change="onAvatarImageChange" @onAvatarDelete="onAvatarImageDelete" :avatarURL=userStore.userAvatar />
<FormField v-slot="{ componentField }" name="bio">
<FormItem v-auto-animate>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea placeholder="Tell us a little bit about yourself" class="resize-none" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit">
Update
</Button>
</form>
</template>

View File

@@ -16,22 +16,6 @@ const sidebarNavItems = [
title: 'Profile',
href: '/account/profile',
},
{
title: 'Account',
href: '/account/account',
},
{
title: 'Appearance',
href: '/account/appearance',
},
{
title: 'Notifications',
href: '/account/notifications',
},
{
title: 'Display',
href: '/account/display',
},
]
</script>

View File

@@ -1,65 +1,51 @@
<template>
<div class="h-screen">
<div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md">
<div class="flex justify-between mt-3">
<h3 class="scroll-m-20 text-2xl font-medium flex gap-x-2">
Conversations
</h3>
</div>
<div class="flex justify-between px-3 py-2 border-b-2 rounded-b-lg shadow-md">
<!-- Search -->
<!-- <div class="relative mx-auto my-3">
<Input id="search" type="text" placeholder="Search message or reference number"
class="pl-10 bg-[#F0F2F5]" />
<span class="absolute start-1 inset-y-0 flex items-center justify-center px-2">
<Search class="size-6 text-muted-foreground" />
</span>
</div> -->
<div class="flex justify-between mt-5">
<Tabs v-model:model-value="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full">
Assigned
</TabsTrigger>
<TabsTrigger value="unassigned" class="w-full">
Unassigned
</TabsTrigger>
<TabsTrigger value="all" class="w-full">
All
</TabsTrigger>
</TabsList>
</Tabs>
<div class="space-x-2">
<div class="w-[8rem]">
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all">
All
</SelectItem>
<SelectItem value="status_open">
Open
</SelectItem>
<SelectItem value="status_processing">
Processing
</SelectItem>
<SelectItem value="status_spam">
Spam
</SelectItem>
<SelectItem value="status_resolved">
Resolved
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Tabs v-model:model-value="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full">
Assigned
</TabsTrigger>
<TabsTrigger value="unassigned" class="w-full">
Unassigned
</TabsTrigger>
<TabsTrigger value="all" class="w-full">
All
</TabsTrigger>
</TabsList>
</Tabs>
<div class="space-x-2">
<div class="w-[8rem]">
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all">
All
</SelectItem>
<SelectItem value="status_open">
Open
</SelectItem>
<SelectItem value="status_processing">
Processing
</SelectItem>
<SelectItem value="status_spam">
Spam
</SelectItem>
<SelectItem value="status_resolved">
Resolved
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</div>
<Error :errorMessage="conversationStore.conversations.errorMessage"></Error>

View File

@@ -13,5 +13,5 @@ const dataDonut = [
</script>
<template>
<DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" />
<DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" type="pie" />
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import { LineChart } from '@/components/ui/chart-line'
defineProps({
data: {
type: Array,
required: true,
default: () => []
}
});
</script>
<template>
<LineChart :data="data" index="date" :categories="['new_conversations']" :y-formatter="(tick, i) => {
return typeof tick === 'number'
? `${new Intl.NumberFormat('us').format(tick).toString()}`
: ''
}" />
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { Slot } from "radix-vue";
import { useFormField } from "./useFormField";
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
</script>
<template>
<Slot
:id="formItemId"
:aria-describedby="
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { useFormField } from "./useFormField";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
const { formDescriptionId } = useFormField();
</script>
<template>
<p
:id="formDescriptionId"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { provide } from "vue";
import { useId } from "radix-vue";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
const id = useId();
provide(FORM_ITEM_INJECTION_KEY, id);
</script>
<template>
<div :class="cn('space-y-2', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { useFormField } from "./useFormField";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const { error, formItemId } = useFormField();
</script>
<template>
<Label
:class="cn(error && 'text-destructive', props.class)"
:for="formItemId"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { ErrorMessage } from "vee-validate";
import { toValue } from "vue";
import { useFormField } from "./useFormField";
const { name, formMessageId } = useFormField();
</script>
<template>
<ErrorMessage
:id="formMessageId"
as="p"
:name="toValue(name)"
class="text-[0.8rem] font-medium text-destructive"
/>
</template>

View File

@@ -0,0 +1,11 @@
export {
Form,
Field as FormField,
FieldArray as FormFieldArray,
} from "vee-validate";
export { default as FormItem } from "./FormItem.vue";
export { default as FormLabel } from "./FormLabel.vue";
export { default as FormControl } from "./FormControl.vue";
export { default as FormMessage } from "./FormMessage.vue";
export { default as FormDescription } from "./FormDescription.vue";
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";

View File

@@ -0,0 +1 @@
export const FORM_ITEM_INJECTION_KEY = Symbol();

View File

@@ -0,0 +1,35 @@
import {
FieldContextKey,
useFieldError,
useIsFieldDirty,
useIsFieldTouched,
useIsFieldValid,
} from "vee-validate";
import { inject } from "vue";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
export function useFormField() {
const fieldContext = inject(FieldContextKey);
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
const fieldState = {
valid: useIsFieldValid(),
isDirty: useIsFieldDirty(),
isTouched: useIsFieldTouched(),
error: useFieldError(),
};
if (!fieldContext)
throw new Error("useFormField should be used within <FormField>");
const { name } = fieldContext;
const id = fieldItemContext;
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}

View File

@@ -0,0 +1,29 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<textarea
v-model="modelValue"
:class="
cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue";

View File

@@ -7,7 +7,8 @@ import AccountView from "@/views/AccountView.vue"
const routes = [
{
path: '/',
redirect: '/dashboard'
name: "login",
component: UserLoginView
},
{
path: '/dashboard',
@@ -20,16 +21,18 @@ const routes = [
component: ConversationsView,
props: true,
},
{
path: '/login',
name: "login",
component: UserLoginView
},
{
path: '/account/:page?',
name: 'account',
component: AccountView,
props: true,
beforeEnter: (to, from, next) => {
if (!to.params.page) {
next({ ...to, params: { ...to.params, page: 'profile' } });
} else {
next();
}
},
}
]

View File

@@ -23,14 +23,17 @@ export const useUserStore = defineStore('user', () => {
return userFirstName.value + " " + userLastName.value
})
const getCurrentUser = () => {
return api.getCurrentUser().then((resp) => {
const getCurrentUser = async () => {
try {
const resp = await api.getCurrentUser();
if (resp.data.data) {
userAvatar.value = resp.data.data.avatar_url
userFirstName.value = resp.data.data.first_name
userLastName.value = resp.data.data.last_name
userAvatar.value = resp.data.data.avatar_url;
userFirstName.value = resp.data.data.first_name;
userLastName.value = resp.data.data.last_name;
}
})
} catch (error) {
console.error("Error fetching current user:", error);
}
}
return { userFirstName, userLastName, userAvatar, getFullName, setAvatar, setFirstName, setLastName, getCurrentUser }

View File

@@ -1,9 +1,10 @@
<script setup>
import Account from '@/components/account/Account.vue'
import Profile from '@/components/account/Profile.vue'
</script>
<template>
<Account>
yo
<Profile/>
</Account>
</template>

View File

@@ -6,9 +6,11 @@ import api from '@/api';
import { useToast } from '@/components/ui/toast/use-toast'
import CountCards from '@/components/dashboard/agent/CountCards.vue'
import ConversationsOverTime from '@/components/dashboard/agent/ConversationsOverTime.vue';
const { toast } = useToast()
const counts = ref({})
const newConversationsStats = ref([])
const userStore = useUserStore()
const agentCountCardsLabels = {
total_assigned: "Total Assigned",
@@ -18,10 +20,11 @@ const agentCountCardsLabels = {
};
onMounted(() => {
getStats()
getCardStats()
getnewConversationsStatsStats()
})
const getStats = () => {
const getCardStats = () => {
api.getAssigneeStats().then((resp) => {
counts.value = resp.data.data
}).catch((err) => {
@@ -32,24 +35,48 @@ const getStats = () => {
})
})
}
const getnewConversationsStatsStats = () => {
api.getNewConversationsStats().then((resp) => {
newConversationsStats.value = resp.data.data
}).catch((err) => {
toast({
title: 'Uh oh! Something went wrong.',
description: err.response.data.message,
variant: 'destructive',
})
})
}
function getGreeting () {
const now = new Date();
const hours = now.getHours();
if (hours >= 5 && hours < 12) {
return "Good morning";
} else if (hours >= 12 && hours < 17) {
return "Good afternoon";
} else if (hours >= 17 && hours < 21) {
return "Good evening";
} else {
return "Good night";
}
}
</script>
<template>
<div class="tab-container-default">
<div class="page-content">
<div v-if="userStore.getFullName">
<h4 class="scroll-m-20 text-2xl font-semibold tracking-tight">
<p>Good morning, {{ userStore.getFullName }}</p>
<p>{{ getGreeting() }}, {{ userStore.getFullName }}</p>
<p class="text-xl text-muted-foreground">🌤 {{ format(new Date(), "EEEE, MMMM d HH:mm a") }}</p>
</h4>
</div>
<CountCards :counts="counts" :labels="agentCountCardsLabels" />
<!-- <div class="w-1/2 flex flex-col items-center justify-between">
<LineChart :data="data" index="year" :categories="['Export Growth Rate', 'Import Growth Rate']"
:y-formatter="(tick, i) => {
return typeof tick === 'number'
? `$ ${new Intl.NumberFormat('us').format(tick).toString()}`
: ''
}" />
</div> -->
<!-- <AssignedByStatusDonut /> -->
<div class="flex my-10">
<ConversationsOverTime class="flex-1" :data=newConversationsStats />
</div>
</div>
</template>

View File

@@ -17,5 +17,5 @@ export default defineConfig({
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
},
})

File diff suppressed because it is too large Load Diff

1
go.mod
View File

@@ -45,6 +45,7 @@ require (
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/stuffbin v1.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

2
go.sum
View File

@@ -103,6 +103,8 @@ github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
github.com/knadh/smtppool v1.1.0 h1:J7RB3PpNQW/STnJ6JXlNZLfuNsgJu2VILV+CHWnc/j8=
github.com/knadh/smtppool v1.1.0/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
github.com/knadh/stuffbin v1.3.0 h1:HaVSuYV+KnrlCHl7DrLNyOCgpTU2K8x5Hb+J4Ck3gww=
github.com/knadh/stuffbin v1.3.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View File

@@ -83,6 +83,7 @@ type queries struct {
GetConversationsUUIDs string `query:"get-conversations-uuids"`
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
GetAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"`
GetNewConversationsStats *sqlx.Stmt `query:"get-new-conversations-stats"`
InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
InsertConversation *sqlx.Stmt `query:"insert-conversation"`
UpdateFirstReplyAt *sqlx.Stmt `query:"update-first-reply-at"`
@@ -442,6 +443,18 @@ func (c *Manager) GetAssigneeStats(userID int) (models.ConversationCounts, error
return counts, nil
}
func (c *Manager) GetNewConversationsStats() ([]models.NewConversationsStats, error) {
var stats []models.NewConversationsStats
if err := c.q.GetNewConversationsStats.Select(&stats); err != nil {
if err == sql.ErrNoRows {
return stats, err
}
c.lo.Error("error fetching new conversation stats", "error", err)
return stats, err
}
return stats, nil
}
func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
// Delete tags that have been removed.
if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {

View File

@@ -50,3 +50,8 @@ type ConversationCounts struct {
AwaitingResponseCount int `db:"awaiting_response_count" json:"awaiting_response_count"`
CreatedTodayCount int `db:"created_today_count" json:"created_today_count"`
}
type NewConversationsStats struct {
Date time.Time `db:"date" json:"date"`
NewConversations int `db:"new_conversations" json:"new_conversations"`
}

View File

@@ -205,6 +205,17 @@ WHERE
assigned_user_id = $1;
-- name: get-new-conversations-stats
SELECT
DATE_TRUNC('day', created_at) AS date,
COUNT(*) AS new_conversations
FROM
conversations
GROUP BY
date
ORDER BY
date;
-- name: update-first-reply-at
UPDATE conversations
SET first_reply_at = $2

View File

@@ -0,0 +1,8 @@
-- name: insert-upload
INSERT INTO uploads
(filename)
VALUES($1)
RETURNING id;
-- name: delete-upload
DELETE FROM uploads WHERE id = $1;

80
internal/upload/upload.go Normal file
View File

@@ -0,0 +1,80 @@
package upload
import (
"embed"
"fmt"
"io"
"github.com/abhinavxd/artemis/internal/attachment"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
// Embedded filesystem
//go:embed queries.sql
efs embed.FS
uriUploads = "/upload/%s"
)
// Manager is the uploads manager.
type Manager struct {
Store attachment.Store
lo *logf.Logger
queries queries
appBaseURL string
}
type Opts struct {
Lo *logf.Logger
DB *sqlx.DB
AppBaseURL string
}
// New creates a new attachment manager instance.
func New(store attachment.Store, opt Opts) (*Manager, error) {
var q queries
// Scan SQL file
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
return nil, err
}
return &Manager{
queries: q,
Store: store,
lo: opt.Lo,
appBaseURL: opt.AppBaseURL,
}, nil
}
type queries struct {
Insert *sqlx.Stmt `query:"insert-upload"`
Delete *sqlx.Stmt `query:"delete-upload"`
}
// Upload inserts the attachment details into the db and uploads the attachment.
func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, error) {
var uuid string
if err := m.queries.Insert.QueryRow(fileName).Scan(&uuid); err != nil {
m.lo.Error("error inserting upload", "error", err)
return uuid, err
}
if _, err := m.Store.Put(uuid, contentType, content); err != nil {
m.Delete(uuid)
return uuid, err
}
return m.appBaseURL + fmt.Sprintf(uriUploads, uuid), nil
}
// AttachMessage attaches given attachments to a message.
func (m *Manager) Delete(uuid string) error {
if err := m.Store.Delete(uuid); err != nil {
return err
}
return nil
}