@@ -122,7 +122,7 @@ const loginAction = () => {
password: loginForm.value.password
})
.then(() => {
- router.push({ name: 'dashboard' })
+ router.push({ name: 'conversations' })
})
.catch((error) => {
errorMessage.value = handleHTTPError(error).message
diff --git a/frontend/src/websocket.js b/frontend/src/websocket.js
index 5756379..472a13a 100644
--- a/frontend/src/websocket.js
+++ b/frontend/src/websocket.js
@@ -107,10 +107,11 @@ export function sendMessage (message) {
})
}
-export function subscribeConversationsList (type) {
+export function subscribeConversationsList (type, teamID) {
const message = {
action: CONVERSATION_WS_ACTIONS.SUB_LIST,
type: type,
+ team_id: parseInt(teamID, 10),
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index ca273b4..9aaad59 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -2,6 +2,7 @@ const animate = require("tailwindcss-animate")
/** @type {import('tailwindcss').Config} */
module.exports = {
+ mode: 'jit',
darkMode: ["class"],
safelist: ["dark"],
prefix: "",
@@ -87,8 +88,8 @@ module.exports = {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
- "collapsible-down": "collapsible-down 0.2s ease-in-out",
- "collapsible-up": "collapsible-up 0.2s ease-in-out",
+ 'collapsible-down': 'collapsible-down 0.2s ease-in-out',
+ 'collapsible-up': 'collapsible-up 0.2s ease-in-out',
},
},
},
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 33b24a8..cfa1e47 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -210,6 +210,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2"
integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==
+"@babel/helper-string-parser@^7.25.9":
+ version "7.25.9"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c"
+ integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==
+
"@babel/helper-validator-identifier@^7.24.5":
version "7.24.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62"
@@ -220,6 +225,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db"
integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==
+"@babel/helper-validator-identifier@^7.25.9":
+ version "7.25.9"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7"
+ integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==
+
"@babel/helper-validator-option@^7.24.7":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6"
@@ -263,6 +273,13 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85"
integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==
+"@babel/parser@^7.25.3":
+ version "7.26.3"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.3.tgz#8c51c5db6ddf08134af1ddbacf16aaab48bac234"
+ integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==
+ dependencies:
+ "@babel/types" "^7.26.3"
+
"@babel/plugin-syntax-jsx@^7.24.7":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz#39a1fa4a7e3d3d7f34e2acc6be585b718d30e02d"
@@ -357,6 +374,14 @@
"@babel/helper-validator-identifier" "^7.24.7"
to-fast-properties "^2.0.0"
+"@babel/types@^7.26.3":
+ version "7.26.3"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0"
+ integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==
+ dependencies:
+ "@babel/helper-string-parser" "^7.25.9"
+ "@babel/helper-validator-identifier" "^7.25.9"
+
"@bassist/utils@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@bassist/utils/-/utils-0.4.0.tgz#58eb247b37afc3c405543884731a134f522fc64b"
@@ -817,6 +842,11 @@
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
+"@jridgewell/sourcemap-codec@^1.5.0":
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a"
+ integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
+
"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25":
version "0.3.25"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0"
@@ -1974,6 +2004,17 @@
estree-walker "^2.0.2"
source-map-js "^1.2.0"
+"@vue/compiler-core@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05"
+ integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==
+ dependencies:
+ "@babel/parser" "^7.25.3"
+ "@vue/shared" "3.5.13"
+ entities "^4.5.0"
+ estree-walker "^2.0.2"
+ source-map-js "^1.2.0"
+
"@vue/compiler-dom@3.4.31", "@vue/compiler-dom@^3.4.27":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz#30961ca847f5d6ad18ffa26236c219f61b195f6b"
@@ -1990,6 +2031,14 @@
"@vue/compiler-core" "3.4.37"
"@vue/shared" "3.4.37"
+"@vue/compiler-dom@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58"
+ integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==
+ dependencies:
+ "@vue/compiler-core" "3.5.13"
+ "@vue/shared" "3.5.13"
+
"@vue/compiler-sfc@3.4.37":
version "3.4.37"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.37.tgz#8afaf1a86cb849422c765d4369ba1e85fffe0234"
@@ -2005,6 +2054,21 @@
postcss "^8.4.40"
source-map-js "^1.2.0"
+"@vue/compiler-sfc@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46"
+ integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==
+ dependencies:
+ "@babel/parser" "^7.25.3"
+ "@vue/compiler-core" "3.5.13"
+ "@vue/compiler-dom" "3.5.13"
+ "@vue/compiler-ssr" "3.5.13"
+ "@vue/shared" "3.5.13"
+ estree-walker "^2.0.2"
+ magic-string "^0.30.11"
+ postcss "^8.4.48"
+ source-map-js "^1.2.0"
+
"@vue/compiler-sfc@^3.4", "@vue/compiler-sfc@^3.4.27":
version "3.4.31"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz#cc6bfccda17df8268cc5440842277f61623c591f"
@@ -2036,6 +2100,14 @@
"@vue/compiler-dom" "3.4.37"
"@vue/shared" "3.4.37"
+"@vue/compiler-ssr@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba"
+ integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==
+ dependencies:
+ "@vue/compiler-dom" "3.5.13"
+ "@vue/shared" "3.5.13"
+
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.5.1":
version "6.6.1"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83"
@@ -2068,6 +2140,13 @@
dependencies:
"@vue/shared" "3.4.37"
+"@vue/reactivity@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f"
+ integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==
+ dependencies:
+ "@vue/shared" "3.5.13"
+
"@vue/runtime-core@3.4.37":
version "3.4.37"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.37.tgz#3fe734a666db7842bea4185a13f7697a2102b719"
@@ -2076,6 +2155,14 @@
"@vue/reactivity" "3.4.37"
"@vue/shared" "3.4.37"
+"@vue/runtime-core@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455"
+ integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==
+ dependencies:
+ "@vue/reactivity" "3.5.13"
+ "@vue/shared" "3.5.13"
+
"@vue/runtime-core@^3.4.15":
version "3.4.27"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.4.27.tgz#1b6e1d71e4604ba7442dd25ed22e4a1fc6adbbda"
@@ -2094,6 +2181,16 @@
"@vue/shared" "3.4.37"
csstype "^3.1.3"
+"@vue/runtime-dom@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215"
+ integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==
+ dependencies:
+ "@vue/reactivity" "3.5.13"
+ "@vue/runtime-core" "3.5.13"
+ "@vue/shared" "3.5.13"
+ csstype "^3.1.3"
+
"@vue/server-renderer@3.4.37":
version "3.4.37"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.4.37.tgz#d341425bb5395a3f6ed70572ea5c3edefab92f28"
@@ -2102,6 +2199,14 @@
"@vue/compiler-ssr" "3.4.37"
"@vue/shared" "3.4.37"
+"@vue/server-renderer@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7"
+ integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==
+ dependencies:
+ "@vue/compiler-ssr" "3.5.13"
+ "@vue/shared" "3.5.13"
+
"@vue/shared@3.4.27":
version "3.4.27"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.27.tgz#f05e3cd107d157354bb4ae7a7b5fc9cf73c63b50"
@@ -2117,6 +2222,11 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.4.37.tgz#4f4c08a2e73da512a77b47165cf59ffbc1b5ade8"
integrity sha512-nIh8P2fc3DflG8+5Uw8PT/1i17ccFn0xxN/5oE9RfV5SVnd7G0XEFRwakrnNFE/jlS95fpGXDVG5zDETS26nmg==
+"@vue/shared@3.5.13":
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
+ integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
+
"@vuedx/template-ast-types@0.7.1":
version "0.7.1"
resolved "https://registry.yarnpkg.com/@vuedx/template-ast-types/-/template-ast-types-0.7.1.tgz#ccc75786a4fe1d1910f6c8d93d150d44ee1dabdc"
@@ -2152,15 +2262,15 @@
"@vueuse/shared" "10.9.0"
vue-demi ">=0.14.7"
-"@vueuse/core@^11.2.0":
- version "11.2.0"
- resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-11.2.0.tgz#3fc6c0963051bb154dc4c08061889405e3fc745d"
- integrity sha512-JIUwRcOqOWzcdu1dGlfW04kaJhW3EXnnjJJfLTtddJanymTL7lF1C0+dVVZ/siLfc73mWn+cGP1PE1PKPruRSA==
+"@vueuse/core@^12.2.0":
+ version "12.2.0"
+ resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-12.2.0.tgz#dc708892b2b796f21929dc3aa83d27a6d87a0033"
+ integrity sha512-jksyNu+5EGwggNkRWd6xX+8qBkYbmrwdFQMgCABsz+wq8bKF6w3soPFLB8vocFp3wFIzn0OYkSPM9JP+AFKwsg==
dependencies:
"@types/web-bluetooth" "^0.0.20"
- "@vueuse/metadata" "11.2.0"
- "@vueuse/shared" "11.2.0"
- vue-demi ">=0.14.10"
+ "@vueuse/metadata" "12.2.0"
+ "@vueuse/shared" "12.2.0"
+ vue "^3.5.13"
"@vueuse/metadata@10.11.1":
version "10.11.1"
@@ -2172,10 +2282,10 @@
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.9.0.tgz#769a1a9db65daac15cf98084cbf7819ed3758620"
integrity sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==
-"@vueuse/metadata@11.2.0":
- version "11.2.0"
- resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.2.0.tgz#fd02cbbc7d08cb4592fceea0486559b89ae38643"
- integrity sha512-L0ZmtRmNx+ZW95DmrgD6vn484gSpVeRbgpWevFKXwqqQxW9hnSi2Ppuh2BzMjnbv4aJRiIw8tQatXT9uOB23dQ==
+"@vueuse/metadata@12.2.0":
+ version "12.2.0"
+ resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.2.0.tgz#446ed1e0900d0133a2d63924fa854892f0a2b2a9"
+ integrity sha512-x6zynZtTh1l52m0y8d/EgzpshnMjg8cNZ2KWoncJ62Z5qPSGoc4FUunmMVrrRM/I/5542rTEY89CGftngZvrkQ==
"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0":
version "10.11.1"
@@ -2191,12 +2301,12 @@
dependencies:
vue-demi ">=0.14.7"
-"@vueuse/shared@11.2.0":
- version "11.2.0"
- resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-11.2.0.tgz#7fb2f3cade6b6c00ef97e613f187ee9bdcfb9a3a"
- integrity sha512-VxFjie0EanOudYSgMErxXfq6fo8vhr5ICI+BuE3I9FnX7ePllEsVrRQ7O6Q1TLgApeLuPKcHQxAXpP+KnlrJsg==
+"@vueuse/shared@12.2.0":
+ version "12.2.0"
+ resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.2.0.tgz#495eed20416d4975ea2570d3087db4eebcb92252"
+ integrity sha512-SRr4AZwv/giS+EmyA1ZIzn3/iALjjnWAGaBNmoDTMEob9JwQaevAocuaMDnPAvU7Z35Y5g3CFRusCWgp1gVJ3Q==
dependencies:
- vue-demi ">=0.14.10"
+ vue "^3.5.13"
"@withtypes/mime@^0.1.2":
version "0.1.2"
@@ -5990,6 +6100,13 @@ magic-string@^0.30.10:
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.15"
+magic-string@^0.30.11:
+ version "0.30.17"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
+ integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.0"
+
make-dir@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
@@ -7284,7 +7401,7 @@ picocolors@^1.0.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1"
integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==
-picocolors@^1.1.0:
+picocolors@^1.1.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@@ -7405,6 +7522,15 @@ postcss@^8.4.43:
picocolors "^1.1.0"
source-map-js "^1.2.1"
+postcss@^8.4.48:
+ version "8.4.49"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
+ integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
+ dependencies:
+ nanoid "^3.3.7"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
potpack@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/potpack/-/potpack-1.0.2.tgz#23b99e64eb74f5741ffe7656b5b5c4ddce8dfc14"
@@ -9443,11 +9569,6 @@ vue-demi@>=0.13.0, vue-demi@>=0.14.5, vue-demi@>=0.14.7:
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.7.tgz#8317536b3ef74c5b09f268f7782e70194567d8f2"
integrity sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==
-vue-demi@>=0.14.10:
- version "0.14.10"
- resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
- integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
-
vue-demi@>=0.14.8:
version "0.14.8"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a"
@@ -9497,6 +9618,11 @@ vue-router@^4.2.5:
dependencies:
"@vue/devtools-api" "^6.5.1"
+vue-sonner@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/vue-sonner/-/vue-sonner-1.3.0.tgz#da8ab9be995dfea781d57a6ac52d170d02473d86"
+ integrity sha512-jAodBy4Mri8rQjVZGQAPs4ZYymc1ywPiwfa81qU0fFl+Suk7U8NaOxIDdI1oBGLeQJqRZi/oxNIuhCLqsBmOwg==
+
vue@^3.4.37:
version "3.4.37"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.4.37.tgz#64ce0eeb8de16a29fb74e504777ee8c0c1cf229e"
@@ -9508,6 +9634,17 @@ vue@^3.4.37:
"@vue/server-renderer" "3.4.37"
"@vue/shared" "3.4.37"
+vue@^3.5.13:
+ version "3.5.13"
+ resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
+ integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==
+ dependencies:
+ "@vue/compiler-dom" "3.5.13"
+ "@vue/compiler-sfc" "3.5.13"
+ "@vue/runtime-dom" "3.5.13"
+ "@vue/server-renderer" "3.5.13"
+ "@vue/shared" "3.5.13"
+
w3c-keyname@^2.2.0:
version "2.2.8"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index b79ac40..6cfc621 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -9,12 +9,14 @@ import (
"sync"
"time"
+ amodels "github.com/abhinavxd/artemis/internal/auth/models"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/stringutil"
"github.com/abhinavxd/artemis/internal/user/models"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/redis/go-redis/v9"
"github.com/valyala/fasthttp"
+ "github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
"github.com/zerodha/logf"
sessredisstore "github.com/zerodha/simplesessions/stores/redis/v3"
@@ -195,7 +197,7 @@ func (a *Auth) ExchangeOIDCToken(ctx context.Context, providerID int, code, nonc
}
// SaveSession creates and sets a session (post successful login/auth).
-func (a *Auth) SaveSession(user models.User, r *fastglue.Request) error {
+func (a *Auth) SaveSession(user amodels.User, r *fastglue.Request) error {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -265,7 +267,7 @@ func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
return models.User{
ID: userID,
- Email: email,
+ Email: null.NewString(email, email != ""),
FirstName: firstName,
LastName: lastName,
}, nil
diff --git a/internal/auth/models/models.go b/internal/auth/models/models.go
new file mode 100644
index 0000000..145987c
--- /dev/null
+++ b/internal/auth/models/models.go
@@ -0,0 +1,9 @@
+package models
+
+// User represents an authenticated user.
+type User struct {
+ ID int `json:"id"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email,omitempty"`
+}
diff --git a/internal/authz/authz.go b/internal/authz/authz.go
index ff26b57..6faa170 100644
--- a/internal/authz/authz.go
+++ b/internal/authz/authz.go
@@ -57,8 +57,15 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
}
userID, permObj, permAct := strconv.Itoa(user.ID), parts[0], parts[1]
- if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
- return fmt.Errorf("failed to add policy: %v", err)
+
+ has, err := e.enforcer.HasPolicy(userID, permObj, permAct)
+ if err != nil {
+ return fmt.Errorf("failed to check policy: %v", err)
+ }
+ if !has {
+ if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
+ return fmt.Errorf("failed to add policy: %v", err)
+ }
}
}
return nil
diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go
index 62ddf38..42519ee 100644
--- a/internal/autoassigner/autoassigner.go
+++ b/internal/autoassigner/autoassigner.go
@@ -20,6 +20,10 @@ var (
ErrTeamNotFound = errors.New("team not found")
)
+const (
+ AssignmentTypeRoundRobin = "Round robin"
+)
+
// Engine represents a manager for assigning unassigned conversations
// to team agents in a round-robin pattern.
type Engine struct {
@@ -118,11 +122,11 @@ func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
}
for _, team := range teams {
- if !team.AutoAssignConversations {
+ if team.ConversationAssignmentType != AssignmentTypeRoundRobin {
continue
}
- users, err := e.teamManager.GetTeamMembers(team.Name)
+ users, err := e.teamManager.GetMembers(team.ID)
if err != nil {
return nil, err
}
diff --git a/internal/automation/automation.go b/internal/automation/automation.go
index 1657904..49e3cf9 100644
--- a/internal/automation/automation.go
+++ b/internal/automation/automation.go
@@ -52,6 +52,7 @@ type Engine struct {
q queries
lo *logf.Logger
conversationStore ConversationStore
+ slaStore SLAStore
systemUser umodels.User
taskQueue chan ConversationTask
closed bool
@@ -65,14 +66,19 @@ type Opts struct {
}
type ConversationStore interface {
- GetConversation(uuid string) (cmodels.Conversation, error)
+ GetConversation(id int, uuid string) (cmodels.Conversation, error)
GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error)
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error
- UpdateConversationStatus(uuid string, status []byte, actor umodels.User) error
+ UpdateConversationStatus(uuid string, status, snoozeDur []byte, actor umodels.User) error
UpdateConversationPriority(uuid string, priority []byte, actor umodels.User) error
SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error
SendReply(media []mmodels.Media, senderID int, conversationUUID, content string) error
+ RecordSLASet(conversationUUID string, actor umodels.User) error
+}
+
+type SLAStore interface {
+ ApplySLA(conversationID, slaID int) error
}
type queries struct {
@@ -104,8 +110,9 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
}
// SetConversationStore sets conversations store.
-func (e *Engine) SetConversationStore(store ConversationStore) {
+func (e *Engine) SetConversationStore(store ConversationStore, slaStore SLAStore) {
e.conversationStore = store
+ e.slaStore = slaStore
}
// ReloadRules reloads automation rules from DB.
@@ -246,7 +253,7 @@ func (e *Engine) DeleteRule(id int) error {
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
- conversation, err := e.conversationStore.GetConversation(conversationUUID)
+ conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
@@ -257,7 +264,7 @@ func (e *Engine) handleNewConversation(conversationUUID string) {
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
- conversation, err := e.conversationStore.GetConversation(conversationUUID)
+ conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
diff --git a/internal/automation/evaluator.go b/internal/automation/evaluator.go
index 2520b58..bfbfa2d 100644
--- a/internal/automation/evaluator.go
+++ b/internal/automation/evaluator.go
@@ -16,7 +16,7 @@ import (
// the corresponding actions are executed.
func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels.Conversation) {
for _, rule := range rules {
- e.lo.Debug("evaluating rule for conversation", "rule", rule, "conversation_uuid", conversation.UUID)
+ e.lo.Debug("evaluating rule for conversation", "rule", rule, "conversation_id", conversation.ID)
// At max there can be only 2 groups.
if len(rule.Groups) > 2 {
@@ -101,9 +101,9 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
// Extract the value from the conversation based on the rule's field
switch rule.Field {
case models.ConversationSubject:
- valueToCompare = conversation.Subject
+ valueToCompare = conversation.Subject.String
case models.ConversationContent:
- valueToCompare = conversation.LastMessage
+ valueToCompare = conversation.LastMessage.String
case models.ConversationStatus:
valueToCompare = conversation.Status.String
case models.ConversationPriority:
@@ -232,7 +232,7 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
}
case models.ActionSetStatus:
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
- if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, []byte(action.Action), e.systemUser); err != nil {
+ if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, []byte(action.Action), []byte(""), e.systemUser); err != nil {
return err
}
case models.ActionSendPrivateNote:
@@ -245,6 +245,19 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
if err := e.conversationStore.SendReply([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action); err != nil {
return err
}
+ case models.ActionSetSLA:
+ e.lo.Debug("executing set SLA action", "value", action.Action, "conversation_uuid", conversation.UUID)
+ slaID, err := strconv.Atoi(action.Action)
+ if err != nil {
+ e.lo.Error("error converting string to int", "string", action.Action, "error", err)
+ return err
+ }
+ if err := e.slaStore.ApplySLA(conversation.ID, slaID); err != nil {
+ return err
+ }
+ if err := e.conversationStore.RecordSLASet(conversation.UUID, e.systemUser); err != nil {
+ return err
+ }
default:
return fmt.Errorf("unrecognized rule action: %s", action.Type)
}
diff --git a/internal/automation/models/models.go b/internal/automation/models/models.go
index 02eaa03..ac7cfe8 100644
--- a/internal/automation/models/models.go
+++ b/internal/automation/models/models.go
@@ -14,6 +14,7 @@ const (
ActionSetPriority = "set_priority"
ActionSendPrivateNote = "send_private_note"
ActionReply = "reply"
+ ActionSetSLA = "set_sla"
OperatorAnd = "AND"
OperatorOR = "OR"
@@ -60,6 +61,7 @@ type RuleRecord struct {
Rules json.RawMessage `db:"rules" json:"rules"`
}
+
type Rule struct {
Type string `json:"type" db:"type"`
Events []string `json:"event" db:"event"`
diff --git a/internal/business_hours/business_hours.go b/internal/business_hours/business_hours.go
new file mode 100644
index 0000000..a5ff29e
--- /dev/null
+++ b/internal/business_hours/business_hours.go
@@ -0,0 +1,104 @@
+// Package businesshours handles the management of business hours and holidays.
+package businesshours
+
+import (
+ "embed"
+
+ "github.com/abhinavxd/artemis/internal/business_hours/models"
+ "github.com/abhinavxd/artemis/internal/dbutil"
+ "github.com/abhinavxd/artemis/internal/envelope"
+ "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx/types"
+ "github.com/volatiletech/null/v9"
+ "github.com/zerodha/logf"
+)
+
+var (
+ //go:embed queries.sql
+ efs embed.FS
+)
+
+// Manager manages business hours.
+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 {
+ GetBusinessHours *sqlx.Stmt `query:"get-business-hours"`
+ GetAllBusinessHours *sqlx.Stmt `query:"get-all-business-hours"`
+ InsertBusinessHours *sqlx.Stmt `query:"insert-business-hours"`
+ DeleteBusinessHours *sqlx.Stmt `query:"delete-business-hours"`
+ UpdateBusinessHours *sqlx.Stmt `query:"update-business-hours"`
+ InsertHoliday *sqlx.Stmt `query:"insert-holiday"`
+ DeleteHoliday *sqlx.Stmt `query:"delete-holiday"`
+ GetAllHolidays *sqlx.Stmt `query:"get-all-holidays"`
+}
+
+// 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
+}
+
+// Get retrieves business hours by ID.
+func (m *Manager) Get(id int) (models.BusinessHours, error) {
+ var bh models.BusinessHours
+ if err := m.q.GetBusinessHours.Get(&bh, id); err != nil {
+ m.lo.Error("error fetching business hours", "error", err)
+ return bh, envelope.NewError(envelope.GeneralError, "Error fetching business hours", nil)
+ }
+ return bh, nil
+}
+
+// GetAll retrieves all business hours.
+func (m *Manager) GetAll() ([]models.BusinessHours, error) {
+ var hours = make([]models.BusinessHours, 0)
+ if err := m.q.GetAllBusinessHours.Select(&hours); err != nil {
+ m.lo.Error("error fetching business hours", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, "Error fetching business hours", nil)
+ }
+ return hours, nil
+}
+
+// Create creates new business hours.
+func (m *Manager) Create(name string, description null.String, isAlwaysOpen bool, workingHrs, holidays types.JSONText) error {
+ if _, err := m.q.InsertBusinessHours.Exec(name, description, isAlwaysOpen, workingHrs, holidays); err != nil {
+ m.lo.Error("error inserting business hours", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error creating business hours", nil)
+ }
+ return nil
+}
+
+// Delete deletes business hours by ID.
+func (m *Manager) Delete(id int) error {
+ if _, err := m.q.DeleteBusinessHours.Exec(id); err != nil {
+ m.lo.Error("error deleting business hours", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error deleting business hours", nil)
+ }
+ return nil
+}
+
+// Update updates business hours by ID.
+func (m *Manager) Update(id int, name string, description null.String, isAlwaysOpen bool, workingHrs, holidays types.JSONText) error {
+ if _, err := m.q.UpdateBusinessHours.Exec(id, name, description, isAlwaysOpen, workingHrs, holidays); err != nil {
+ m.lo.Error("error updating business hours", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error updating business hours", nil)
+ }
+ return nil
+}
diff --git a/internal/business_hours/holidays.go b/internal/business_hours/holidays.go
new file mode 100644
index 0000000..c966d18
--- /dev/null
+++ b/internal/business_hours/holidays.go
@@ -0,0 +1,34 @@
+package businesshours
+
+import (
+ "github.com/abhinavxd/artemis/internal/business_hours/models"
+ "github.com/abhinavxd/artemis/internal/envelope"
+)
+
+// GetAllHolidays retrieves all holidays.
+func (m *Manager) GetAllHolidays() ([]models.Holiday, error) {
+ var holidays []models.Holiday
+ if err := m.q.GetAllHolidays.Select(&holidays); err != nil {
+ m.lo.Error("error getting holidays", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, "Error getting holidays", nil)
+ }
+ return holidays, nil
+}
+
+// AddHoliday adds a new holiday.
+func (m *Manager) AddHoliday(name string, date string) error {
+ if _, err := m.q.InsertHoliday.Exec(name, date); err != nil {
+ m.lo.Error("error inserting holiday", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error adding holiday", nil)
+ }
+ return nil
+}
+
+// RemoveHoliday removes a holiday by name.
+func (m *Manager) RemoveHoliday(name string) error {
+ if _, err := m.q.DeleteHoliday.Exec(name); err != nil {
+ m.lo.Error("error deleting holiday", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error removing holiday", nil)
+ }
+ return nil
+}
diff --git a/internal/business_hours/models/models.go b/internal/business_hours/models/models.go
new file mode 100644
index 0000000..9d82325
--- /dev/null
+++ b/internal/business_hours/models/models.go
@@ -0,0 +1,35 @@
+// Package models contains the data models for the businesshours package.
+package models
+
+import (
+ "time"
+
+ "github.com/jmoiron/sqlx/types"
+ "github.com/volatiletech/null/v9"
+)
+
+// BusinessHours represents the business in the database.
+type BusinessHours struct {
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Description null.String `db:"description" json:"description"`
+ IsAlwaysOpen bool `db:"is_always_open" json:"is_always_open"`
+ Holidays types.JSONText `db:"holidays" json:"holidays"`
+ Hours types.JSONText `db:"hours" json:"hours"`
+}
+
+// WorkingHours represents the working hours for a specific day.
+type WorkingHours struct {
+ OpenAllDay bool `json:"open_all_day"`
+ ClosedAllDay bool `json:"closed_all_day"`
+ Open string `json:"open"`
+ Close string `json:"close"`
+}
+
+// Holiday represents a holiday.
+type Holiday struct {
+ Name string `json:"name"`
+ Date string `json:"date"`
+}
diff --git a/internal/business_hours/queries.sql b/internal/business_hours/queries.sql
new file mode 100644
index 0000000..4456549
--- /dev/null
+++ b/internal/business_hours/queries.sql
@@ -0,0 +1,60 @@
+-- name: get-business-hours
+SELECT id,
+ created_at,
+ updated_at,
+ "name",
+ description,
+ is_always_open,
+ hours,
+ holidays
+FROM business_hours
+WHERE id = $1;
+
+-- name: get-all-business-hours
+SELECT id,
+ created_at,
+ updated_at,
+ "name",
+ description
+FROM business_hours
+ORDER BY updated_at DESC;
+
+-- name: insert-business-hours
+INSERT INTO business_hours (
+ "name",
+ description,
+ is_always_open,
+ hours,
+ holidays
+ )
+VALUES ($1, $2, $3, $4, $5);
+
+-- name: delete-business-hours
+DELETE FROM business_hours
+WHERE id = $1;
+
+-- name: update-business-hours
+UPDATE business_hours
+SET "name" = $2,
+ description = $3,
+ is_always_open = $4,
+ hours = $5,
+ holidays = $6,
+ updated_at = NOW()
+WHERE id = $1;
+
+-- name: insert-holiday
+INSERT INTO holidays (
+ "name",
+ date
+ )
+VALUES ($1, $2);
+
+-- name: delete-holiday
+DELETE FROM holidays
+WHERE "name" = $1;
+
+-- name: get-all-holidays
+SELECT "name",
+ date
+FROM holidays;
diff --git a/internal/colorlog/colorlog.go b/internal/colorlog/colorlog.go
new file mode 100644
index 0000000..cd4d99c
--- /dev/null
+++ b/internal/colorlog/colorlog.go
@@ -0,0 +1,19 @@
+// package colorlog provides logging in color.
+package colorlog
+
+import "log"
+
+const (
+ ANSIColourGreen = "\x1b[32m"
+ ANSIColourRed = "\x1b[31m"
+)
+
+// Green logs messages in green color.
+func Green(format string, v ...interface{}) {
+ log.Printf("%s"+format+"\x1b[0m", append([]interface{}{ANSIColourGreen}, v...)...)
+}
+
+// Red logs messages in red color.
+func Red(format string, v ...interface{}) {
+ log.Printf("%s"+format+"\x1b[0m", append([]interface{}{ANSIColourRed}, v...)...)
+}
diff --git a/internal/contact/contact.go b/internal/contact/contact.go
deleted file mode 100644
index a9a3caf..0000000
--- a/internal/contact/contact.go
+++ /dev/null
@@ -1,57 +0,0 @@
-// Package contact provides functionality to manage contacts in the system.
-package contact
-
-import (
- "embed"
-
- "github.com/abhinavxd/artemis/internal/contact/models"
- "github.com/abhinavxd/artemis/internal/dbutil"
- "github.com/jmoiron/sqlx"
- "github.com/zerodha/logf"
-)
-
-var (
- //go:embed queries.sql
- efs embed.FS
-)
-
-// Manager handles the operations related to contacts.
-type Manager struct {
- q queries
- lo *logf.Logger
-}
-
-// Opts holds the options for creating a new Manager.
-type Opts struct {
- DB *sqlx.DB
- Lo *logf.Logger
-}
-
-type queries struct {
- UpsertContact *sqlx.Stmt `query:"upsert-contact"`
-}
-
-// New initializes a new 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
-}
-
-// Upsert inserts or updates a contact and returns the contact ID.
-func (m *Manager) Upsert(con models.Contact) (int, error) {
- var contactID int
- if err := m.q.UpsertContact.QueryRow(con.Source, con.SourceID, con.InboxID,
- con.FirstName, con.LastName, con.Email, con.PhoneNumber, con.AvatarURL).Scan(&contactID); err != nil {
- m.lo.Error("error upserting contact", "error", err)
- return contactID, err
- }
- return contactID, nil
-}
diff --git a/internal/contact/models/models.go b/internal/contact/models/models.go
deleted file mode 100644
index ebcb76a..0000000
--- a/internal/contact/models/models.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package models
-
-import (
- "time"
-
- "github.com/volatiletech/null/v9"
-)
-
-type Contact struct {
- ID int `db:"id" json:"id,omitempty"`
- CreatedAt time.Time `db:"created_at" json:"created_at,omitempty"`
- FirstName string `db:"first_name" json:"first_name"`
- LastName string `db:"last_name" json:"last_name"`
- Email string `db:"email" json:"email"`
- PhoneNumber null.String `db:"phone_number" json:"phone_number"`
- AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
- InboxID int `db:"inbox_id" json:"-"`
- Source string `db:"source" json:"-"`
- SourceID string `db:"source_id" json:"-"`
-}
diff --git a/internal/contact/queries.sql b/internal/contact/queries.sql
deleted file mode 100644
index 010df5d..0000000
--- a/internal/contact/queries.sql
+++ /dev/null
@@ -1,35 +0,0 @@
--- name: upsert-contact
--- Check if contact exists
-WITH existing_contact AS (
- SELECT contact_id
- FROM public.contact_methods
- WHERE source = $1 AND source_id = $2
-)
-
--- Insert contact if it does not exist
-, ins_contact AS (
- INSERT INTO public.contacts (first_name, last_name, email, phone_number, avatar_url)
- SELECT $4, $5, $6, $7, $8
- WHERE NOT EXISTS (SELECT 1 FROM existing_contact)
- RETURNING id
-)
-
--- Determine which contact ID to use
-, final_contact AS (
- SELECT contact_id AS id FROM existing_contact
- UNION ALL
- SELECT id FROM ins_contact
- LIMIT 1
-)
-
--- Insert contact method if it does not exist
-, ins_contact_method AS (
- INSERT INTO public.contact_methods (contact_id, source, source_id, inbox_id)
- SELECT id, $1, $2, $3
- FROM final_contact
- ON CONFLICT DO NOTHING
-)
-
--- Return the final contact ID
-SELECT id AS contact_id FROM final_contact;
-
diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go
index b6ece19..4731cf9 100644
--- a/internal/conversation/conversation.go
+++ b/internal/conversation/conversation.go
@@ -13,14 +13,12 @@ import (
"time"
"github.com/abhinavxd/artemis/internal/automation"
- cmodels "github.com/abhinavxd/artemis/internal/contact/models"
"github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/inbox"
mmodels "github.com/abhinavxd/artemis/internal/media/models"
notifier "github.com/abhinavxd/artemis/internal/notification"
- "github.com/abhinavxd/artemis/internal/stringutil"
tmodels "github.com/abhinavxd/artemis/internal/team/models"
"github.com/abhinavxd/artemis/internal/template"
umodels "github.com/abhinavxd/artemis/internal/user/models"
@@ -33,9 +31,10 @@ import (
var (
//go:embed queries.sql
- efs embed.FS
- ErrConversationNotFound = errors.New("conversation not found")
- ConversationsListAllowedFilters = []string{"status_id", "priority_id", "reference_number"}
+ efs embed.FS
+ ErrConversationNotFound = errors.New("conversation not found")
+ ConversationsListAllowedFilterFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id"}
+ ConversationStatusesFilterFields = []string{"id", "name"}
)
const (
@@ -45,7 +44,6 @@ const (
// Manager handles the operations related to conversations
type Manager struct {
q queries
- contactStore contactStore
inboxStore inboxStore
userStore userStore
teamStore teamStore
@@ -66,15 +64,13 @@ type Manager struct {
}
type teamStore interface {
- GetTeam(int) (tmodels.Team, error)
+ Get(int) (tmodels.Team, error)
+ UserBelongsToTeam(userID, teamID int) (bool, error)
}
type userStore interface {
Get(int) (umodels.User, error)
-}
-
-type contactStore interface {
- Upsert(cmodels.Contact) (int, error)
+ CreateContact(user *umodels.User) error
}
type mediaStore interface {
@@ -102,7 +98,6 @@ func New(
wsHub *ws.Hub,
i18n *i18n.I18n,
notifier *notifier.Service,
- contactStore contactStore,
inboxStore inboxStore,
userStore userStore,
teamStore teamStore,
@@ -121,7 +116,6 @@ func New(
wsHub: wsHub,
i18n: i18n,
notifier: notifier,
- contactStore: contactStore,
inboxStore: inboxStore,
userStore: userStore,
teamStore: teamStore,
@@ -156,6 +150,7 @@ type queries struct {
UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"`
UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"`
UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"`
+ UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"`
UpdateConversationMeta *sqlx.Stmt `query:"update-conversation-meta"`
InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
InsertConversation *sqlx.Stmt `query:"insert-conversation"`
@@ -178,13 +173,12 @@ type queries struct {
}
// CreateConversation creates a new conversation and returns its ID and UUID.
-func (c *Manager) CreateConversation(contactID int, inboxID int, meta []byte) (int, string, error) {
+func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string) (int, string, error) {
var (
- id int
- uuid string
- refNum, _ = stringutil.RandomNumericString(20)
+ id int
+ uuid string
)
- if err := c.q.InsertConversation.QueryRow(refNum, contactID, models.StatusOpen, inboxID, meta).Scan(&id, &uuid); err != nil {
+ if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject).Scan(&id, &uuid); err != nil {
c.lo.Error("error inserting new conversation into the DB", "error", err)
return id, uuid, err
}
@@ -192,14 +186,14 @@ func (c *Manager) CreateConversation(contactID int, inboxID int, meta []byte) (i
}
// GetConversation retrieves a conversation by its UUID.
-func (c *Manager) GetConversation(uuid string) (models.Conversation, error) {
+func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, error) {
var conversation models.Conversation
- if err := c.q.GetConversation.Get(&conversation, uuid); err != nil {
+ if err := c.q.GetConversation.Get(&conversation, id, uuid); err != nil {
if err == sql.ErrNoRows {
- return conversation, envelope.NewError(envelope.InputError, "Conversation not found.", nil)
+ return conversation, envelope.NewError(envelope.InputError, "Conversation not found", nil)
}
c.lo.Error("error fetching conversation", "error", err)
- return conversation, envelope.NewError(envelope.InputError, "Error fetching conversation.", nil)
+ return conversation, envelope.NewError(envelope.GeneralError, "Error fetching conversation", nil)
}
return conversation, nil
}
@@ -208,10 +202,6 @@ func (c *Manager) GetConversation(uuid string) (models.Conversation, error) {
func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
if err := c.q.GetConversationsCreatedAfter.Select(&conversations, time); err != nil {
- if err == sql.ErrNoRows {
- c.lo.Error("conversations not found", "created_after", time)
- return conversations, err
- }
c.lo.Error("error fetching conversation", "error", err)
return conversations, err
}
@@ -222,7 +212,7 @@ func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Convers
func (c *Manager) UpdateConversationAssigneeLastSeen(uuid string) error {
if _, err := c.q.UpdateConversationAssigneeLastSeen.Exec(uuid); err != nil {
c.lo.Error("error updating conversation", "error", err)
- return envelope.NewError(envelope.GeneralError, "Error updating assignee last seen.", nil)
+ return envelope.NewError(envelope.GeneralError, "Error updating assignee last seen", nil)
}
// Broadcast the property update to all subscribers.
@@ -290,51 +280,86 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
}
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
-func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, int, error) {
+func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
return c.GetConversations(0, models.AllConversations, order, orderBy, filters, page, pageSize)
}
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
-func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, int, error) {
+func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
return c.GetConversations(userID, models.AssignedConversations, order, orderBy, filters, page, pageSize)
}
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
-func (c *Manager) GetUnassignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, int, error) {
- return c.GetConversations(userID, models.UnassignedConversations, order, orderBy, filters, page, pageSize)
+func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
+ return c.GetConversations(0, models.UnassignedConversations, order, orderBy, filters, page, pageSize)
+}
+
+// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
+func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
+ return c.GetConversations(teamID, models.TeamUnassignedConversations, order, orderBy, filters, page, pageSize)
+}
+
+func (c *Manager) GetViewConversationsList(userID int, typ, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
+ fmt.Println("Applying view filters", filters)
+ switch typ {
+ case models.AssignedConversations:
+ return c.GetAssignedConversationsList(userID, order, orderBy, filters, page, pageSize)
+ case models.UnassignedConversations:
+ return c.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
+ case models.AllConversations:
+ return c.GetAllConversationsList(order, orderBy, filters, page, pageSize)
+ default:
+ return nil, envelope.NewError(envelope.InputError, fmt.Sprintf("Invalid conversation type: %s", typ), nil)
+ }
}
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
-func (c *Manager) GetConversations(userID int, listType, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, int, error) {
+func (c *Manager) GetConversations(userID int, listType, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
- if orderBy == "" {
- orderBy = "last_message_at"
- }
- query, pageSize, qArgs, err := c.makeConversationsListQuery(userID, c.q.GetConversations, listType, order, orderBy, page, pageSize, filters)
+ // Make the query.
+ query, qArgs, err := c.makeConversationsListQuery(userID, c.q.GetConversations, listType, order, orderBy, page, pageSize, filters)
if err != nil {
- return conversations, pageSize, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
+ c.lo.Error("error making conversations query", "error", err)
+ return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
}
tx, err := c.db.BeginTxx(context.Background(), nil)
defer tx.Rollback()
if err != nil {
c.lo.Error("error preparing get conversations query", "error", err)
- return conversations, pageSize, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
+ return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
}
if err := tx.Select(&conversations, query, qArgs...); err != nil {
c.lo.Error("error fetching conversations", "error", err)
- return conversations, pageSize, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
+ return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.conversations}"), nil)
}
- return conversations, pageSize, nil
+ return conversations, nil
}
-// GetConversationsListUUIDs retrieves the UUIDs of conversations list.
-func (c *Manager) GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error) {
- var ids = make([]string, 0)
+// GetConversationsListUUIDs retrieves the UUIDs of conversations list, used to subscribe to conversations.
+func (c *Manager) GetConversationsListUUIDs(userID, teamID, page, pageSize int, typ string) ([]string, error) {
+ var (
+ ids = make([]string, 0)
+ id = userID
+ )
- query, _, qArgs, err := c.makeConversationsListQuery(userID, c.q.GetConversationsListUUIDs, typ, "", "", page, pageSize, "")
+ if typ == models.TeamUnassignedConversations {
+ id = teamID
+ if teamID == 0 {
+ return ids, fmt.Errorf("team ID is required for team unassigned conversations")
+ }
+ exists, err := c.teamStore.UserBelongsToTeam(userID, teamID)
+ if err != nil {
+ return ids, fmt.Errorf("fetching team members: %w", err)
+ }
+ if !exists {
+ return ids, fmt.Errorf("user does not belong to team")
+ }
+ }
+
+ query, qArgs, err := c.makeConversationsListQuery(id, c.q.GetConversationsListUUIDs, typ, "", "", page, pageSize, "")
if err != nil {
c.lo.Error("error generating conversations query", "error", err)
return ids, err
@@ -368,12 +393,13 @@ func (c *Manager) UpdateConversationMeta(conversationID int, conversationUUID st
return nil
}
-// UpdateConversationLastMessage updates the last message details in the conversation meta.
-func (c *Manager) UpdateConversationLastMessage(conversationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
- return c.UpdateConversationMeta(conversationID, conversationUUID, map[string]string{
- "last_message": lastMessage,
- "last_message_at": lastMessageAt.Format(time.RFC3339),
- })
+// UpdateConversationLastMessage updates the last message details for a conversation.
+func (c *Manager) UpdateConversationLastMessage(convesationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
+ if _, err := c.q.UpdateConversationLastMessage.Exec(convesationID, conversationUUID, lastMessage, lastMessageAt); err != nil {
+ c.lo.Error("error updating conversation last message", "error", err)
+ return err
+ }
+ return nil
}
// UpdateConversationFirstReplyAt updates the first reply timestamp for a conversation.
@@ -397,13 +423,13 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac
return envelope.NewError(envelope.GeneralError, "Error updating assignee", nil)
}
- conversation, err := c.GetConversation(uuid)
+ conversation, err := c.GetConversation(0, uuid)
if err != nil {
return err
}
// Send email to assignee.
- if err := c.SendAssignedConversationEmail([]int{assigneeID}, conversation.Subject, uuid); err != nil {
+ if err := c.SendAssignedConversationEmail([]int{assigneeID}, conversation.Subject.String, uuid); err != nil {
c.lo.Error("error sending assigned conversation email", "error", err)
}
@@ -463,9 +489,26 @@ func (c *Manager) UpdateConversationPriority(uuid string, priority []byte, actor
}
// UpdateConversationStatus updates the status of a conversation.
-func (c *Manager) UpdateConversationStatus(uuid string, status []byte, actor umodels.User) error {
- var statusStr = string(status)
- if _, err := c.q.UpdateConversationStatus.Exec(uuid, status); err != nil {
+func (c *Manager) UpdateConversationStatus(uuid string, status []byte, snoozeDur []byte, actor umodels.User) error {
+ var (
+ statusStr = string(status)
+ snoozeDurS = string(snoozeDur)
+ )
+
+ if statusStr == models.StatusSnoozed && snoozeDurS == "" {
+ return envelope.NewError(envelope.InputError, "Snooze duration is required", nil)
+ }
+
+ snoozeUntil := time.Time{}
+ if statusStr == models.StatusSnoozed {
+ duration, err := time.ParseDuration(snoozeDurS)
+ if err != nil {
+ return envelope.NewError(envelope.InputError, "Invalid snooze duration format", nil)
+ }
+ snoozeUntil = time.Now().Add(duration)
+ }
+
+ if _, err := c.q.UpdateConversationStatus.Exec(uuid, status, snoozeUntil); err != nil {
c.lo.Error("error updating conversation status", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating status", nil)
}
@@ -528,7 +571,7 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
qArgs []interface{}
)
- // TODO: Add date range filter on the UI.
+ // TODO: Add date range filter support.
if userID > 0 {
cond = " AND assigned_user_id = $1"
qArgs = append(qArgs, userID)
@@ -557,8 +600,11 @@ func (t *Manager) UpsertConversationTags(uuid string, tagIDs []int) error {
}
// makeConversationsListQuery prepares a SQL query string for conversations list
-func (c *Manager) makeConversationsListQuery(userID int, baseQuery, listType, order, orderBy string, page, pageSize int, filtersJSON string) (string, int, []interface{}, error) {
+func (c *Manager) makeConversationsListQuery(userID int, baseQuery, listType, order, orderBy string, page, pageSize int, filtersJSON string) (string, []interface{}, error) {
var qArgs []interface{}
+ if orderBy == "" {
+ orderBy = "last_message_at"
+ }
if order == "" {
order = "DESC"
}
@@ -566,49 +612,55 @@ func (c *Manager) makeConversationsListQuery(userID int, baseQuery, listType, or
filtersJSON = "[]"
}
if pageSize > conversationsListMaxPageSize {
- pageSize = conversationsListMaxPageSize
+ return "", nil, fmt.Errorf("page size exceeds maximum limit of %d", conversationsListMaxPageSize)
}
if pageSize < 1 {
- pageSize = 10
+ return "", nil, fmt.Errorf("page size must be greater than 0")
}
if page < 1 {
- page = 1
+ return "", nil, fmt.Errorf("page must be greater than 0")
}
-
- // Set condition based on the list type.
+ // Apply filters based on the type of conversation list.
switch listType {
+ // Conversations assigned to the current user.
case models.AssignedConversations:
baseQuery = fmt.Sprintf(baseQuery, "AND conversations.assigned_user_id = $1")
qArgs = append(qArgs, userID)
+ // Conversations that are unassigned.
case models.UnassignedConversations:
- baseQuery = fmt.Sprintf(baseQuery, "AND conversations.assigned_user_id IS NULL AND conversations.assigned_team_id IN (SELECT team_id FROM team_members WHERE user_id = $1)")
- qArgs = append(qArgs, userID)
+ baseQuery = fmt.Sprintf(baseQuery, "AND conversations.assigned_user_id IS NULL AND conversations.assigned_team_id IS NULL")
+ // All conversations without any specific filter.
case models.AllConversations:
baseQuery = fmt.Sprintf(baseQuery, "")
+ // Conversations assigned to a team but not to a specific user.
+ case models.TeamUnassignedConversations:
+ baseQuery = fmt.Sprintf(baseQuery, "AND conversations.assigned_team_id = $1 AND conversations.assigned_user_id IS NULL")
+ qArgs = append(qArgs, userID)
default:
- return "", pageSize, nil, fmt.Errorf("invalid conversation type %s", listType)
+ return "", nil, fmt.Errorf("unknown conversation type: %s", listType)
}
- query, qArgs, err := dbutil.PaginateAndFilterQuery(baseQuery, qArgs, dbutil.PaginationOptions{
+ // Build the paginated query.
+ query, qArgs, err := dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
}, filtersJSON, dbutil.AllowedFields{
- "conversations": ConversationsListAllowedFilters,
+ "conversations": ConversationsListAllowedFilterFields,
+ "conversation_statuses": ConversationStatusesFilterFields,
})
if err != nil {
c.lo.Error("error preparing query", "error", err)
- return "", pageSize, nil, err
+ return "", nil, err
}
-
- return query, pageSize, qArgs, err
+ return query, qArgs, err
}
-// GetToAddress retrieves the recipient addresses for a conversation.
-func (m *Manager) GetToAddress(conversationID int, channel string) ([]string, error) {
+// GetToAddress retrieves the recipient addresses for a conversation and channel.
+func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
var addr []string
- if err := m.q.GetToAddress.Select(&addr, conversationID, channel); err != nil {
+ if err := m.q.GetToAddress.Select(&addr, conversationID); err != nil {
m.lo.Error("error fetching `to` address for message", "error", err, "conversation_id", conversationID)
return addr, err
}
@@ -627,26 +679,26 @@ func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string,
// SendAssignedConversationEmail sends a email for an assigned conversation to the passed user ids.
func (m *Manager) SendAssignedConversationEmail(userIDs []int, subject, conversationUUID string) error {
- content, err := m.template.Render(template.TmplConversationAssigned,
+ content, subject, err := m.template.RenderNamedTemplate(template.TmplConversationAssigned,
map[string]interface{}{
- "Conversation": map[string]string{
- "Subject": subject,
- "UUID": conversationUUID,
+ "conversation": map[string]string{
+ "subject": subject,
+ "uuid": conversationUUID,
},
})
if err != nil {
- m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "error", err)
- return err
+ m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversationUUID, "error", err)
+ return fmt.Errorf("rendering template: %w", err)
}
nm := notifier.Message{
UserIDs: userIDs,
- Subject: "Conversation Assigned",
+ Subject: subject,
Content: content,
Provider: notifier.ProviderEmail,
}
if err := m.notifier.Send(nm); err != nil {
m.lo.Error("error sending notification message", "error", err)
- return err
+ return fmt.Errorf("sending notification message: %w", err)
}
return nil
}
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 8eb78d8..654091f 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"database/sql"
- "encoding/json"
"errors"
"fmt"
"time"
@@ -28,10 +27,10 @@ const (
SenderTypeUser = "user"
SenderTypeContact = "contact"
- MessageStatusPending = "pending"
- MessageStatusSent = "sent"
- MessageStatusFailed = "failed"
- MessageStatusReceived = "received"
+ MessageStatusPending = "pending"
+ MessageStatusSent = "sent"
+ MessageStatusFailed = "failed"
+ MessageStatusReceived = "received"
ActivityStatusChange = "status_change"
ActivityPriorityChange = "priority_change"
@@ -39,6 +38,7 @@ const (
ActivityAssignedTeamChange = "assigned_team_change"
ActivitySelfAssign = "self_assign"
ActivityTagChange = "tag_change"
+ ActivitySLASet = "sla_set"
ContentTypeText = "text"
ContentTypeHTML = "html"
@@ -175,7 +175,7 @@ func (m *Manager) processOutgoingMessage(message models.Message) {
// Set message properties
message.From = inbox.FromAddress()
- message.To, _ = m.GetToAddress(message.ConversationID, inbox.Channel())
+ message.To, _ = m.GetToAddress(message.ConversationID)
message.InReplyTo, _ = m.GetLatestReceivedMessageSourceID(message.ConversationID)
// Send message
@@ -193,12 +193,12 @@ func (m *Manager) processOutgoingMessage(message models.Message) {
func (m *Manager) RenderContentInTemplate(channel string, message *models.Message) error {
switch channel {
case inbox.ChannelEmail:
- conversation, err := m.GetConversation(message.ConversationUUID)
+ conversation, err := m.GetConversation(0, message.ConversationUUID)
if err != nil {
m.lo.Error("error fetching conversation", "uuid", message.ConversationUUID, "error", err)
return fmt.Errorf("fetching conversation: %w", err)
}
- message.Content, err = m.template.RenderEmail(conversation, message.Content)
+ message.Content, err = m.template.RenderWithBaseTemplate(conversation, message.Content)
if err != nil {
m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
return fmt.Errorf("could not render email content using template: %w", err)
@@ -332,12 +332,12 @@ func (m *Manager) InsertMessage(message *models.Message) error {
return envelope.NewError(envelope.GeneralError, "Error sending message", nil)
}
- // Update conversation meta with the last message details.
- plainTextContent := stringutil.HTML2Text(message.Content)
- m.UpdateConversationLastMessage(0, message.ConversationUUID, plainTextContent, message.CreatedAt)
+ // Update conversation last message details.
+ lastMessage := stringutil.HTML2Text(message.Content)
+ m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.CreatedAt)
// Broadcast new message to all conversation subscribers.
- m.BroadcastNewConversationMessage(message.ConversationUUID, plainTextContent, message.UUID, message.CreatedAt.Format(time.RFC3339), message.Type, message.Private)
+ m.BroadcastNewConversationMessage(message.ConversationUUID, lastMessage, message.UUID, message.CreatedAt.Format(time.RFC3339), message.Type, message.Private)
return nil
}
@@ -358,7 +358,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
// RecordAssigneeTeamChange records an activity for a team assignee change.
func (m *Manager) RecordAssigneeTeamChange(conversationUUID string, teamID int, actor umodels.User) error {
- team, err := m.teamStore.GetTeam(teamID)
+ team, err := m.teamStore.Get(teamID)
if err != nil {
return err
}
@@ -375,6 +375,11 @@ func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umod
return m.InsertConversationActivity(ActivityStatusChange, conversationUUID, status, actor)
}
+// RecordSLASet records an activity for an SLA set.
+func (m *Manager) RecordSLASet(conversationUUID string, actor umodels.User) error {
+ return m.InsertConversationActivity(ActivitySLASet, conversationUUID, "", actor)
+}
+
// InsertConversationActivity inserts an activity message.
func (m *Manager) InsertConversationActivity(activityType, conversationUUID, newValue string, actor umodels.User) error {
content, err := m.getMessageActivityContent(activityType, newValue, actor.FullName())
@@ -425,6 +430,8 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
case ActivityTagChange:
content = fmt.Sprintf("%s added tags %s", actorName, newValue)
+ case ActivitySLASet:
+ content = fmt.Sprintf("%s set an SLA to this conversation", actorName)
default:
return "", fmt.Errorf("invalid activity type %s", activityType)
}
@@ -434,16 +441,16 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
// processIncomingMessage handles the insertion of an incoming message and
// associated contact. It finds or creates the contact, checks for existing
// conversations, and creates a new conversation if necessary. It also
-// inserts the message, uploads any attachments, and evaluates automation rules.
+// inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules.
func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
var err error
- // Find or create contact.
- in.Message.SenderID, err = m.contactStore.Upsert(in.Contact)
- if err != nil {
+ // Find or create contact and set sender ID.
+ if err = m.userStore.CreateContact(&in.Contact); err != nil {
m.lo.Error("error upserting contact", "error", err)
return err
}
+ in.Message.SenderID = in.Contact.ID
// This message already exists?
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
@@ -455,7 +462,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
}
// Find or create new conversation.
- isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Message.SenderID)
+ isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ContactChannelID, in.Contact.ID)
if err != nil {
return err
}
@@ -525,13 +532,13 @@ func (m *Manager) GetConversationByMessageID(id int) (models.Conversation, error
// generateMessagesQuery generates the SQL query for fetching messages in a conversation.
func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, page, pageSize int) (string, int, []interface{}, error) {
if page <= 0 {
- page = 1
+ return "", 0, nil, errors.New("page must be greater than 0")
}
if pageSize > maxMessagesPerPage {
pageSize = maxMessagesPerPage
}
if pageSize <= 0 {
- pageSize = 10
+ return "", 0, nil, errors.New("page size must be greater than 0")
}
// Calculate the offset
@@ -590,7 +597,7 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
}
// findOrCreateConversation finds or creates a conversation for the given message.
-func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int) (bool, error) {
+func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactChannelID, contactID int) (bool, error) {
var (
new bool
err error
@@ -611,18 +618,9 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, cont
// Conversation not found, create one.
if conversationID == 0 {
new = true
-
- // Put subject & last message details in meta.
- plainTextContent := stringutil.HTML2Text(in.Content)
- conversationMeta, err := json.Marshal(map[string]string{
- "subject": in.Subject,
- "last_message": plainTextContent,
- "last_message_at": time.Now().Format(time.RFC3339),
- })
- if err != nil {
- return false, err
- }
- conversationID, conversationUUID, err = m.CreateConversation(contactID, inboxID, conversationMeta)
+ lastMessage := stringutil.HTML2Text(in.Content)
+ lastMessageAt := time.Now()
+ conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject)
if err != nil || conversationID == 0 {
return new, err
}
@@ -630,7 +628,6 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, cont
in.ConversationUUID = conversationUUID
return new, nil
}
-
// Get UUID.
if conversationUUID == "" {
conversationUUID, err = m.GetConversationUUID(conversationID)
diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go
index 732c7e7..831123f 100644
--- a/internal/conversation/models/models.go
+++ b/internal/conversation/models/models.go
@@ -5,46 +5,58 @@ import (
"time"
"github.com/abhinavxd/artemis/internal/attachment"
- cmodels "github.com/abhinavxd/artemis/internal/contact/models"
mmodels "github.com/abhinavxd/artemis/internal/media/models"
+ umodels "github.com/abhinavxd/artemis/internal/user/models"
+ "github.com/lib/pq"
"github.com/volatiletech/null/v9"
)
var (
- StatusOpen = "Open"
+ StatusOpen = "Open"
+ StatusReplied = "Replied"
+ StatusResolved = "Resolved"
+ StatusClosed = "Closed"
+ StatusSnoozed = "Snoozed"
AssigneeTypeTeam = "team"
AssigneeTypeUser = "user"
- AllConversations = "all"
- AssignedConversations = "assigned"
- UnassignedConversations = "unassigned"
+ AllConversations = "all"
+ AssignedConversations = "assigned"
+ UnassignedConversations = "unassigned"
+ TeamUnassignedConversations = "team_unassigned"
)
type Conversation struct {
- ID int `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- UUID string `db:"uuid" json:"uuid"`
- ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
- ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
- ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
- Priority null.String `db:"priority" json:"priority"`
- Status null.String `db:"status" json:"status"`
- FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
- ContactID int `db:"contact_id" json:"contact_id"`
- AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
- AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
- AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
- Subject string `db:"subject" json:"subject"`
- UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
- InboxName string `db:"inbox_name" json:"inbox_name"`
- InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
- Tags null.JSON `db:"tags" json:"tags"`
- LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
- LastMessage string `db:"last_message" json:"last_message"`
- Contact cmodels.Contact `db:"contact" json:"contact"`
- Total int `db:"total" json:"-"`
+ ID int `db:"id" json:"id,omitempty"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ UUID string `db:"uuid" json:"uuid"`
+ ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
+ ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
+ ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
+ Priority null.String `db:"priority" json:"priority"`
+ Status null.String `db:"status" json:"status"`
+ FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
+ ContactID int `db:"contact_id" json:"contact_id"`
+ AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
+ AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
+ AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
+ Subject null.String `db:"subject" json:"subject"`
+ UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
+ InboxName string `db:"inbox_name" json:"inbox_name"`
+ InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
+ Tags null.JSON `db:"tags" json:"tags"`
+ Meta pq.StringArray `db:"meta" json:"meta"`
+ CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
+ LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
+ LastMessage null.String `db:"last_message" json:"last_message"`
+ Contact umodels.User `db:"contact" json:"contact"`
+ FirstReplyDueAt null.Time `db:"first_reply_due_at" json:"first_reply_due_at"`
+ ResolutionDueAt null.Time `db:"resolution_due_at" json:"resolution_due_at"`
+ SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
+ SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
+ Total int `db:"total" json:"-"`
}
type ConversationParticipant struct {
@@ -100,7 +112,7 @@ type Message struct {
// IncomingMessage links a message with the contact information and inbox id.
type IncomingMessage struct {
Message Message
- Contact cmodels.Contact
+ Contact umodels.User
InboxID int
}
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index bb68268..1ffc72b 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -1,26 +1,27 @@
-- name: insert-conversation
INSERT INTO conversations
-(reference_number, contact_id, status_id, inbox_id, meta)
-VALUES($1, $2,
- (SELECT id FROM conversation_statuses WHERE name = $3),
- $4,
- $5)
+(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject)
+VALUES($1, $2, (SELECT id FROM conversation_statuses WHERE name = $3), $4, $5, $6, $7)
RETURNING id, uuid;
-- name: get-conversations
SELECT
COUNT(*) OVER() AS total,
+ conversations.created_at,
conversations.updated_at,
conversations.uuid,
conversations.assignee_last_seen_at,
- contacts.first_name as "contact.first_name",
- contacts.last_name as "contact.last_name",
- contacts.avatar_url as "contact.avatar_url",
+ users.first_name as "contact.first_name",
+ users.last_name as "contact.last_name",
+ users.avatar_url as "contact.avatar_url",
inboxes.channel as inbox_channel,
inboxes.name as inbox_name,
- COALESCE(conversations.meta->>'subject', '') as subject,
- COALESCE(conversations.meta->>'last_message', '') as last_message,
- COALESCE((conversations.meta->>'last_message_at')::timestamp, '1970-01-01 00:00:00'::timestamp) as last_message_at,
+ conversations.sla_policy_id,
+ conversations.first_reply_at,
+ conversations.resolved_at,
+ conversations.subject,
+ conversations.last_message,
+ conversations.last_message_at,
(
SELECT COUNT(*)
FROM conversation_messages m
@@ -29,7 +30,7 @@ SELECT
conversation_statuses.name as status,
conversation_priorities.name as priority
FROM conversations
- JOIN contacts ON conversations.contact_id = contacts.id
+ JOIN users ON conversations.contact_id = users.id
JOIN inboxes ON conversations.inbox_id = inboxes.id
LEFT JOIN conversation_statuses ON conversations.status_id = conversation_statuses.id
LEFT JOIN conversation_priorities ON conversations.priority_id = conversation_priorities.id
@@ -45,6 +46,7 @@ WHERE 1=1 %s
-- name: get-conversation
SELECT
+ c.id,
c.created_at,
c.updated_at,
c.closed_at,
@@ -56,9 +58,11 @@ SELECT
c.first_reply_at,
c.assigned_user_id,
c.assigned_team_id,
- c.meta->>'subject' as subject,
+ c.subject,
c.contact_id,
- COALESCE(c.meta->>'last_message', '') as last_message,
+ c.sla_policy_id,
+ c.last_message,
+ sla.name as sla_policy_name,
(SELECT COALESCE(
(SELECT json_agg(t.name)
FROM tags t
@@ -69,18 +73,19 @@ SELECT
ct.first_name as "contact.first_name",
ct.last_name as "contact.last_name",
ct.email as "contact.email",
- ct.phone_number as "contact.phone_number",
ct.avatar_url as "contact.avatar_url"
FROM conversations c
-JOIN contacts ct ON c.contact_id = ct.id
-LEFT JOIN users u ON u.id = c.assigned_user_id
+JOIN users ct ON c.contact_id = ct.id
+LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
LEFT JOIN teams at ON at.id = c.assigned_team_id
LEFT JOIN conversation_statuses s ON c.status_id = s.id
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
-WHERE c.uuid = $1;
+WHERE ($1 > 0 AND c.id = $1)
+ OR ($2 != '' AND c.uuid = $2::uuid);
-- name: get-conversations-created-after
SELECT
+ c.id,
c.created_at,
c.updated_at,
c.closed_at,
@@ -90,11 +95,10 @@ SELECT
c.uuid,
c.reference_number,
c.first_reply_at,
- ct.first_name as first_name,
- ct.last_name as last_name,
- ct.email as email,
- ct.phone_number as phone_number,
- ct.avatar_url as avatar_url,
+ u.first_name as first_name,
+ u.last_name as last_name,
+ u.email as email,
+ u.avatar_url as avatar_url,
(SELECT COALESCE(
(SELECT json_agg(t.name)
FROM tags t
@@ -103,7 +107,7 @@ SELECT
'[]'::json
)) AS tags
FROM conversations c
-JOIN contacts ct ON c.contact_id = ct.id
+JOIN users u ON c.contact_id = u.id
LEFT JOIN conversation_statuses s ON c.status_id = s.id
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
WHERE c.created_at > $1;
@@ -142,6 +146,12 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2),
ELSE
closed_at
END,
+ snoozed_until = CASE
+ WHEN $2 = 'Snoozed' THEN
+ COALESCE(snoozed_until, $3)
+ ELSE
+ snoozed_until
+ END,
updated_at = now()
WHERE uuid = $1;
@@ -171,6 +181,12 @@ WHERE conversation_id =
SELECT id FROM conversations WHERE uuid = $1
);
+-- name: update-conversation-last-message
+UPDATE conversations SET last_message = $3, last_message_at = $4 WHERE CASE
+ WHEN $1 > 0 THEN id = $1
+ ELSE uuid = $2
+END
+
-- name: insert-conversation-participant
INSERT INTO conversation_participants
(user_id, conversation_id)
@@ -187,9 +203,9 @@ SELECT
ct.first_name,
ct.last_name,
ct.avatar_url,
- COALESCE(c.meta->>'subject', '') as subject,
- COALESCE(c.meta->>'last_message', '') as last_message,
- COALESCE((c.meta->>'last_message_at')::timestamp, '1970-01-01 00:00:00'::timestamp) as last_message_at,
+ c.subject,
+ c.last_message,
+ c.last_message_at,
(
SELECT COUNT(*)
FROM conversation_messages m
@@ -198,7 +214,7 @@ SELECT
s.name as status,
p.name as priority
FROM conversations c
- JOIN contacts ct ON c.contact_id = ct.id
+ JOIN users ct ON c.contact_id = ct.id
JOIN inboxes inb ON c.inbox_id = inb.id
LEFT JOIN conversation_statuses s ON c.status_id = s.id
LEFT JOIN conversation_priorities p ON c.priority_id = p.id
@@ -274,10 +290,10 @@ WHERE conversation_id = (SELECT id FROM conversation_id)
AND tag_id NOT IN (SELECT unnest($2::int[]));
-- name: get-to-address
-SELECT cm.source_id
+SELECT cc.identifier
FROM conversations c
-INNER JOIN contact_methods cm ON cm.contact_id = c.contact_id
-WHERE c.id = $1 AND cm.source = $2;
+INNER JOIN contact_channels cc ON cc.id = c.contact_channel_id
+WHERE c.id = $1;
-- name: get-conversation-uuid-from-message-uuid
SELECT c.uuid AS conversation_uuid
@@ -313,7 +329,7 @@ SELECT
m.source_id,
c.inbox_id,
c.uuid as conversation_uuid,
- COALESCE(c.meta->>'subject', '') as subject
+ c.subject
FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id
WHERE m.status = 'pending'
diff --git a/internal/conversation/status/status.go b/internal/conversation/status/status.go
index 564363b..dfc97ea 100644
--- a/internal/conversation/status/status.go
+++ b/internal/conversation/status/status.go
@@ -86,7 +86,7 @@ func (m *Manager) Delete(id int) error {
if _, err := m.q.DeleteStatus.Exec(id); err != nil {
// Check if the error is a foreign key error.
if dbutil.IsForeignKeyError(err) {
- return envelope.NewError(envelope.InputError, "Cannot delete status as it is in use, Please remove this status from all conversations before deleting", nil)
+ return envelope.NewError(envelope.InputError, "Cannot delete status as it is in use, Please remove this status from all conversations before deleting.", nil)
}
m.lo.Error("error deleting status", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting status", nil)
diff --git a/internal/csat/csat.go b/internal/csat/csat.go
new file mode 100644
index 0000000..96c5e7b
--- /dev/null
+++ b/internal/csat/csat.go
@@ -0,0 +1,92 @@
+// Package csat contains the logic for managing CSAT.
+package csat
+
+import (
+ "database/sql"
+ "embed"
+
+ "github.com/abhinavxd/artemis/internal/csat/models"
+ "github.com/abhinavxd/artemis/internal/dbutil"
+ "github.com/abhinavxd/artemis/internal/envelope"
+ "github.com/jmoiron/sqlx"
+ "github.com/zerodha/logf"
+)
+
+var (
+ //go:embed queries.sql
+ efs embed.FS
+)
+
+// Manager manages CSAT.
+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 {
+ Insert *sqlx.Stmt `query:"insert"`
+ Get *sqlx.Stmt `query:"get"`
+ Update *sqlx.Stmt `query:"update"`
+}
+
+// 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
+}
+
+// Create creates a new CSAT for the given conversation ID.
+func (m *Manager) Create(conversationID, assignedAgentID int) error {
+ _, err := m.q.Insert.Exec(conversationID, assignedAgentID)
+ if err != nil && dbutil.IsUniqueViolationError(err) {
+ m.lo.Error("error creating CSAT", "err", err)
+ return err
+ }
+ return nil
+}
+
+// Get retrieves the CSAT for the given UUID.
+func (m *Manager) Get(uuid string) (models.CSATResponse, error) {
+ var csat models.CSATResponse
+ err := m.q.Get.Get(&csat, uuid)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return csat, envelope.NewError(envelope.InputError, "CSAT not found", nil)
+ }
+ m.lo.Error("error getting CSAT", "err", err)
+ return csat, err
+ }
+ return csat, nil
+}
+
+// UpdateResponse updates the CSAT response for the given csat.
+func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error {
+ csat, err := m.Get(uuid)
+ if err != nil {
+ return err
+ }
+
+ if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() {
+ return envelope.NewError(envelope.InputError, "CSAT already submitted", nil)
+ }
+
+ _, err = m.q.Update.Exec(uuid, score, feedback)
+ if err != nil {
+ m.lo.Error("error updating CSAT", "err", err)
+ return envelope.NewError(envelope.GeneralError, "Error updating CSAT", nil)
+ }
+ return nil
+}
diff --git a/internal/csat/models/models.go b/internal/csat/models/models.go
new file mode 100644
index 0000000..5e75203
--- /dev/null
+++ b/internal/csat/models/models.go
@@ -0,0 +1,21 @@
+// package models has the models for the customer satisfaction survey responses.
+package models
+
+import (
+ "time"
+
+ "github.com/volatiletech/null/v9"
+)
+
+// CSATResponse represents a customer satisfaction survey response.
+type CSATResponse struct {
+ ID int `db:"id"`
+ UUID string `db:"uuid"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+ ConversationID int `db:"conversation_id"`
+ AssignedAgentID int `db:"assigned_agent_id"`
+ Score int `db:"rating"`
+ Feedback null.String `db:"feedback"`
+ ResponseTimestamp null.Time `db:"response_timestamp"`
+}
diff --git a/internal/csat/queries.sql b/internal/csat/queries.sql
new file mode 100644
index 0000000..dabccf2
--- /dev/null
+++ b/internal/csat/queries.sql
@@ -0,0 +1,26 @@
+-- name: insert
+INSERT INTO csat_responses (
+ conversation_id,
+ assigned_agent_id
+ )
+VALUES ($1, $2);
+
+-- name: get
+SELECT id,
+ uuid,
+ created_at,
+ updated_at,
+ conversation_id,
+ assigned_agent_id,
+ rating,
+ feedback,
+ response_timestamp
+FROM csat_responses
+WHERE uuid = $1;
+
+-- name: update
+UPDATE csat_responses
+SET rating = $2,
+ feedback = $3,
+ response_timestamp = NOW()
+WHERE uuid = $1;
diff --git a/internal/dbutil/builder.go b/internal/dbutil/builder.go
index 345523d..a425b8f 100644
--- a/internal/dbutil/builder.go
+++ b/internal/dbutil/builder.go
@@ -7,7 +7,7 @@ import (
"strings"
)
-// PaginationOptions represents the options for paginating a query
+// PaginationOptions represents the options for paginating a query.
type PaginationOptions struct {
Page int
PageSize int
@@ -15,12 +15,13 @@ type PaginationOptions struct {
Order string
}
+// Order directions.
const (
- ASC string = "ASC"
- DESC string = "DESC"
+ ASC = "ASC"
+ DESC = "DESC"
)
-// Filter represents a single filter condition
+// Filter represents a filter to be applied to a query.
type Filter struct {
Model string `json:"model"`
Field string `json:"field"`
@@ -28,107 +29,111 @@ type Filter struct {
Value string `json:"value"`
}
-// AllowedFields represents the allowed fields for each model
+// AllowedFields is a map of model names to a list of allowed fields for that model.
type AllowedFields map[string][]string
-// PaginateAndFilterQuery returns a paginated and filtered query with arguments
-func PaginateAndFilterQuery(baseQuery string, existingArgs []interface{}, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields) (string, []interface{}, error) {
- // Parse filters
+// BuildPaginatedQuery builds a paginated query from the given base query, existing arguments, pagination options, filters JSON, and allowed fields.
+func BuildPaginatedQuery(baseQuery string, existingArgs []interface{}, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields) (string, []interface{}, error) {
+ if opts.Page <= 0 {
+ return "", nil, fmt.Errorf("invalid page number: %d", opts.Page)
+ }
+ if opts.PageSize <= 0 {
+ return "", nil, fmt.Errorf("invalid page size: %d", opts.PageSize)
+ }
+
var filters []Filter
if filtersJSON != "" {
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
- return "", nil, fmt.Errorf("invalid filters JSON: %v", err)
+ return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
}
}
- // Apply filters
- query, args, err := applyFilters(baseQuery, existingArgs, filters, allowedFields)
+ whereClause, filterArgs, err := buildWhereClause(filters, existingArgs, allowedFields)
if err != nil {
return "", nil, err
}
- // Calculate offset
- offset := (opts.Page - 1) * opts.PageSize
+ query := baseQuery
+ args := existingArgs
- // Prepare the order by clause
- var orderByClause string
- if opts.OrderBy != "" {
- orderByClause = fmt.Sprintf("ORDER BY %s", opts.OrderBy)
- if opts.Order != "" {
- switch strings.ToUpper(opts.Order) {
- case ASC, DESC:
- orderByClause += " " + strings.ToUpper(opts.Order)
- default:
- return "", nil, fmt.Errorf("invalid order direction: %s", opts.Order)
- }
- }
+ if whereClause != "" {
+ query += " AND " + whereClause
+ args = append(args, filterArgs...)
}
- // Append pagination to the query
- query = fmt.Sprintf("%s %s LIMIT $%d OFFSET $%d",
- query,
- orderByClause,
- len(args)+1,
- len(args)+2,
- )
+ if opts.OrderBy != "" {
+ order := strings.ToUpper(opts.Order)
+ if order != "" && order != ASC && order != DESC {
+ return "", nil, fmt.Errorf("invalid order direction: %s", opts.Order)
+ }
+ query += fmt.Sprintf(" ORDER BY %s %s NULLS LAST", opts.OrderBy, order)
+ }
- // Append pagination arguments
+ offset := (opts.Page - 1) * opts.PageSize
+ query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
args = append(args, opts.PageSize, offset)
return query, args, nil
}
-// applyFilters applies passed filters to the based query.
-func applyFilters(baseQuery string, existingArgs []interface{}, filters []Filter, allowedFields AllowedFields) (string, []interface{}, error) {
- var conditions []string
- args := make([]interface{}, len(existingArgs))
- copy(args, existingArgs)
+// buildWhereClause builds a WHERE clause from the given filters and returns the WHERE clause and the arguments to be passed to the query.
+func buildWhereClause(filters []Filter, existingArgs []interface{}, allowedFields AllowedFields) (string, []interface{}, error) {
+ conditions := []string{}
+ args := []interface{}{}
+ paramCount := len(existingArgs) + 1
- operatorMap := map[string]string{
- "=": "=",
- "!=": "!=",
- ">": ">",
- "<": "<",
- ">=": ">=",
- "<=": "<=",
- }
-
- // Build the conditions
- for _, filter := range filters {
- modelFields, ok := allowedFields[filter.Model]
+ for _, f := range filters {
+ modelFields, ok := allowedFields[f.Model]
if !ok {
- return "", nil, fmt.Errorf("invalid model in filter: %s", filter.Model)
+ return "", nil, fmt.Errorf("invalid model: %s", f.Model)
}
- if !slices.Contains(modelFields, filter.Field) {
- return "", nil, fmt.Errorf("invalid field in filter: %s for model: %s", filter.Field, filter.Model)
- }
- op, ok := operatorMap[filter.Operator]
- if !ok {
- return "", nil, fmt.Errorf("invalid operator: %s", filter.Operator)
+ if !slices.Contains(modelFields, f.Field) {
+ return "", nil, fmt.Errorf("invalid field: %s for model: %s", f.Field, f.Model)
}
- var condition string
- if op == "!=" {
- // For != operator, include NULL values using OR IS NULL
- condition = fmt.Sprintf("(%s.%s %s $%d OR %s.%s IS NULL)",
- filter.Model, filter.Field, op, len(args)+1,
- filter.Model, filter.Field)
- } else {
- condition = fmt.Sprintf("%s.%s %s $%d",
- filter.Model, filter.Field, op, len(args)+1)
- }
+ field := fmt.Sprintf("%s.%s", f.Model, f.Field)
- conditions = append(conditions, condition)
- args = append(args, filter.Value)
- }
-
- if len(conditions) > 0 {
- if strings.Contains(baseQuery, "WHERE") {
- baseQuery += " AND " + strings.Join(conditions, " AND ")
- } else {
- baseQuery += " WHERE " + strings.Join(conditions, " AND ")
+ switch f.Operator {
+ case "equals":
+ conditions = append(conditions, field+fmt.Sprintf(" = $%d", paramCount))
+ args = append(args, f.Value)
+ paramCount++
+ case "not equals":
+ conditions = append(conditions, field+fmt.Sprintf(" != $%d", paramCount))
+ args = append(args, f.Value)
+ paramCount++
+ case "set":
+ conditions = append(conditions, field+" IS NOT NULL")
+ case "not set":
+ conditions = append(conditions, field+" IS NULL")
+ case "in":
+ var arr []string
+ if err := json.Unmarshal([]byte(f.Value), &arr); err != nil {
+ return "", nil, fmt.Errorf("invalid array format for 'in' operator: %v", err)
+ }
+ placeholders := make([]string, len(arr))
+ for i, v := range arr {
+ placeholders[i] = fmt.Sprintf("$%d", paramCount)
+ args = append(args, v)
+ paramCount++
+ }
+ conditions = append(conditions, field+" IN ("+strings.Join(placeholders, ",")+")")
+ case "between":
+ values := strings.Split(f.Value, ",")
+ if len(values) != 2 {
+ return "", nil, fmt.Errorf("between requires 2 values")
+ }
+ conditions = append(conditions, fmt.Sprintf("%s BETWEEN $%d AND $%d", field, paramCount, paramCount+1))
+ args = append(args, strings.TrimSpace(values[0]), strings.TrimSpace(values[1]))
+ paramCount += 2
+ default:
+ return "", nil, fmt.Errorf("invalid operator: %s", f.Operator)
}
}
- return baseQuery, args, nil
+ if len(conditions) == 0 {
+ return "", nil, nil
+ }
+
+ return strings.Join(conditions, " AND "), args, nil
}
diff --git a/internal/dbutil/dbutil.go b/internal/dbutil/dbutil.go
index 9f7f1fe..7a82572 100644
--- a/internal/dbutil/dbutil.go
+++ b/internal/dbutil/dbutil.go
@@ -12,3 +12,14 @@ func IsForeignKeyError(err error) bool {
}
return false
}
+
+// IsUniqueViolationError checks if the given error is a PostgreSQL unique violation (error code 23505)
+func IsUniqueViolationError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if pqErr, ok := err.(*pq.Error); ok {
+ return pqErr.Code == "23505"
+ }
+ return false
+}
diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go
index 1259547..93cd8b5 100644
--- a/internal/inbox/channel/email/imap.go
+++ b/internal/inbox/channel/email/imap.go
@@ -7,9 +7,10 @@ import (
"time"
"github.com/abhinavxd/artemis/internal/attachment"
- cmodels "github.com/abhinavxd/artemis/internal/contact/models"
"github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/models"
+ "github.com/abhinavxd/artemis/internal/user"
+ umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/jhillyerd/enmime"
@@ -60,6 +61,7 @@ func (e *Email) processMailbox(cfg IMAPConfig) error {
return fmt.Errorf("error selecting mailbox: %w", err)
}
+ // TODO: Set value from config.
since := time.Now().Add(-12 * time.Hour)
searchData, err := e.searchMessages(client, since)
@@ -135,13 +137,17 @@ func (e *Email) processEnvelope(client *imapclient.Client, env *imap.Envelope, s
e.lo.Debug("message does not exist", "message_id", env.MessageID)
- var contact = cmodels.Contact{
- Source: e.Channel(),
- SourceID: env.From[0].Addr(),
- Email: env.From[0].Addr(),
- InboxID: inboxID,
+ // Make contact.
+ firstName, lastName := getContactName(env.From[0])
+ var contact = umodels.User{
+ InboxID: inboxID,
+ FirstName: firstName,
+ LastName: lastName,
+ SourceChannel: null.NewString(e.Channel(), true),
+ SourceChannelID: null.NewString(env.From[0].Addr(), true),
+ Email: null.NewString(env.From[0].Addr(), true),
+ Type: user.UserTypeContact,
}
- contact.FirstName, contact.LastName = getContactName(env.From[0])
incomingMsg := models.IncomingMessage{
Message: models.Message{
@@ -223,7 +229,7 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
Disposition: attachment.DispositionInline,
})
}
- e.lo.Debug("enqueuing message", "message_id", incomingMsg.Message.SourceID, "attachments", len(envelope.Attachments), "inlines", len(envelope.Inlines))
+ e.lo.Debug("enqueuing prepared incoming message for inserting in DB", "message_id", incomingMsg.Message.SourceID.String, "attachments", len(envelope.Attachments), "inlines", len(envelope.Inlines))
if err := e.messageStore.EnqueueIncoming(incomingMsg); err != nil {
return err
}
diff --git a/internal/setting/models/models.go b/internal/setting/models/models.go
index e82d2db..b528158 100644
--- a/internal/setting/models/models.go
+++ b/internal/setting/models/models.go
@@ -5,8 +5,11 @@ type General struct {
Lang string `json:"app.lang"`
MaxFileUploadSize int `json:"app.max_file_upload_size"`
FaviconURL string `json:"app.favicon_url"`
+ LogoURL string `json:"app.logo_url"`
RootURL string `json:"app.root_url"`
AllowedFileUploadExtensions []string `json:"app.allowed_file_upload_extensions"`
+ Timezone string `json:"app.timezone"`
+ BusinessHoursID string `json:"app.business_hours_id"`
}
type EmailNotification struct {
diff --git a/internal/setting/setting.go b/internal/setting/setting.go
index 88c4fd4..1a9c3d5 100644
--- a/internal/setting/setting.go
+++ b/internal/setting/setting.go
@@ -101,7 +101,7 @@ func (m *Manager) GetByPrefix(prefix string) (types.JSONText, error) {
var b types.JSONText
if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil {
m.lo.Error("error fetching settings", "prefix", prefix, "error", err)
- return b, err
+ return b, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil)
}
return b, nil
}
diff --git a/internal/sla/calculator.go b/internal/sla/calculator.go
new file mode 100644
index 0000000..0d22795
--- /dev/null
+++ b/internal/sla/calculator.go
@@ -0,0 +1,127 @@
+package sla
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/abhinavxd/artemis/internal/business_hours/models"
+)
+
+// CalculateDeadline computes the SLA deadline from a start time and SLA duration in minutes
+// considering the provided holidays, working hours, and time zone.
+func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHours models.BusinessHours, timeZone string) (time.Time, error) {
+ if slaMinutes <= 0 {
+ return time.Time{}, fmt.Errorf("SLA duration must be positive")
+ }
+
+ // If business is always open, return the deadline as the start time plus the SLA duration.
+ if businessHours.IsAlwaysOpen {
+ return start.Add(time.Duration(slaMinutes) * time.Minute), nil
+ }
+
+ // Load the specified time zone.
+ loc, err := time.LoadLocation(timeZone)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("invalid time zone %s: %v", timeZone, err)
+ }
+
+ // Convert start time to the specified time zone.
+ currentTime := start.In(loc)
+ remainingMinutes := slaMinutes
+ maxIterations := ((slaMinutes+59)/60)*24 + 1
+
+ // Unmarshal working hours.
+ var workingHours map[string]models.WorkingHours
+ if err := json.Unmarshal(businessHours.Hours, &workingHours); err != nil {
+ return time.Time{}, fmt.Errorf("could not unmarshal working hours: %v", err)
+ }
+
+ // Unmarshal holidays.
+ var holidays = []models.Holiday{}
+ if err := json.Unmarshal(businessHours.Holidays, &holidays); err != nil {
+ return time.Time{}, fmt.Errorf("could not unmarshal holidays: %v", err)
+ }
+
+ // Create a map of holidays.
+ holidaysMap := make(map[string]struct{})
+ for _, holiday := range holidays {
+ holidaysMap[holiday.Date] = struct{}{}
+ }
+
+ iterations := 0
+ for remainingMinutes > 0 {
+ iterations++
+ if iterations > maxIterations {
+ return time.Time{}, fmt.Errorf("sla: exceeded maximum iterations - check configuration")
+ }
+
+ // Skip holidays.
+ dateStr := currentTime.Format(time.DateOnly)
+ if _, isHoliday := holidaysMap[dateStr]; isHoliday {
+ currentTime = nextDay(currentTime, loc)
+ continue
+ }
+
+ // Get working hours for the current day.
+ dayOfWeek := currentTime.Weekday().String()
+ workHours, exists := workingHours[dayOfWeek]
+ if !exists || workHours.ClosedAllDay {
+ currentTime = nextDay(currentTime, loc)
+ continue
+ }
+
+ // OpenAllDay scenario.
+ var startOfWork, endOfWork time.Time
+ if workHours.OpenAllDay {
+ startOfWork = time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, loc)
+ endOfWork = time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 23, 59, 59, 0, loc)
+ } else {
+ var err error
+ startOfWork, err = parseTime(currentTime, workHours.Open, loc)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("invalid open time %s for %s: %v", workHours.Open, dayOfWeek, err)
+ }
+ endOfWork, err = parseTime(currentTime, workHours.Close, loc)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("invalid close time %s for %s: %v", workHours.Close, dayOfWeek, err)
+ }
+ }
+
+ // Adjust to start of work if current time is before it.
+ if currentTime.Before(startOfWork) {
+ currentTime = startOfWork
+ }
+
+ // Move to next day if current time is after end of work.
+ if currentTime.After(endOfWork) {
+ currentTime = nextDay(startOfWork, loc)
+ continue
+ }
+
+ // Deduct minutes worked today from remaining SLA time.
+ workMinutesLeft := int(endOfWork.Sub(currentTime).Minutes())
+ if workMinutesLeft > remainingMinutes {
+ return currentTime.Add(time.Duration(remainingMinutes) * time.Minute), nil
+ }
+
+ remainingMinutes -= workMinutesLeft
+ currentTime = nextDay(startOfWork, loc)
+ }
+
+ return currentTime, nil
+}
+
+// nextDay advances the time to the start of the next day in the specified time zone.
+func nextDay(t time.Time, loc *time.Location) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc)
+}
+
+// parseTime parses a time string in "HH:MM" format and returns a time.Time object for the given date and location.
+func parseTime(date time.Time, timeStr string, loc *time.Location) (time.Time, error) {
+ parsedTime, err := time.ParseInLocation("15:04", timeStr, loc)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return time.Date(date.Year(), date.Month(), date.Day(), parsedTime.Hour(), parsedTime.Minute(), 0, 0, loc), nil
+}
diff --git a/internal/sla/models/models.go b/internal/sla/models/models.go
new file mode 100644
index 0000000..9ae8da2
--- /dev/null
+++ b/internal/sla/models/models.go
@@ -0,0 +1,36 @@
+// package models contains the model definitions for the SLA package.
+package models
+
+import (
+ "time"
+
+ "github.com/volatiletech/null/v9"
+)
+
+// SLAPolicy represents an SLA policy.
+type SLAPolicy struct {
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Description string `db:"description" json:"description"`
+ FirstResponseTime string `db:"first_response_time" json:"first_response_time"`
+ ResolutionTime string `db:"resolution_time" json:"resolution_time"`
+ EveryResponseTime string `db:"every_response_time" json:"every_response_time"`
+}
+
+// ConversationSLA represents an SLA policy applied to a conversation.
+type ConversationSLA struct {
+ ID int `db:"id"`
+ CreatedAt time.Time `db:"created_at"`
+ ConversationID int `db:"conversation_id"`
+ ConversationCreatedAt time.Time `db:"conversation_created_at"`
+ ConversationFirstReplyAt null.Time `db:"conversation_first_reply_at"`
+ ConversationLastMessageAt null.Time `db:"conversation_last_message_at"`
+ ConversationResolvedAt null.Time `db:"conversation_resolved_at"`
+ ConversationAssignedTeamID null.Int `db:"conversation_assigned_team_id"`
+ SLAPolicyID int `db:"sla_policy_id"`
+ SLAType string `db:"sla_type"`
+ DueAt null.Time `db:"due_at"`
+ BreachedAt null.Time `db:"breached_at"`
+}
diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql
new file mode 100644
index 0000000..f5ce117
--- /dev/null
+++ b/internal/sla/queries.sql
@@ -0,0 +1,102 @@
+-- name: get-sla-policy
+SELECT id,
+ created_at,
+ updated_at,
+ "name",
+ description,
+ first_response_time,
+ resolution_time
+FROM sla_policies
+WHERE id = $1;
+
+-- name: get-all-sla-policies
+SELECT id,
+ created_at,
+ updated_at,
+ "name",
+ description,
+ first_response_time,
+ resolution_time
+FROM sla_policies
+ORDER BY updated_at DESC;
+
+-- name: insert-sla-policy
+INSERT INTO sla_policies (
+ "name",
+ description,
+ first_response_time,
+ resolution_time
+ )
+VALUES ($1, $2, $3, $4);
+
+-- name: delete-sla-policy
+DELETE FROM sla_policies
+WHERE id = $1;
+
+-- name: update-sla-policy
+UPDATE sla_policies
+SET "name" = $2,
+ description = $3,
+ first_response_time = $4,
+ resolution_time = $5,
+ updated_at = NOW()
+WHERE id = $1;
+
+-- name: apply-sla-policy
+INSERT INTO applied_slas (
+ status,
+ conversation_id,
+ sla_policy_id
+ )
+VALUES ($1, $2, $3);
+
+-- name: get-unbreached-slas
+SELECT
+ cs.id,
+ cs.sla_policy_id,
+ cs.sla_type,
+ cs.breached_at,
+ cs.due_at,
+ c.created_at as conversation_created_at,
+ c.first_reply_at as conversation_first_reply_at,
+ c.last_message_at as conversation_last_message_at,
+ c.resolved_at as conversation_resolved_at,
+ c.assigned_team_id as conversation_assigned_team_id
+FROM conversation_slas cs
+LEFT JOIN conversations c ON cs.conversation_id = c.id
+WHERE cs.breached_at is NULL AND cs.met_at is NULL;
+
+-- name: update-breached-at
+UPDATE conversation_slas
+SET breached_at = NOW(), updated_at = NOW()
+WHERE id = $1;
+
+-- name: update-due-at
+WITH updated_slas AS (
+ UPDATE conversation_slas
+ SET due_at = $2,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING conversation_id
+)
+-- Also set in conversations table.
+UPDATE conversations
+SET next_sla_deadline_at = $2
+WHERE id IN (SELECT conversation_id FROM updated_slas)
+ AND (next_sla_deadline_at IS NULL OR next_sla_deadline_at > $2);
+
+-- name: update-met-at
+UPDATE conversation_slas
+SET met_at = $2, updated_at = NOW()
+WHERE id = $1;
+
+-- name: insert-conversation-sla
+WITH inserted AS (
+ INSERT INTO conversation_slas (conversation_id, sla_policy_id, sla_type)
+ VALUES ($1, $2, $3)
+ RETURNING conversation_id, sla_policy_id
+)
+UPDATE conversations
+SET sla_policy_id = inserted.sla_policy_id
+FROM inserted
+WHERE conversations.id = inserted.conversation_id;
\ No newline at end of file
diff --git a/internal/sla/sla.go b/internal/sla/sla.go
new file mode 100644
index 0000000..ca1587b
--- /dev/null
+++ b/internal/sla/sla.go
@@ -0,0 +1,348 @@
+// Package sla implements service-level agreement (SLA) calculations for conversations.
+package sla
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "time"
+
+ bmodels "github.com/abhinavxd/artemis/internal/business_hours/models"
+ "github.com/abhinavxd/artemis/internal/dbutil"
+ "github.com/abhinavxd/artemis/internal/envelope"
+ models "github.com/abhinavxd/artemis/internal/sla/models"
+ tmodels "github.com/abhinavxd/artemis/internal/team/models"
+ "github.com/abhinavxd/artemis/internal/workerpool"
+ "github.com/jmoiron/sqlx"
+ "github.com/jmoiron/sqlx/types"
+ "github.com/zerodha/logf"
+)
+
+var (
+ //go:embed queries.sql
+ efs embed.FS
+ slaGracePeriod = 5 * time.Minute
+)
+
+const (
+ SLATypeFirstResponse = "first_response"
+ SLATypeResolution = "resolution"
+ SLATypeEveryResponse = "every_response"
+)
+
+// Manager provides SLA management and calculations.
+type Manager struct {
+ q queries
+ lo *logf.Logger
+ pool *workerpool.Pool
+ teamStore teamStore
+ appSettingsStore appSettingsStore
+ businessHrsStore businessHrsStore
+ opts Opts
+}
+
+// Opts defines options for initializing Manager.
+type Opts struct {
+ DB *sqlx.DB
+ Lo *logf.Logger
+ ScannerInterval time.Duration
+}
+
+type teamStore interface {
+ Get(id int) (tmodels.Team, error)
+}
+
+type appSettingsStore interface {
+ GetByPrefix(prefix string) (types.JSONText, error)
+}
+
+type businessHrsStore interface {
+ Get(id int) (bmodels.BusinessHours, error)
+}
+
+// queries holds prepared SQL statements.
+type queries struct {
+ GetSLA *sqlx.Stmt `query:"get-sla-policy"`
+ GetAllSLA *sqlx.Stmt `query:"get-all-sla-policies"`
+ InsertSLA *sqlx.Stmt `query:"insert-sla-policy"`
+ DeleteSLA *sqlx.Stmt `query:"delete-sla-policy"`
+ UpdateSLA *sqlx.Stmt `query:"update-sla-policy"`
+ GetUnbreachedSLAs *sqlx.Stmt `query:"get-unbreached-slas"`
+ UpdateBreachedAt *sqlx.Stmt `query:"update-breached-at"`
+ UpdateDueAt *sqlx.Stmt `query:"update-due-at"`
+ UpdateMetAt *sqlx.Stmt `query:"update-met-at"`
+ InsertConversationSLA *sqlx.Stmt `query:"insert-conversation-sla"`
+}
+
+// New returns a new Manager.
+func New(opts Opts, pool *workerpool.Pool, teamStore teamStore, appSettingsStore appSettingsStore, businessHrsStore businessHrsStore) (*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,
+ pool: pool,
+ teamStore: teamStore,
+ appSettingsStore: appSettingsStore,
+ businessHrsStore: businessHrsStore,
+ opts: opts,
+ }, nil
+}
+
+// Get retrieves an SLA by its ID.
+func (m *Manager) Get(id int) (models.SLAPolicy, error) {
+ var sla models.SLAPolicy
+ if err := m.q.GetSLA.Get(&sla, id); err != nil {
+ m.lo.Error("error fetching SLA", "error", err)
+ return sla, envelope.NewError(envelope.GeneralError, "Error fetching SLA", nil)
+ }
+ return sla, nil
+}
+
+// GetAll fetches all SLA policies.
+func (m *Manager) GetAll() ([]models.SLAPolicy, error) {
+ var slas = make([]models.SLAPolicy, 0)
+ if err := m.q.GetAllSLA.Select(&slas); err != nil {
+ m.lo.Error("error fetching SLAs", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, "Error fetching SLAs", nil)
+ }
+ return slas, nil
+}
+
+// Create adds a new SLA policy.
+func (m *Manager) Create(name, description, firstResponseDuration, resolutionDuration string) error {
+ if _, err := m.q.InsertSLA.Exec(name, description, firstResponseDuration, resolutionDuration); err != nil {
+ m.lo.Error("error inserting SLA", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error creating SLA", nil)
+ }
+ return nil
+}
+
+// Delete removes an SLA policy by its ID.
+func (m *Manager) Delete(id int) error {
+ if _, err := m.q.DeleteSLA.Exec(id); err != nil {
+ m.lo.Error("error deleting SLA", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error deleting SLA", nil)
+ }
+ return nil
+}
+
+// Update modifies an SLA policy by its ID.
+func (m *Manager) Update(id int, name, description, firstResponseDuration, resolutionDuration string) error {
+ if _, err := m.q.UpdateSLA.Exec(id, name, description, firstResponseDuration, resolutionDuration); err != nil {
+ m.lo.Error("error updating SLA", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error updating SLA", nil)
+ }
+ return nil
+}
+
+// ApplySLA associates an SLA policy with a conversation.
+func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
+ sla, err := m.Get(slaPolicyID)
+ if err != nil {
+ return err
+ }
+ for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
+ if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
+ continue
+ }
+ if t == SLATypeResolution && sla.ResolutionTime == "" {
+ continue
+ }
+ if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
+ m.lo.Error("error applying SLA to conversation", "error", err)
+ return err
+ }
+ }
+ return nil
+}
+
+// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).
+func (m *Manager) Run(ctx context.Context) {
+ ticker := time.NewTicker(m.opts.ScannerInterval)
+ defer ticker.Stop()
+ m.pool.Run()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := m.processUnbreachedSLAs(); err != nil {
+ m.lo.Error("error during SLA periodic check", "error", err)
+ }
+ }
+ }
+}
+
+// Close shuts down the SLA worker pool.
+func (m *Manager) Close() error {
+ m.pool.Close()
+ return nil
+}
+
+// CalculateConversationDeadlines calculates deadlines for SLA policies attached to a conversation.
+func (m *Manager) CalculateConversationDeadlines(conversationCreatedAt time.Time, assignedTeamID, slaPolicyID int) (time.Time, time.Time, error) {
+ var (
+ businessHrsID, timezone = 0, ""
+ firstResponseDeadline, resolutionDeadline = time.Time{}, time.Time{}
+ )
+
+ // Fetch SLA policy.
+ slaPolicy, err := m.Get(slaPolicyID)
+ if err != nil {
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+
+ // First fetch business hours and timezone from assigned team if available.
+ if assignedTeamID != 0 {
+ team, err := m.teamStore.Get(assignedTeamID)
+ if err != nil {
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+ businessHrsID = team.BusinessHoursID
+ timezone = team.Timezone
+ }
+
+ // If not found in team, fetch from app settings.
+ if businessHrsID == 0 || timezone == "" {
+ settingsJ, err := m.appSettingsStore.GetByPrefix("app")
+ if err != nil {
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+
+ var out map[string]interface{}
+ if err := json.Unmarshal([]byte(settingsJ), &out); err != nil {
+ m.lo.Error("error parsing settings", "error", err)
+ return firstResponseDeadline, resolutionDeadline, envelope.NewError(envelope.GeneralError, "Error parsing settings", nil)
+ }
+
+ businessHrsIDStr, _ := out["app.business_hours_id"].(string)
+ businessHrsID, _ = strconv.Atoi(businessHrsIDStr)
+ timezone, _ = out["app.timezone"].(string)
+ }
+
+ // Not set, skip SLA calculation.
+ if businessHrsID == 0 || timezone == "" {
+ m.lo.Warn("default business hours or timezone not set, skipping SLA calculation")
+ return firstResponseDeadline, resolutionDeadline, nil
+ }
+
+ bh, err := m.businessHrsStore.Get(businessHrsID)
+ if err != nil {
+ m.lo.Error("error fetching business hours", "error", err)
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+
+ calculateDeadline := func(durationStr string) (time.Time, error) {
+ if durationStr == "" {
+ return time.Time{}, nil
+ }
+ dur, parseErr := time.ParseDuration(durationStr)
+ if parseErr != nil {
+ return time.Time{}, fmt.Errorf("parsing duration: %v", parseErr)
+ }
+ deadline, err := m.CalculateDeadline(
+ conversationCreatedAt,
+ int(dur.Minutes()),
+ bh,
+ timezone,
+ )
+ if err != nil {
+ return time.Time{}, err
+ }
+ return deadline.Add(slaGracePeriod), nil
+ }
+ firstResponseDeadline, err = calculateDeadline(slaPolicy.FirstResponseTime)
+ if err != nil {
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+ resolutionDeadline, err = calculateDeadline(slaPolicy.ResolutionTime)
+ if err != nil {
+ return firstResponseDeadline, resolutionDeadline, err
+ }
+ return firstResponseDeadline, resolutionDeadline, nil
+}
+
+// processUnbreachedSLAs fetches unbreached SLAs and pushes them to the worker pool for processing.
+func (m *Manager) processUnbreachedSLAs() error {
+ var unbreachedSLAs []models.ConversationSLA
+ if err := m.q.GetUnbreachedSLAs.Select(&unbreachedSLAs); err != nil {
+ m.lo.Error("error fetching unbreached SLAs", "error", err)
+ return err
+ }
+ m.lo.Debug("processing unbreached SLAs", "count", len(unbreachedSLAs))
+ for _, u := range unbreachedSLAs {
+ slaData := u
+ m.pool.Push(func() {
+ if err := m.evaluateSLA(slaData); err != nil {
+ m.lo.Error("error processing SLA", "error", err)
+ }
+ })
+ }
+ return nil
+}
+
+// evaluateSLA checks if an SLA has been breached or met and updates the database accordingly.
+func (m *Manager) evaluateSLA(cSLA models.ConversationSLA) error {
+ var deadline, compareTime time.Time
+
+ firstResponseDeadline, resolutionDeadline, err := m.CalculateConversationDeadlines(cSLA.ConversationCreatedAt, cSLA.ConversationAssignedTeamID.Int, cSLA.SLAPolicyID)
+ if err != nil {
+ return err
+ }
+ switch cSLA.SLAType {
+ case SLATypeFirstResponse:
+ deadline = firstResponseDeadline
+ compareTime = cSLA.ConversationFirstReplyAt.Time
+ case SLATypeResolution:
+ deadline = resolutionDeadline
+ compareTime = cSLA.ConversationResolvedAt.Time
+ default:
+ return fmt.Errorf("unknown SLA type: %s", cSLA.SLAType)
+ }
+
+ if deadline.IsZero() {
+ m.lo.Warn("could not calculate SLA deadline", "conversation_id", cSLA.ConversationID)
+ return nil
+ }
+
+ if !compareTime.IsZero() {
+ if compareTime.After(deadline) {
+ return m.markSLABreached(cSLA.ID)
+ }
+ return m.markSLAMet(cSLA.ID, compareTime)
+ }
+
+ if time.Now().After(deadline) {
+ return m.markSLABreached(cSLA.ID)
+ }
+
+ if _, err := m.q.UpdateDueAt.Exec(cSLA.ID, deadline); err != nil {
+ m.lo.Error("error updating SLA due_at", "error", err)
+ return fmt.Errorf("updating SLA due_at: %v", err)
+ }
+ return nil
+}
+
+// markSLABreached updates the breach time for a conversation SLA.
+func (m *Manager) markSLABreached(id int) error {
+ if _, err := m.q.UpdateBreachedAt.Exec(id); err != nil {
+ m.lo.Error("error updating SLA breach time", "error", err)
+ return fmt.Errorf("updating SLA breach time: %v", err)
+ }
+ return nil
+}
+
+// markSLAMet updates the met time for a conversation SLA.
+func (m *Manager) markSLAMet(id int, t time.Time) error {
+ if _, err := m.q.UpdateMetAt.Exec(id, t); err != nil {
+ m.lo.Error("error updating SLA met time", "error", err)
+ return fmt.Errorf("updating SLA met time: %v", err)
+ }
+ return nil
+}
diff --git a/internal/team/models/models.go b/internal/team/models/models.go
index 21e0d60..c3f9d9e 100644
--- a/internal/team/models/models.go
+++ b/internal/team/models/models.go
@@ -5,14 +5,19 @@ import (
"encoding/json"
"fmt"
"time"
+
+ "github.com/volatiletech/null/v9"
)
type Team struct {
- ID int `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- Name string `db:"name" json:"name"`
- AutoAssignConversations bool `db:"auto_assign_conversations" json:"auto_assign_conversations"`
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Emoji null.String `db:"emoji" json:"emoji"`
+ Name string `db:"name" json:"name"`
+ ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type"`
+ Timezone string `db:"timezone" json:"timezone"`
+ BusinessHoursID int `db:"business_hours_id" json:"business_hours_id,omitempty"`
}
type Teams []Team
@@ -48,9 +53,9 @@ func (t Teams) Names() []string {
// IDs returns a slice of all team IDs in the Teams slice.
func (t Teams) IDs() []int {
- ids := make([]int, len(t))
- for i, team := range t {
- ids[i] = team.ID
- }
- return ids
-}
\ No newline at end of file
+ ids := make([]int, len(t))
+ for i, team := range t {
+ ids[i] = team.ID
+ }
+ return ids
+}
diff --git a/internal/team/queries.sql b/internal/team/queries.sql
index becbf82..e4c53c8 100644
--- a/internal/team/queries.sql
+++ b/internal/team/queries.sql
@@ -1,24 +1,27 @@
-- name: get-teams
-SELECT id, created_at, updated_at, name, auto_assign_conversations, disabled from teams order by updated_at desc;
+SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, disabled from teams order by updated_at desc;
+
+-- name: get-user-teams
+SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, disabled from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
-- name: get-teams-compact
-SELECT id, name from teams order by name;
+SELECT id, name, emoji from teams order by name;
-- name: get-team
-SELECT id, name, auto_assign_conversations from teams where id = $1;
+SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id from teams where id = $1;
-- name: get-team-members
SELECT u.id, t.id as team_id
FROM users u
JOIN team_members tm ON tm.user_id = u.id
JOIN teams t ON t.id = tm.team_id
-WHERE t.name = $1;
+WHERE t.id = $1;
-- name: insert-team
-INSERT INTO teams (name) values($1);
+INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id) VALUES ($1, $2, $3, $4) RETURNING id;
-- name: update-team
-UPDATE teams set name = $2, auto_assign_conversations = $3, updated_at = now() where id = $1;
+UPDATE teams set name = $2, timezone = $3, conversation_assignment_type = $4, business_hours_id = $5 where id = $1;
-- name: upsert-user-teams
WITH delete_old_teams AS (
@@ -36,4 +39,7 @@ insert_new_teams AS (
SELECT 1;
-- name: delete-team
-DELETE FROM teams where id = $1;
\ No newline at end of file
+DELETE FROM teams where id = $1;
+
+-- name: user-belongs-to-team
+SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2);
\ No newline at end of file
diff --git a/internal/team/team.go b/internal/team/team.go
index df8c483..4d7452c 100644
--- a/internal/team/team.go
+++ b/internal/team/team.go
@@ -35,14 +35,16 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
- GetTeams *sqlx.Stmt `query:"get-teams"`
- GetTeamsCompact *sqlx.Stmt `query:"get-teams-compact"`
- GetTeam *sqlx.Stmt `query:"get-team"`
- InsertTeam *sqlx.Stmt `query:"insert-team"`
- UpdateTeam *sqlx.Stmt `query:"update-team"`
- DeleteTeam *sqlx.Stmt `query:"delete-team"`
- GetTeamMembers *sqlx.Stmt `query:"get-team-members"`
- UpsertUserTeams *sqlx.Stmt `query:"upsert-user-teams"`
+ GetTeams *sqlx.Stmt `query:"get-teams"`
+ GetUserTeams *sqlx.Stmt `query:"get-user-teams"`
+ GetTeamsCompact *sqlx.Stmt `query:"get-teams-compact"`
+ GetTeam *sqlx.Stmt `query:"get-team"`
+ InsertTeam *sqlx.Stmt `query:"insert-team"`
+ UpdateTeam *sqlx.Stmt `query:"update-team"`
+ DeleteTeam *sqlx.Stmt `query:"delete-team"`
+ GetTeamMembers *sqlx.Stmt `query:"get-team-members"`
+ UpsertUserTeams *sqlx.Stmt `query:"upsert-user-teams"`
+ UserBelongsToTeam *sqlx.Stmt `query:"user-belongs-to-team"`
}
// New creates and returns a new instance of the Manager.
@@ -66,7 +68,7 @@ func (u *Manager) GetAll() ([]models.Team, error) {
if errors.Is(err, sql.ErrNoRows) {
return teams, nil
}
- u.lo.Error("error fetching teams from db", "error", err)
+ u.lo.Error("error fetching teams", "error", err)
return teams, envelope.NewError(envelope.GeneralError, "Error fetching teams", nil)
}
return teams, nil
@@ -79,14 +81,14 @@ func (u *Manager) GetAllCompact() ([]models.Team, error) {
if errors.Is(err, sql.ErrNoRows) {
return teams, nil
}
- u.lo.Error("error fetching teams from db", "error", err)
+ u.lo.Error("error fetching teams", "error", err)
return teams, envelope.NewError(envelope.GeneralError, "Error fetching teams", nil)
}
return teams, nil
}
-// GetTeam retrieves a team by ID.
-func (u *Manager) GetTeam(id int) (models.Team, error) {
+// Get retrieves a team by ID.
+func (u *Manager) Get(id int) (models.Team, error) {
var team models.Team
if err := u.q.GetTeam.Get(&team, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -99,26 +101,26 @@ func (u *Manager) GetTeam(id int) (models.Team, error) {
return team, nil
}
-// CreateTeam creates a new team.
-func (u *Manager) CreateTeam(t models.Team) error {
- if _, err := u.q.InsertTeam.Exec(t.Name); err != nil {
+// Create creates a new team.
+func (u *Manager) Create(name, timezone, conversationAssignmentType string, businessHrsID int) error {
+ if _, err := u.q.InsertTeam.Exec(name, timezone, conversationAssignmentType, businessHrsID); err != nil {
u.lo.Error("error inserting team", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating team", nil)
}
return nil
}
-// UpdateTeam updates an existing team.
-func (u *Manager) UpdateTeam(id int, t models.Team) error {
- if _, err := u.q.UpdateTeam.Exec(id, t.Name, t.AutoAssignConversations); err != nil {
+// Update updates an existing team.
+func (u *Manager) Update(id int, name, timezone, conversationAssignmentType string, businessHrsID int) error {
+ if _, err := u.q.UpdateTeam.Exec(id, name, timezone, conversationAssignmentType, businessHrsID); err != nil {
u.lo.Error("error updating team", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating team", nil)
}
return nil
}
-// DeleteTeam deletes a team by ID also deletes all the team members and unassigns all the conversations belonging to the team.
-func (u *Manager) DeleteTeam(id int) error {
+// Delete deletes a team by ID also deletes all the team members and unassigns all the conversations belonging to the team.
+func (u *Manager) Delete(id int) error {
if _, err := u.q.DeleteTeam.Exec(id); err != nil {
u.lo.Error("error deleting team", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting team", nil)
@@ -126,17 +128,17 @@ func (u *Manager) DeleteTeam(id int) error {
return nil
}
-// GetTeamMembers retrieves members of a team by team name.
-func (u *Manager) GetTeamMembers(name string) ([]umodels.User, error) {
- var users []umodels.User
- if err := u.q.GetTeamMembers.Select(&users, name); err != nil {
+// GetUserTeams retrieves teams of a user by user ID.
+func (u *Manager) GetUserTeams(userID int) ([]models.Team, error) {
+ var teams = make([]models.Team, 0)
+ if err := u.q.GetUserTeams.Select(&teams, userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
- return users, nil
+ return teams, nil
}
- u.lo.Error("error fetching team members from db", "team_name", name, "error", err)
- return users, fmt.Errorf("fetching team members: %w", err)
+ u.lo.Error("error fetching teams", "user_id", userID, "error", err)
+ return teams, envelope.NewError(envelope.GeneralError, "Error fetching teams", nil)
}
- return users, nil
+ return teams, nil
}
// UpsertUserTeams updates/inserts exists user teams
@@ -147,3 +149,26 @@ func (u *Manager) UpsertUserTeams(id int, teamNames []string) error {
}
return nil
}
+
+// UserBelongsToTeam returns true if the user belongs to the team.
+func (u *Manager) UserBelongsToTeam(teamID, userID int) (bool, error) {
+ var exists bool
+ if err := u.q.UserBelongsToTeam.Get(&exists, teamID, userID); err != nil {
+ u.lo.Error("error checking if user belongs to team", "team_id", teamID, "user_id", userID, "error", err)
+ return false, envelope.NewError(envelope.GeneralError, "Error checking if user belongs to team", nil)
+ }
+ return exists, nil
+}
+
+// GetMembers retrieves members of a team by team ID.
+func (u *Manager) GetMembers(id int) ([]umodels.User, error) {
+ var users []umodels.User
+ if err := u.q.GetTeamMembers.Select(&users, id); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return users, nil
+ }
+ u.lo.Error("error fetching team members", "team_id", id, "error", err)
+ return users, fmt.Errorf("fetching team members: %w", err)
+ }
+ return users, nil
+}
diff --git a/internal/template/models/models.go b/internal/template/models/models.go
index 1d13846..61b000a 100644
--- a/internal/template/models/models.go
+++ b/internal/template/models/models.go
@@ -1,12 +1,19 @@
package models
-import "time"
+import (
+ "time"
+
+ "github.com/volatiletech/null/v9"
+)
type Template struct {
- ID int `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- Name string `db:"name" json:"name"`
- Body string `db:"body" json:"body"`
- IsDefault bool `db:"is_default" json:"is_default"`
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Type string `db:"type" json:"type"`
+ Name string `db:"name" json:"name"`
+ Subject null.String `db:"subject" json:"subject"`
+ Body string `db:"body" json:"body"`
+ IsDefault bool `db:"is_default" json:"is_default"`
+ IsBuiltIn bool `db:"is_builtin" json:"is_builtin"`
}
diff --git a/internal/template/queries.sql b/internal/template/queries.sql
index 269db78..f45b031 100644
--- a/internal/template/queries.sql
+++ b/internal/template/queries.sql
@@ -1,25 +1,38 @@
-- name: insert
-INSERT INTO templates ("name", body, is_default)
-VALUES ($1, $2, $3);
+INSERT INTO templates ("name", body, is_default, subject, type)
+VALUES ($1, $2, $3, $4, $5);
-- name: update
WITH u AS (
- UPDATE templates
- SET name = $2, body = $3, is_default = $4, updated_at = NOW()
+ UPDATE templates
+ SET
+ name = CASE WHEN $6::template_type = 'email_outgoing' THEN $2 ELSE name END,
+ body = $3,
+ is_default = $4,
+ subject = $5,
+ type = $6::template_type,
+ updated_at = NOW()
WHERE id = $1
+ RETURNING id
)
-UPDATE templates
-SET is_default = FALSE
+UPDATE templates
+SET is_default = FALSE
WHERE id != $1 AND $4 = TRUE;
-- name: get-default
-SELECT id, name, body FROM templates WHERE is_default = TRUE;
+SELECT id, name, body, subject FROM templates WHERE is_default is TRUE;
-- name: get-all
-SELECT * FROM templates ORDER BY updated_at DESC;
+SELECT id, name, is_default, updated_at FROM templates WHERE type = $1 ORDER BY updated_at DESC;
-- name: get-template
-SELECT * FROM templates WHERE id = $1;
+SELECT id, name, body, subject, is_default, type FROM templates WHERE id = $1;
-- name: delete
DELETE FROM templates WHERE id = $1;
+
+-- name: get-by-name
+SELECT id, name, body, subject, is_default, type FROM templates WHERE name = $1;
+
+-- name: is-builtin
+SELECT EXISTS(SELECT 1 FROM templates WHERE id = $1 AND is_builtin is TRUE);
\ No newline at end of file
diff --git a/internal/template/render.go b/internal/template/render.go
index a548ad8..1059eec 100644
--- a/internal/template/render.go
+++ b/internal/template/render.go
@@ -6,65 +6,147 @@ import (
"strings"
"text/template"
- "github.com/abhinavxd/artemis/internal/conversation/models"
+ "github.com/valyala/fasthttp"
)
const (
- TmplConversationAssigned = "conversation-assigned"
- TmplResetPassword = "reset-password"
- TmplWelcome = "welcome"
- TmplBase = "base"
- TmplContent = "content"
+ // Built-in templates names stored in the database.
+ TmplConversationAssigned = "Conversation assigned"
+
+ // Built-in templates in fetched from memory stored in `static` directory.
+ TmplResetPassword = "reset-password"
+ TmplWelcome = "welcome"
+
+ // Template names for rendering.
+ TmplBase = "base"
+ TmplContent = "content"
)
-// RenderEmail renders a message into the base email template. It combines the base template
-// with message content and conversation data to produce the final email HTML.
-// The base template must contain a {{ template "content" . }} block where the message
-// will be inserted.
-func (m *Manager) RenderEmail(conversation models.Conversation, messageContent string) (string, error) {
- tmpl, err := m.GetDefault()
+// RenderWithBaseTemplate merges the given content with the default outgoing email template, if available.
+func (m *Manager) RenderWithBaseTemplate(data any, content string) (string, error) {
+ defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
if err != nil {
if err == ErrTemplateNotFound {
- m.lo.Warn("default email template not found, using message content as is")
- return messageContent, nil
+ m.lo.Warn("default outgoing email template not found, rendering content without base")
+ return content, nil
}
return "", err
}
- // Parse base template first
- baseTmpl, err := template.New(TmplBase).Parse(tmpl.Body)
+ baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body)
if err != nil {
return "", fmt.Errorf("parsing base template: %w", err)
}
- // Parse message template
- msgTmpl, err := template.New(TmplContent).Parse(messageContent)
+ contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content)
if err != nil {
- return "", fmt.Errorf("parsing message template: %w", err)
+ return "", fmt.Errorf("parsing content template: %w", err)
}
- // Add message template to base
- baseTmpl, err = baseTmpl.AddParseTree(TmplContent, msgTmpl.Tree)
+ baseTemplate, err = baseTemplate.AddParseTree(TmplContent, contentTemplate.Tree)
if err != nil {
- return "", fmt.Errorf("adding content template to base: %w", err)
+ return "", fmt.Errorf("adding content template: %w", err)
}
- var out strings.Builder
- if err := baseTmpl.ExecuteTemplate(&out, TmplBase, conversation); err != nil {
- return "", fmt.Errorf("executing template: %w", err)
- }
-
- return out.String(), nil
-}
-
-// Render executes a named template with the provided data.
-func (m *Manager) Render(name string, data interface{}) (string, error) {
- var rendered bytes.Buffer
-
- err := m.tpls.ExecuteTemplate(&rendered, name, data)
- if err != nil {
- return "", fmt.Errorf("executing template %s: %w", name, err)
+ var rendered strings.Builder
+ if err := baseTemplate.ExecuteTemplate(&rendered, TmplBase, data); err != nil {
+ return "", fmt.Errorf("executing base template: %w", err)
}
return rendered.String(), nil
}
+
+// RenderNamedTemplate fetches a named template from DB and merges it with the default base template, if available.
+func (m *Manager) RenderNamedTemplate(name string, data any) (string, string, error) {
+ tmpl, err := m.getByName(name)
+ if err != nil {
+ if err == ErrTemplateNotFound {
+ return "", "", fmt.Errorf("template %s not found", name)
+ }
+ return "", "", err
+ }
+
+ executeContentTemplate := func(tmplBody string) (string, error) {
+ var sb strings.Builder
+ t, err := template.New(name).Funcs(m.funcMap).Parse(tmplBody)
+ if err != nil {
+ return "", fmt.Errorf("parsing content template: %w", err)
+ }
+ if err := t.Execute(&sb, data); err != nil {
+ return "", fmt.Errorf("executing content template: %w", err)
+ }
+ return sb.String(), nil
+ }
+
+ executeSubjectTemplate := func(subject string) (string, error) {
+ var sb strings.Builder
+ subjectTmpl, err := template.New("subject").Funcs(m.funcMap).Parse(subject)
+ if err != nil {
+ return "", fmt.Errorf("parsing subject template: %w", err)
+ }
+ if err := subjectTmpl.Execute(&sb, data); err != nil {
+ return "", fmt.Errorf("executing subject template: %w", err)
+ }
+ return sb.String(), nil
+ }
+
+ defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
+ if err != nil {
+ if err == ErrTemplateNotFound {
+ m.lo.Warn("default outgoing email template not found, rendering content without base")
+ content, err := executeContentTemplate(tmpl.Body)
+ if err != nil {
+ return "", "", err
+ }
+ subject, err := executeSubjectTemplate(tmpl.Subject.String)
+ if err != nil {
+ return "", "", err
+ }
+ return content, subject, nil
+ }
+ return "", "", err
+ }
+
+ baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body)
+ if err != nil {
+ return "", "", fmt.Errorf("parsing base template: %w", err)
+ }
+
+ contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(tmpl.Body)
+ if err != nil {
+ return "", "", fmt.Errorf("parsing content template: %w", err)
+ }
+
+ baseTemplate, err = baseTemplate.AddParseTree(TmplContent, contentTemplate.Tree)
+ if err != nil {
+ return "", "", fmt.Errorf("adding content template: %w", err)
+ }
+
+ var rendered strings.Builder
+ if err := baseTemplate.ExecuteTemplate(&rendered, TmplBase, data); err != nil {
+ return "", "", fmt.Errorf("executing base template: %w", err)
+ }
+
+ subject, err := executeSubjectTemplate(tmpl.Subject.String)
+ if err != nil {
+ return "", "", err
+ }
+
+ return rendered.String(), subject, nil
+}
+
+// RenderTemplate executes a named in-memory template with the provided data.
+func (m *Manager) RenderTemplate(name string, data interface{}) (string, error) {
+ var buf bytes.Buffer
+ if err := m.tpls.ExecuteTemplate(&buf, name, data); err != nil {
+ return "", fmt.Errorf("executing in-memory template %q: %w", name, err)
+ }
+ return buf.String(), nil
+}
+
+// RenderWebPage renders a template to the http.ResponseWriter with data.
+func (t *Manager) RenderWebPage(ctx *fasthttp.RequestCtx, tmplFile string, data map[string]interface{}) error {
+ ctx.SetContentType("text/html; charset=utf-8")
+ ctx.SetStatusCode(fasthttp.StatusOK)
+ return t.webTpls.ExecuteTemplate(ctx, tmplFile, data)
+}
diff --git a/internal/template/template.go b/internal/template/template.go
index d119732..b4f51bc 100644
--- a/internal/template/template.go
+++ b/internal/template/template.go
@@ -16,16 +16,19 @@ import (
var (
//go:embed queries.sql
- efs embed.FS
-
- ErrTemplateNotFound = errors.New("template not found")
+ efs embed.FS
+ ErrTemplateNotFound = errors.New("template not found")
+ TypeEmailOutgoing = "email_outgoing"
+ TypeEmailNotification = "email_notification"
)
// Manager handles template-related operations.
type Manager struct {
- tpls *template.Template
- q queries
- lo *logf.Logger
+ tpls *template.Template
+ webTpls *template.Template
+ funcMap template.FuncMap
+ q queries
+ lo *logf.Logger
}
// queries contains prepared SQL queries.
@@ -36,20 +39,22 @@ type queries struct {
GetDefaultTemplate *sqlx.Stmt `query:"get-default"`
GetAllTemplates *sqlx.Stmt `query:"get-all"`
GetTemplate *sqlx.Stmt `query:"get-template"`
+ GetByName *sqlx.Stmt `query:"get-by-name"`
+ IsBuiltIn *sqlx.Stmt `query:"is-builtin"`
}
// New creates and returns a new instance of the Manager.
-func New(lo *logf.Logger, db *sqlx.DB, tpls *template.Template) (*Manager, error) {
+func New(lo *logf.Logger, db *sqlx.DB, webTpls *template.Template, tpls *template.Template, funcMap template.FuncMap) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
return nil, err
}
- return &Manager{tpls, q, lo}, nil
+ return &Manager{tpls, webTpls, funcMap, q, lo}, nil
}
// Update updates a new template with the given name, and body.
func (m *Manager) Update(id int, t models.Template) error {
- if _, err := m.q.UpdateTemplate.Exec(id, t.Name, t.Body, t.IsDefault); err != nil {
+ if _, err := m.q.UpdateTemplate.Exec(id, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
m.lo.Error("error updating template", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating template", nil)
}
@@ -58,15 +63,68 @@ func (m *Manager) Update(id int, t models.Template) error {
// Create creates a template.
func (m *Manager) Create(t models.Template) error {
- if _, err := m.q.InsertTemplate.Exec(t.Name, t.Body, t.IsDefault); err != nil {
+ if t.IsDefault {
+ t.Type = TypeEmailOutgoing
+ }
+ if _, err := m.q.InsertTemplate.Exec(t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
m.lo.Error("error inserting template", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating template", nil)
}
return nil
}
-// GetDefault retrieves the default template.
-func (m *Manager) GetDefault() (models.Template, error) {
+// GetAll returns all templates by type.
+func (m *Manager) GetAll(typ string) ([]models.Template, error) {
+ var templates = make([]models.Template, 0)
+ if err := m.q.GetAllTemplates.Select(&templates, typ); err != nil {
+ m.lo.Error("error fetching templates", "error", err)
+ return templates, envelope.NewError(envelope.GeneralError, "Error fetching templates", nil)
+ }
+ return templates, nil
+}
+
+// Get returns a template by id.
+func (m *Manager) Get(id int) (models.Template, error) {
+ var templates = models.Template{}
+ if err := m.q.GetTemplate.Get(&templates, id); err != nil {
+ if err == sql.ErrNoRows {
+ return templates, envelope.NewError(envelope.NotFoundError, "Template not found", nil)
+ }
+ m.lo.Error("error fetching template", "error", err)
+ return templates, envelope.NewError(envelope.GeneralError, "Error fetching template", nil)
+ }
+ return templates, nil
+}
+
+// Delete deletes a template by id.
+func (m *Manager) Delete(id int) error {
+ // Do not allow deletion of built-in templates.
+ isBuiltIn, err := m.isBuiltIn(id)
+ if err != nil {
+ return err
+ }
+ if isBuiltIn {
+ return envelope.NewError(envelope.PermissionError, "Cannot delete built-in templates", nil)
+ }
+ if _, err := m.q.DeleteTemplate.Exec(id); err != nil {
+ m.lo.Error("error deleting template", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error deleting template", nil)
+ }
+ return nil
+}
+
+// isBuiltIn returns true if the template is built-in.
+func (m *Manager) isBuiltIn(id int) (bool, error) {
+ var isBuiltIn bool
+ if err := m.q.IsBuiltIn.Get(&isBuiltIn, id); err != nil {
+ m.lo.Error("error fetching template", "error", err)
+ return false, envelope.NewError(envelope.GeneralError, "Error fetching template", nil)
+ }
+ return isBuiltIn, nil
+}
+
+// getDefaultOutgoingEmailTemplate returns the default outgoing email template.
+func (m *Manager) getDefaultOutgoingEmailTemplate() (models.Template, error) {
var template models.Template
if err := m.q.GetDefaultTemplate.Get(&template); err != nil {
if err == sql.ErrNoRows {
@@ -78,31 +136,15 @@ func (m *Manager) GetDefault() (models.Template, error) {
return template, nil
}
-// GetAll returns all templates.
-func (m *Manager) GetAll() ([]models.Template, error) {
- var templates = make([]models.Template, 0)
- if err := m.q.GetAllTemplates.Select(&templates); err != nil {
- m.lo.Error("error fetching templates", "error", err)
- return templates, envelope.NewError(envelope.GeneralError, "Error fetching templates", nil)
+// getByName returns a template by name.
+func (m *Manager) getByName(name string) (models.Template, error) {
+ var template models.Template
+ if err := m.q.GetByName.Get(&template, name); err != nil {
+ if err == sql.ErrNoRows {
+ return template, ErrTemplateNotFound
+ }
+ m.lo.Error("error fetching default template", "error", err)
+ return template, envelope.NewError(envelope.GeneralError, "Error fetching template", nil)
}
- return templates, nil
-}
-
-// Get returns a template by id.
-func (m *Manager) Get(id int) (models.Template, error) {
- var templates = models.Template{}
- if err := m.q.GetTemplate.Get(&templates, id); err != nil {
- m.lo.Error("error fetching template", "error", err)
- return templates, envelope.NewError(envelope.GeneralError, "Error fetching template", nil)
- }
- return templates, nil
-}
-
-// Delete deletes a template by id.
-func (m *Manager) Delete(id int) error {
- if _, err := m.q.DeleteTemplate.Exec(id); err != nil {
- m.lo.Error("error deleting template", "error", err)
- return envelope.NewError(envelope.GeneralError, "Error deleting template", nil)
- }
- return nil
+ return template, nil
}
diff --git a/internal/user/contact.go b/internal/user/contact.go
new file mode 100644
index 0000000..204bb1f
--- /dev/null
+++ b/internal/user/contact.go
@@ -0,0 +1,37 @@
+package user
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+
+ "github.com/abhinavxd/artemis/internal/user/models"
+ "github.com/lib/pq"
+ "github.com/volatiletech/null/v9"
+)
+
+// CreateContact creates a new contact user.
+func (u *Manager) CreateContact(user *models.User) error {
+ password, err := u.generatePassword()
+ if err != nil {
+ u.lo.Error("generating password", "error", err)
+ return fmt.Errorf("generating password: %w", err)
+ }
+
+ // Normalize email address.
+ user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid)
+
+ // Check if user already exists.
+ if err := u.q.GetUserByEmail.QueryRow(user.Email).Scan(&user.ID); err == nil {
+ return nil
+ } else if err != sql.ErrNoRows {
+ u.lo.Error("checking if user exists", "error", err)
+ return fmt.Errorf("checking if user exists: %w", err)
+ }
+
+ if err := u.q.InsertContact.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles), user.InboxID, user.SourceChannelID).Scan(&user.ID, &user.ContactChannelID); err != nil {
+ u.lo.Error("creating user", "error", err)
+ return fmt.Errorf("creating user: %w", err)
+ }
+ return nil
+}
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index dd6a819..a6cb8ac 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -14,15 +14,22 @@ type User struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
- Email string `db:"email" json:"email,omitempty"`
+ Email null.String `db:"email" json:"email,omitempty"`
+ Type string `db:"type" json:"type"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Disabled bool `db:"disabled" json:"disabled"`
Password string `db:"password" json:"-"`
- NewPassword string `db:"-" json:"new_password,omitempty"`
- SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
+ Meta pq.StringArray `db:"meta" json:"meta"`
+ CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
Teams tmodels.Teams `db:"teams" json:"teams"`
+ ContactChannelID int `db:"contact_channel_id"`
+ NewPassword string `db:"-" json:"new_password,omitempty"`
+ SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
+ InboxID int
+ SourceChannel null.String
+ SourceChannelID null.String
}
func (u *User) FullName() string {
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index a35e391..de2503e 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -1,24 +1,30 @@
-- name: get-users
SELECT u.id, u.updated_at, u.first_name, u.last_name, u.email, u.disabled
-FROM users u where u.email != 'System' and u.deleted_at is null
+FROM users u
+WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent'
ORDER BY u.updated_at DESC;
-- name: soft-delete-user
-UPDATE users SET deleted_at = now() WHERE id = $1;
+UPDATE users
+SET deleted_at = now()
+WHERE id = $1 AND type = 'agent';
-- name: get-users-compact
SELECT u.id, u.first_name, u.last_name, u.disabled
-FROM users u where u.email != 'System' and u.deleted_at is null
+FROM users u
+WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent'
ORDER BY u.updated_at DESC;
-- name: get-email
-SELECT email from users where id = $1 and deleted_at is null;
+SELECT email
+FROM users
+WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
-- name: get-user-by-email
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, r.permissions
FROM users u
JOIN roles r ON r.name = ANY(u.roles)
-WHERE u.email = $1 and u.deleted_at is null;
+WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent';
-- name: get-user
SELECT
@@ -46,16 +52,12 @@ SELECT
FROM
users u
WHERE
- u.id = $1 and u.deleted_at is null;
+ u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent';
-- name: set-user-password
-update users set password = $1, updated_at = now() where id = $2;
-
--- name: create-user
-INSERT INTO users
-(email, first_name, last_name, "password", avatar_url, roles)
-VALUES($1, $2, $3, $4, $5, $6)
-RETURNING id;
+UPDATE users
+SET password = $1, updated_at = now()
+WHERE id = $2 AND type = 'agent';
-- name: update-user
UPDATE users
@@ -66,24 +68,45 @@ SET first_name = COALESCE($2, first_name),
avatar_url = COALESCE($6, avatar_url),
password = COALESCE($7, password),
updated_at = now()
-WHERE id = $1
+WHERE id = $1 AND type = 'agent';
-- name: update-avatar
UPDATE users
-SET avatar_url = $2, updated_at = now() WHERE id = $1;
+SET avatar_url = $2, updated_at = now()
+WHERE id = $1 AND type = 'agent';
-- name: get-permissions
SELECT unnest(r.permissions)
FROM users u
JOIN roles r ON r.name = ANY(u.roles)
-WHERE u.id = $1
+WHERE u.id = $1 AND u.type = 'agent';
-- name: set-reset-password-token
UPDATE users
SET reset_password_token = $2, reset_password_token_expiry = now() + interval '1 day'
-WHERE id = $1
+WHERE id = $1 AND type = 'agent';
-- name: reset-password
UPDATE users
SET password = $1, reset_password_token = NULL, reset_password_token_expiry = NULL
-WHERE reset_password_token = $2 AND reset_password_token_expiry > now()
\ No newline at end of file
+WHERE reset_password_token = $2 AND reset_password_token_expiry > now() AND type = 'agent';
+
+-- name: insert-agent
+INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, roles)
+VALUES ($1, 'agent', $2, $3, $4, $5, $6)
+ON CONFLICT (email) WHERE email IS NOT NULL
+DO UPDATE SET updated_at = now()
+RETURNING id;
+
+-- name: insert-contact
+WITH contact AS (
+ INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, roles)
+ VALUES ($1, 'contact', $2, $3, $4, $5, $6)
+ ON CONFLICT (email)
+ DO UPDATE SET updated_at = now()
+ RETURNING id
+)
+INSERT INTO contact_channels (contact_id, inbox_id, identifier)
+VALUES ((SELECT id FROM contact), $7, $8)
+ON CONFLICT (contact_id, inbox_id) DO UPDATE SET updated_at = now()
+RETURNING contact_id, id;
\ No newline at end of file
diff --git a/internal/user/user.go b/internal/user/user.go
index 6998694..2173eed 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -2,10 +2,12 @@
package user
import (
+ "context"
"database/sql"
"embed"
"errors"
"fmt"
+ "os"
"regexp"
"strings"
@@ -34,6 +36,8 @@ const (
SystemUserEmail = "System"
MinSystemUserPasswordLen = 8
MaxSystemUserPasswordLen = 50
+ UserTypeAgent = "agent"
+ UserTypeContact = "contact"
)
// Manager handles user-related operations.
@@ -51,7 +55,6 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
- CreateUser *sqlx.Stmt `query:"create-user"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetUserCompact *sqlx.Stmt `query:"get-users-compact"`
GetUser *sqlx.Stmt `query:"get-user"`
@@ -64,16 +67,16 @@ type queries struct {
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
ResetPassword *sqlx.Stmt `query:"reset-password"`
+ InsertAgent *sqlx.Stmt `query:"insert-agent"`
+ InsertContact *sqlx.Stmt `query:"insert-contact"`
}
// New creates and returns a new instance of the Manager.
func New(i18n *i18n.I18n, 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,
@@ -81,8 +84,8 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
}, nil
}
-// Login authenticates a user by email and password.
-func (u *Manager) Login(email string, password []byte) (models.User, error) {
+// VerifyPassword authenticates a user by email and password.
+func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
@@ -128,15 +131,15 @@ func (u *Manager) GetAllCompact() ([]models.User, error) {
return users, nil
}
-// Create creates a new user.
-func (u *Manager) Create(user *models.User) error {
+// CreateAgent creates a new agent user.
+func (u *Manager) CreateAgent(user *models.User) error {
password, err := u.generatePassword()
if err != nil {
u.lo.Error("error generating password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating user", nil)
}
- user.Email = strings.ToLower(strings.TrimSpace(user.Email))
- if err := u.q.CreateUser.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles)).Scan(&user.ID); err != nil {
+ user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid)
+ if err := u.q.InsertAgent.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles)).Scan(&user.ID); err != nil {
u.lo.Error("error creating user", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating user", nil)
}
@@ -210,7 +213,7 @@ func (u *Manager) Update(id int, user models.User) error {
return nil
}
-// Delete deletes a user by ID.
+// SoftDelete soft deletes a user.
func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user.
systemUser, err := u.GetSystemUser()
@@ -273,6 +276,22 @@ func (u *Manager) ResetPassword(token, password string) error {
return nil
}
+// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
+func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
+ // Prompt for password and get hashed password
+ hashedPassword, err := promptAndHashPassword(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Update system user's password in the database.
+ if err := updateSystemUserPassword(db, hashedPassword); err != nil {
+ return fmt.Errorf("error updating system user password: %v", err)
+ }
+ fmt.Println("System user password updated successfully.")
+ return nil
+}
+
// GetPermissions retrieves the permissions of a user by ID.
func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string
@@ -293,11 +312,11 @@ func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
// generatePassword generates a random password and returns its bcrypt hash.
func (u *Manager) generatePassword() ([]byte, error) {
- password, _ := stringutil.RandomAlNumString(16)
+ password, _ := stringutil.RandomAlNumString(70)
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
u.lo.Error("error generating bcrypt password", "error", err)
- return nil, fmt.Errorf("error generating bcrypt password: %w", err)
+ return nil, fmt.Errorf("generating bcrypt password: %w", err)
}
return bytes, nil
}
@@ -313,17 +332,17 @@ func (u *Manager) isStrongPassword(password string) bool {
}
// CreateSystemUser inserts a default system user into the users table with the prompted password.
-func CreateSystemUser(db *sqlx.DB) error {
+func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
// Prompt for password and get hashed password
- hashedPassword, err := promptAndHashPassword()
+ hashedPassword, err := promptAndHashPassword(ctx)
if err != nil {
return err
}
_, err = db.Exec(`
- INSERT INTO users (email, first_name, last_name, password, roles)
- VALUES ($1, $2, $3, $4, $5)`,
- SystemUserEmail, "System", "", hashedPassword, pq.StringArray{"Admin"})
+ INSERT INTO users (email, type, first_name, last_name, password, roles)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ SystemUserEmail, UserTypeAgent, "System", "", hashedPassword, pq.StringArray{"Admin"})
if err != nil {
return fmt.Errorf("failed to create system user: %v", err)
}
@@ -331,40 +350,32 @@ func CreateSystemUser(db *sqlx.DB) error {
return nil
}
-// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
-func ChangeSystemUserPassword(db *sqlx.DB) error {
- // Prompt for password and get hashed password
- hashedPassword, err := promptAndHashPassword()
- if err != nil {
- return err
- }
-
- // Update system user's password in the database.
- if err := updateSystemUserPassword(db, hashedPassword); err != nil {
- return fmt.Errorf("error updating system user password: %v", err)
- }
- fmt.Println("System user password updated successfully.")
- return nil
-}
-
// promptAndHashPassword handles password input and validation, and returns the hashed password.
-func promptAndHashPassword() ([]byte, error) {
- var password string
+func promptAndHashPassword(ctx context.Context) ([]byte, error) {
for {
- fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
- fmt.Scanf("%s", &password)
- if isStrongSystemUserPassword(password) {
- break
- }
- fmt.Println("Password does not meet the strength requirements.")
- }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
+ buffer := make([]byte, 256)
+ n, err := os.Stdin.Read(buffer)
+ if err != nil {
+ return nil, fmt.Errorf("error reading input: %v", err)
+ }
- // Hash the password using bcrypt.
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- return nil, fmt.Errorf("failed to hash password: %v", err)
+ password := strings.TrimSpace(string(buffer[:n]))
+ if isStrongSystemUserPassword(password) {
+ // Hash the password using bcrypt.
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, fmt.Errorf("failed to hash password: %v", err)
+ }
+ return hashedPassword, nil
+ }
+ fmt.Println("Password does not meet the strength requirements.")
+ }
}
- return hashedPassword, nil
}
// updateSystemUserPassword updates the password of the system user in the database.
diff --git a/internal/view/models/models.go b/internal/view/models/models.go
new file mode 100644
index 0000000..b3ef518
--- /dev/null
+++ b/internal/view/models/models.go
@@ -0,0 +1,16 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+)
+
+type View struct {
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ InboxType string `db:"inbox_type" json:"inbox_type"`
+ Filters json.RawMessage `db:"filters" json:"filters"`
+ UserID int `db:"user_id" json:"user_id"`
+}
diff --git a/internal/view/queries.sql b/internal/view/queries.sql
new file mode 100644
index 0000000..614b8ee
--- /dev/null
+++ b/internal/view/queries.sql
@@ -0,0 +1,20 @@
+-- name: get-view
+SELECT id, created_at, updated_at, name, filters, inbox_type, user_id
+FROM views WHERE id = $1;
+
+-- name: get-user-views
+SELECT id, created_at, updated_at, name, filters, inbox_type, user_id
+FROM views WHERE user_id = $1;
+
+-- name: insert-view
+INSERT INTO views (name, filters, inbox_type, user_id)
+VALUES ($1, $2, $3, $4);
+
+-- name: delete-view
+DELETE FROM views
+WHERE id = $1;
+
+-- name: update-view
+UPDATE views
+SET name = $2, filters = $3, inbox_type = $4, updated_at = NOW()
+WHERE id = $1
diff --git a/internal/view/view.go b/internal/view/view.go
new file mode 100644
index 0000000..9b00d32
--- /dev/null
+++ b/internal/view/view.go
@@ -0,0 +1,101 @@
+// Package view handles the management of conversation views.
+package view
+
+import (
+ "database/sql"
+ "embed"
+
+ "github.com/abhinavxd/artemis/internal/dbutil"
+ "github.com/abhinavxd/artemis/internal/envelope"
+ "github.com/abhinavxd/artemis/internal/view/models"
+ "github.com/jmoiron/sqlx"
+ "github.com/zerodha/logf"
+)
+
+var (
+ //go:embed queries.sql
+ efs embed.FS
+)
+
+// Manager manages views.
+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 {
+ GetView *sqlx.Stmt `query:"get-view"`
+ GetUserViews *sqlx.Stmt `query:"get-user-views"`
+ InsertView *sqlx.Stmt `query:"insert-view"`
+ DeleteView *sqlx.Stmt `query:"delete-view"`
+ UpdateView *sqlx.Stmt `query:"update-view"`
+}
+
+// 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
+}
+
+// Get returns a view by ID.
+func (v *Manager) Get(id int) (models.View, error) {
+ var view = models.View{}
+ if err := v.q.GetView.Get(&view, id); err != nil {
+ if err == sql.ErrNoRows {
+ return view, envelope.NewError(envelope.NotFoundError, "View not found", nil)
+ }
+ v.lo.Error("error fetching view", "error", err)
+ return view, envelope.NewError(envelope.GeneralError, "Error fetching view", nil)
+ }
+ return view, nil
+}
+
+// GetUsersViews returns all views for a user.
+func (v *Manager) GetUsersViews(userID int) ([]models.View, error) {
+ views := []models.View{}
+ if err := v.q.GetUserViews.Select(&views, userID); err != nil {
+ v.lo.Error("error fetching views", "error", err)
+ return nil, envelope.NewError(envelope.GeneralError, "Error fetching views", nil)
+ }
+ return views, nil
+}
+
+// Create creates a new view.
+func (v *Manager) Create(name string, filter []byte, baseList string, userID int) error {
+ if _, err := v.q.InsertView.Exec(name, filter, baseList, userID); err != nil {
+ v.lo.Error("error inserting view", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error creating view", nil)
+ }
+ return nil
+}
+
+// Update updates a view by id.
+func (v *Manager) Update(id int, name string, filter []byte, baseList string) error {
+ if _, err := v.q.UpdateView.Exec(id, name, filter, baseList); err != nil {
+ v.lo.Error("error updating view", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error updating view", nil)
+ }
+ return nil
+}
+
+// Delete deletes a view by ID.
+func (v *Manager) Delete(id int) error {
+ if _, err := v.q.DeleteView.Exec(id); err != nil {
+ v.lo.Error("error deleting view", "error", err)
+ return envelope.NewError(envelope.GeneralError, "Error deleting view", nil)
+ }
+ return nil
+}
diff --git a/internal/workerpool/workerpool.go b/internal/workerpool/workerpool.go
new file mode 100644
index 0000000..6bbf05f
--- /dev/null
+++ b/internal/workerpool/workerpool.go
@@ -0,0 +1,46 @@
+// package workerpool contains a single goroutine worker pool that executes arbitrary
+// encapsulated functions.
+package workerpool
+
+import (
+ "sync"
+)
+
+// Pool is a single goroutine worker pool.
+type Pool struct {
+ num int
+ q chan func()
+ wg sync.WaitGroup
+}
+
+// New returns a new goroutine workerpool.
+func New(num, queueSize int) *Pool {
+ return &Pool{
+ num: num,
+ q: make(chan func(), queueSize),
+ wg: sync.WaitGroup{},
+ }
+}
+
+// Run initializes the goroutine worker pool.
+func (w *Pool) Run() {
+ for i := 0; i < w.num; i++ {
+ w.wg.Add(1)
+ go func() {
+ for f := range w.q {
+ f()
+ }
+ w.wg.Done()
+ }()
+ }
+}
+
+// Push pushes a job to the worker queue to execute.
+func (w *Pool) Push(f func()) {
+ w.q <- f
+}
+
+func (w *Pool) Close() {
+ close(w.q)
+ w.wg.Wait()
+}
diff --git a/internal/ws/client.go b/internal/ws/client.go
index 357c07a..5a2e6b3 100644
--- a/internal/ws/client.go
+++ b/internal/ws/client.go
@@ -112,16 +112,17 @@ func (c *Client) processIncomingMessage(data []byte) {
case models.ActionConversationsListSub:
var sReq models.ConversationsListSubscribe
if err := json.Unmarshal(data, &sReq); err != nil {
- c.SendError("error unmarshalling request")
+ c.SendError("error unmarshalling request: " + err.Error())
return
}
+
// First remove all user conversation subscriptions.
c.RemoveAllUserConversationSubscriptions(c.ID)
// Fetch conversations of this list and subscribe to them
for page := 1; page <= maxConversationsPagesToSub; page++ {
- conversationUUIDs, err := c.Hub.conversationStore.GetConversationsListUUIDs(c.ID, page, maxConversationsPageSize, sReq.Type)
+ conversationUUIDs, err := c.Hub.conversationStore.GetConversationsListUUIDs(c.ID, sReq.TeamID, page, maxConversationsPageSize, sReq.Type)
if err != nil {
continue
}
diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go
index 53dc808..22f9471 100644
--- a/internal/ws/models/models.go
+++ b/internal/ws/models/models.go
@@ -38,7 +38,8 @@ type IncomingReq struct {
// ConversationsListSubscribe represents a request to subscribe to conversations list
type ConversationsListSubscribe struct {
- Type string `json:"type"`
+ Type string `json:"type"`
+ TeamID int `json:"team_id"`
}
// ConversationCurrentSet represents a request to set current conversation
diff --git a/internal/ws/ws.go b/internal/ws/ws.go
index 80fff00..af68b7a 100644
--- a/internal/ws/ws.go
+++ b/internal/ws/ws.go
@@ -24,7 +24,7 @@ type Hub struct {
// ConversationStore defines the interface for retrieving conversation UUIDs.
type ConversationStore interface {
- GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error)
+ GetConversationsListUUIDs(userID, teamID, page, pageSize int, typ string) ([]string, error)
}
// NewHub creates a new Hub.
diff --git a/schema.sql b/schema.sql
index 97fef57..5f7344d 100644
--- a/schema.sql
+++ b/schema.sql
@@ -4,34 +4,79 @@ DROP TYPE IF EXISTS "message_type" CASCADE; CREATE TYPE "message_type" AS ENUM (
DROP TYPE IF EXISTS "message_sender_type" CASCADE; CREATE TYPE "message_sender_type" AS ENUM ('user','contact');
DROP TYPE IF EXISTS "message_status" CASCADE; CREATE TYPE "message_status" AS ENUM ('received','sent','failed','pending');
DROP TYPE IF EXISTS "content_type" CASCADE; CREATE TYPE "content_type" AS ENUM ('text','html');
+DROP TYPE IF EXISTS "sla_status" CASCADE; CREATE TYPE "sla_status" AS ENUM ('active','missed');
+DROP TYPE IF EXISTS "conversation_assignment_type" CASCADE; CREATE TYPE "conversation_assignment_type" AS ENUM ('Round robin','Manual');
+DROP TYPE IF EXISTS "sla_type" CASCADE; CREATE TYPE "sla_type" AS ENUM ('first_response','resolution');
+DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM ('email_outgoing', 'email_notification');
+DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact');
+
+DROP TABLE IF EXISTS conversation_slas CASCADE;
+CREATE TABLE conversation_slas (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
+ conversation_id BIGINT NOT NULL REFERENCES conversations(id),
+ sla_policy_id INT NOT NULL REFERENCES sla_policies(id),
+ sla_type sla_type NOT NULL,
+ due_at TIMESTAMPTZ NULL,
+ met_at TIMESTAMPTZ NULL,
+ breached_at TIMESTAMPTZ NULL,
+ CONSTRAINT constraint_conversation_slas_unique UNIQUE (sla_policy_id, conversation_id, sla_type)
+);
DROP TABLE IF EXISTS teams CASCADE;
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
- "name" VARCHAR(140) NOT NULL,
+ "name" TEXT NOT NULL,
+ emoji TEXT NULL,
disabled bool DEFAULT false NOT NULL,
- auto_assign_conversations bool DEFAULT false NOT NULL,
+ conversation_assignment_type conversation_assignment_type NOT NULL,
+ business_hours_id INT REFERENCES business_hours(id) ON DELETE SET NULL ON UPDATE CASCADE NOT NULL,
+ timezone TEXT NULL,
+ CONSTRAINT constraint_teams_on_emoji CHECK (length(emoji) <= 1),
+ CONSTRAINT constraint_teams_on_name CHECK (length("name") <= 140),
+ CONSTRAINT constraint_teams_on_timezone CHECK (length(timezone) <= 50),
CONSTRAINT constraint_teams_on_name_unique UNIQUE ("name")
);
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ type user_type NOT NULL,
+ deleted_at TIMESTAMPTZ NULL,
+ disabled bool DEFAULT false NOT NULL,
+ email TEXT NULL,
+ first_name TEXT NOT NULL,
+ last_name TEXT NULL,
+ phone_number TEXT NULL,
+ country TEXT NULL,
+ "password" VARCHAR(150) NULL,
+ avatar_url TEXT NULL,
+ roles TEXT[] DEFAULT '{}'::TEXT[] NULL,
+ reset_password_token TEXT NULL,
+ reset_password_token_expiry TIMESTAMPTZ NULL,
+ CONSTRAINT constraint_users_on_email_unique UNIQUE (email),
+ CONSTRAINT constraint_users_on_country CHECK (length(country) <= 140),
+ CONSTRAINT constraint_users_on_phone_number CHECK (length(phone_number) <= 20),
+ CONSTRAINT constraint_users_on_email_length CHECK (length(email) <= 320),
+ CONSTRAINT constraint_users_on_first_name CHECK (length(first_name) <= 140),
+ CONSTRAINT constraint_users_on_last_name CHECK (length(last_name) <= 140)
+);
+
+DROP TABLE IF EXISTS contact_channels CASCADE;
+CREATE TABLE contact_channels (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
- deleted_at TIMESTAMPTZ NULL,
- disabled bool DEFAULT false NOT NULL,
- email VARCHAR(254) NOT NULL,
- first_name VARCHAR(100) NOT NULL,
- last_name VARCHAR(100) NULL,
- "password" VARCHAR(150) NULL,
- avatar_url TEXT NULL,
- roles _text DEFAULT '{}'::text [] NOT NULL,
- reset_password_token TEXT NULL,
- reset_password_token_expiry TIMESTAMPTZ NULL,
- CONSTRAINT constraint_users_on_email_unique UNIQUE (email)
+ contact_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ inbox_id INT NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ identifier TEXT NOT NULL,
+ CONSTRAINT constraint_contact_channels_on_identifier CHECK (length(identifier) <= 1000),
+ CONSTRAINT constraint_contact_channels_on_inbox_id_and_contact_id_unique UNIQUE (inbox_id, contact_id)
);
DROP TABLE IF EXISTS conversation_statuses CASCADE;
@@ -58,18 +103,26 @@ CREATE TABLE conversations (
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
"uuid" UUID DEFAULT gen_random_uuid() NOT NULL,
- reference_number TEXT UNIQUE NOT NULL,
+ reference_number BIGSERIAL UNIQUE,
contact_id BIGINT NOT NULL,
+ contact_channel_id INT REFERENCES contact_channels(id) ON DELETE SET NULL ON UPDATE CASCADE,
assigned_user_id INT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
assigned_team_id INT REFERENCES teams(id) ON DELETE SET NULL ON UPDATE CASCADE,
inbox_id INT NOT NULL,
- meta JSONB DEFAULT '{}'::JSON NOT NULL,
+ meta JSONB DEFAULT '{}'::jsonb NOT NULL,
+ custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
assignee_last_seen_at TIMESTAMPTZ DEFAULT NOW(),
first_reply_at TIMESTAMPTZ NULL,
closed_at TIMESTAMPTZ NULL,
resolved_at TIMESTAMPTZ NULL,
status_id INT REFERENCES conversation_statuses(id),
- priority_id INT REFERENCES conversation_priorities(id)
+ priority_id INT REFERENCES conversation_priorities(id),
+ sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ "subject" TEXT NULL,
+ last_message_at TIMESTAMPTZ NULL,
+ last_message TEXT NULL,
+ next_sla_deadline_at TIMESTAMPTZ NULL,
+ snoozed_until TIMESTAMPTZ NULL
);
DROP TABLE IF EXISTS conversation_messages CASCADE;
@@ -85,7 +138,7 @@ CREATE TABLE conversation_messages (
content_type content_type NULL,
"content" TEXT NULL,
source_id TEXT NULL,
- sender_id INT NULL,
+ sender_id INT REFERENCES users(id) NULL,
sender_type message_sender_type NOT NULL,
meta JSONB DEFAULT '{}'::JSONB NULL
);
@@ -95,13 +148,13 @@ CREATE TABLE automation_rules (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
- "name" VARCHAR(255) NOT NULL,
+ "name" TEXT NOT NULL,
description TEXT NULL,
"type" VARCHAR NOT NULL,
rules JSONB NULL,
- events TEXT[] NULL,
+ events TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
disabled BOOL DEFAULT false NOT NULL,
- CONSTRAINT constraint_automation_rules_on_name CHECK (length("name") <= 100),
+ CONSTRAINT constraint_automation_rules_on_name CHECK (length("name") <= 140),
CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300)
);
@@ -112,36 +165,16 @@ CREATE TABLE canned_responses (
updated_at TIMESTAMPTZ DEFAULT NOW(),
title TEXT NOT NULL,
"content" TEXT NOT NULL,
- CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 100),
+ CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 140),
CONSTRAINT constraint_canned_responses_on_content CHECK (length("content") <= 5000)
);
-DROP TABLE IF EXISTS contacts CASCADE;
-CREATE TABLE contacts (
- id BIGSERIAL PRIMARY KEY,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
- first_name TEXT NULL,
- last_name TEXT NULL,
- email VARCHAR(254) NULL,
- phone_number TEXT NULL,
- avatar_url TEXT NULL,
- inbox_id INT NULL,
- source_id TEXT NULL,
- CONSTRAINT constraint_contacts_on_first_name CHECK (length(first_name) <= 100),
- CONSTRAINT constraint_contacts_on_last_name CHECK (length(last_name) <= 100),
- CONSTRAINT constraint_contacts_on_email CHECK (length(email) <= 254),
- CONSTRAINT constraint_contacts_on_phone_number CHECK (length(phone_number) <= 50),
- CONSTRAINT constraint_contacts_on_avatar_url CHECK (length(avatar_url) <= 1000),
- CONSTRAINT constraint_contacts_on_source_id CHECK (length(source_id) <= 5000)
-);
-
DROP TABLE IF EXISTS conversation_participants CASCADE;
CREATE TABLE conversation_participants (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
- user_id INT NOT NULL,
+ user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT constraint_conversation_participants_conversation_id_and_user_id_unique UNIQUE (conversation_id, user_id)
);
@@ -175,7 +208,7 @@ CREATE TABLE media (
"size" INT NULL,
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT constraint_media_on_filename CHECK (length(filename) <= 1000),
- CONSTRAINT constraint_media_on_content_id CHECK (length(content_id) <= 100)
+ CONSTRAINT constraint_media_on_content_id CHECK (length(content_id) <= 300)
);
DROP TABLE IF EXISTS oidc CASCADE;
@@ -201,14 +234,6 @@ CREATE TABLE roles (
description TEXT NULL
);
--- Roles.
-INSERT INTO roles
-(permissions, "name", description)
-VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write}', 'Agent', 'Role for all agents with limited access to conversations.');
-INSERT INTO roles
-(permissions, "name", description)
-VALUES('{conversations:read_unassigned,teams:delete,users:delete,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have complete access to everything.');
-
DROP TABLE IF EXISTS settings CASCADE;
CREATE TABLE settings (
updated_at TIMESTAMPTZ DEFAULT NOW(),
@@ -233,8 +258,10 @@ CREATE TABLE team_members (
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
team_id INT REFERENCES teams(id) ON DELETE CASCADE ON UPDATE CASCADE,
- user_id INT NOT NULL,
- CONSTRAINT constraint_team_members_on_team_id_and_user_id_unique UNIQUE (team_id, user_id)
+ user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+ emoji TEXT NULL,
+ CONSTRAINT constraint_team_members_on_team_id_and_user_id_unique UNIQUE (team_id, user_id),
+ CONSTRAINT constraint_team_members_on_emoji CHECK (length(emoji) <= 1)
);
DROP TABLE IF EXISTS templates CASCADE;
@@ -242,25 +269,18 @@ CREATE TABLE templates (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
+ type template_type NOT NULL,
body TEXT NOT NULL,
is_default bool DEFAULT false NOT NULL,
- "name" TEXT NULL
+ "name" TEXT NOT NULL,
+ subject TEXT NULL,
+ is_builtin bool DEFAULT false NOT NULL,
+ CONSTRAINT constraint_templates_on_name CHECK (length("name") <= 140),
+ CONSTRAINT constraint_templates_on_subject CHECK (length(subject) <= 1000)
);
CREATE UNIQUE INDEX unique_index_templates_on_is_default_when_is_default_is_true ON templates USING btree (is_default)
WHERE (is_default = true);
-DROP TABLE IF EXISTS contact_methods CASCADE;
-CREATE TABLE contact_methods (
- id BIGSERIAL PRIMARY KEY,
- contact_id BIGINT REFERENCES contacts(id) ON DELETE CASCADE ON UPDATE CASCADE,
- "source" TEXT NOT NULL,
- source_id TEXT NOT NULL,
- inbox_id INT NULL,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
- CONSTRAINT constraint_contact_methods_on_source_and_source_id_unique UNIQUE (contact_id, source_id)
-);
-
DROP TABLE IF EXISTS conversation_tags CASCADE;
CREATE TABLE conversation_tags (
id BIGSERIAL PRIMARY KEY,
@@ -271,29 +291,85 @@ CREATE TABLE conversation_tags (
CONSTRAINT constraint_conversation_tags_on_conversation_id_and_tag_id_unique UNIQUE (conversation_id, tag_id)
);
+DROP TABLE IF EXISTS csat_responses CASCADE;
+CREATE TABLE csat_responses (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ uuid UUID DEFAULT gen_random_uuid(),
+ conversation_id BIGSERIAL REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
+ assigned_agent_id INT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
+ rating INT DEFAULT 0 NOT NULL,
+ feedback TEXT NULL,
+ response_timestamp TIMESTAMPTZ NULL,
+ CONSTRAINT constraint_csat_responses_on_rating CHECK (rating >= 0 AND rating <= 5),
+ CONSTRAINT constraint_csat_responses_on_feedback CHECK (length(feedback) <= 1000),
+ CONSTRAINT constraint_csat_responses_conversation_and_assigned_agent_unique UNIQUE (conversation_id, assigned_agent_id)
+);
+
+DROP TABLE IF EXISTS business_hours CASCADE;
+CREATE TABLE business_hours (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ name TEXT NOT NULL,
+ description TEXT NULL,
+ is_always_open BOOL DEFAULT false NOT NULL,
+ hours JSONB NOT NULL,
+ holidays JSONB DEFAULT '{}'::jsonb NOT NULL,
+ CONSTRAINT constraint_business_hours_on_name CHECK (length(name) <= 140)
+);
+
+DROP TABLE IF EXISTS sla_policies CASCADE;
+CREATE TABLE sla_policies (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ name TEXT NOT NULL,
+ description TEXT NULL,
+ first_response_time TEXT NOT NULL,
+ resolution_time TEXT NOT NULL,
+ CONSTRAINT constraint_sla_policies_on_name CHECK (length(name) <= 140)
+);
+
+DROP TABLE IF EXISTS views CASCADE;
+CREATE TABLE views (
+ id SERIAL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ inbox_type TEXT NOT NULL,
+ name TEXT NOT NULL,
+ filters JSONB NOT NULL,
+ user_id INT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT constraint_views_on_name CHECK (length(name) <= 140),
+ CONSTRAINT constraint_views_on_inbox_type CHECK (length(inbox_type) <= 140)
+);
+
-- Default settings
INSERT INTO settings ("key", value)
VALUES
('app.lang', '"en"'::jsonb),
('app.root_url', '"http://localhost:9000"'::jsonb),
- ('app.logo_url', '""'::jsonb),
+ ('app.logo_url', '"http://localhost:9000/logo.png"'::jsonb),
('app.site_name', '"Helpdesk"'::jsonb),
('app.favicon_url', '"http://localhost:9000/favicon.ico"'::jsonb),
('app.max_file_upload_size', '20'::jsonb),
('app.allowed_file_upload_extensions', '["*"]'::jsonb),
+ ('app.timezone', '"Asia/Calcutta"'::jsonb),
+ ('app.business_hours_id', '""'::jsonb),
('notification.email.username', '"admin@yourcompany.com"'::jsonb),
- ('notification.email.host', '""'::jsonb),
+ ('notification.email.host', '"smtp.google.com"'::jsonb),
('notification.email.port', '587'::jsonb),
('notification.email.password', '""'::jsonb),
('notification.email.max_conns', '1'::jsonb),
('notification.email.idle_timeout', '"5s"'::jsonb),
('notification.email.wait_timeout', '"5s"'::jsonb),
- ('notification.email.auth_protocol', '"Plain"'::jsonb),
- ('notification.email.email_address', '""'::jsonb),
+ ('notification.email.auth_protocol', '"plain"'::jsonb),
+ ('notification.email.email_address', '"admin@yourcompany.com"'::jsonb),
('notification.email.max_msg_retries', '3'::jsonb),
('notification.email.enabled', 'false'::jsonb);
-
+-- Default conversation priorities
INSERT INTO conversation_priorities
("name")
VALUES('Low');
@@ -304,6 +380,8 @@ INSERT INTO conversation_priorities
("name")
VALUES('High');
+
+-- Default conversation statuses
INSERT INTO conversation_statuses
("name")
VALUES('Open');
@@ -315,4 +393,16 @@ INSERT INTO conversation_statuses
VALUES('Resolved');
INSERT INTO conversation_statuses
("name")
-VALUES('Closed');
\ No newline at end of file
+VALUES('Closed');
+INSERT INTO conversation_statuses
+("name")
+VALUES('Snoozed');
+
+-- Default roles
+INSERT INTO roles
+(permissions, "name", description)
+VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write}', 'Agent', 'Role for all agents with limited access to conversations.');
+
+INSERT INTO roles
+(permissions, "name", description)
+VALUES('{conversations:read_unassigned,teams:delete,users:delete,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have complete access to everything.');
\ No newline at end of file
diff --git a/static/email-templates/conversation-assigned.html b/static/email-templates/conversation-assigned.html
deleted file mode 100644
index abe0d96..0000000
--- a/static/email-templates/conversation-assigned.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{{ define "conversation-assigned" }}
-{{ template "header" . }}
-
-
A new conversation has been assigned to you:
-
-
- {{ if .Conversation.Subject }}Subject: {{ .Conversation.Subject }}{{ end }}
-
-
-
You can view the conversation by clicking the link below:
-
View Conversation
-
-
-{{ template "footer" . }}
-{{ end }}
\ No newline at end of file
diff --git a/static/public/static/style.css b/static/public/static/style.css
new file mode 100644
index 0000000..7eb8654
--- /dev/null
+++ b/static/public/static/style.css
@@ -0,0 +1,236 @@
+* {
+ box-sizing: border-box;
+}
+html,
+body {
+ padding: 0;
+ margin: 0;
+ min-width: 320px;
+}
+body {
+ background: #f9f9f9;
+ font-family: 'Inter', 'Open Sans', 'Helvetica Neue', sans-serif;
+ font-size: 16px;
+ line-height: 26px;
+ color: #111;
+}
+a {
+ color: #18181b;
+ text-decoration-color: #abcbfb;
+}
+a:hover {
+ color: #111;
+}
+label {
+ cursor: pointer;
+ color: #444;
+}
+h1,
+h2,
+h3,
+h4 {
+ font-weight: 400;
+}
+.section {
+ margin-bottom: 45px;
+}
+
+input[type='text'],
+input[type='email'],
+input[type='password'],
+select {
+ padding: 10px 15px;
+ border: 1px solid #888;
+ border-radius: 3px;
+ width: 100%;
+ box-shadow: 2px 2px 0 #f3f3f3;
+ border: 1px solid #ddd;
+ font-size: 1em;
+}
+input:focus {
+ border-color: #18181b;
+}
+
+input:focus::placeholder {
+ color: transparent;
+}
+
+input[disabled] {
+ opacity: 0.5;
+}
+
+.center {
+ text-align: center;
+}
+.right {
+ text-align: right;
+}
+.error {
+ color: #ff5722;
+}
+.button {
+ background: #18181b;
+ padding: 15px 30px;
+ border-radius: 3px;
+ border: 0;
+ cursor: pointer;
+ text-decoration: none;
+ color: #ffff;
+ display: inline-block;
+ min-width: 150px;
+ font-size: 1.1em;
+ text-align: center;
+}
+.button:hover {
+ background: #333;
+ color: #fff;
+}
+.button.button-outline {
+ background: #fff;
+ border: 1px solid #18181b;
+ color: #18181b;
+}
+.button.button-outline:hover {
+ border-color: #333;
+ background-color: #333;
+ color: #fff;
+}
+
+.container {
+ margin: 60px auto 15px auto;
+ max-width: 550px;
+}
+
+.wrap {
+ background: #fff;
+ padding: 40px;
+ box-shadow: 2px 2px 0 #f3f3f3;
+ border: 1px solid #eee;
+}
+
+.header {
+ border-bottom: 1px solid #eee;
+ padding-bottom: 15px;
+ margin-bottom: 30px;
+}
+.header .logo img {
+ width: auto;
+ max-width: 150px;
+}
+
+.unsub-all {
+ margin-top: 30px;
+ padding-top: 30px;
+ border-top: 1px solid #eee;
+}
+
+.row {
+ margin-bottom: 20px;
+}
+.lists {
+ list-style-type: none;
+ padding: 0;
+}
+.lists li {
+ margin: 0 0 5px 0;
+}
+.lists .description {
+ margin: 0 0 15px 0;
+ font-size: 0.875em;
+ line-height: 1.3rem;
+ color: #888;
+ margin-left: 25px;
+}
+.form .nonce {
+ display: none;
+}
+.form .captcha {
+ margin-top: 30px;
+}
+
+.archive {
+ list-style-type: none;
+ margin: 25px 0 0 0;
+ padding: 0;
+}
+.archive .date {
+ display: block;
+ color: #666;
+ font-size: 0.875em;
+}
+.archive li {
+ margin-bottom: 15px;
+}
+.feed {
+ margin-right: 15px;
+}
+
+.home-options {
+ margin-top: 30px;
+}
+.home-options a {
+ margin: 0 7px;
+}
+
+.pagination {
+ margin-top: 30px;
+ text-align: center;
+}
+.pg-page {
+ display: inline-block;
+ padding: 0 10px;
+ text-decoration: none;
+}
+.pg-page.pg-selected {
+ text-decoration: underline;
+ font-weight: bold;
+}
+
+.login .submit {
+ margin-top: 20px;
+}
+.login button {
+ width: 100%;
+ vertical-align: middle;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.login button img {
+ max-width: 24px;
+ margin-right: 10px;
+}
+
+#btn-back {
+ display: none;
+}
+
+footer.container {
+ margin-top: 15px;
+ text-align: center;
+ color: #aaa;
+ font-size: 0.775em;
+ margin-top: 30px;
+ margin-bottom: 30px;
+}
+footer a {
+ color: #aaa;
+ text-decoration: none;
+}
+footer a:hover {
+ color: #111;
+}
+
+@media screen and (max-width: 650px) {
+ .wrap {
+ margin: 0;
+ padding: 30px;
+ max-width: none;
+ }
+}
+
+.green {
+ color: green;
+}
+
+
diff --git a/static/public/web-templates/csat.html b/static/public/web-templates/csat.html
new file mode 100644
index 0000000..0d83e98
--- /dev/null
+++ b/static/public/web-templates/csat.html
@@ -0,0 +1,300 @@
+{{ define "csat" }}
+{{ template "header" . }}
+
+
+
+{{ template "footer" . }}
+{{ end }}
\ No newline at end of file
diff --git a/static/public/web-templates/error.html b/static/public/web-templates/error.html
new file mode 100644
index 0000000..c6232fe
--- /dev/null
+++ b/static/public/web-templates/error.html
@@ -0,0 +1,11 @@
+{{ define "error" }}
+{{ template "header" . }}
+
+
+ {{ if .error_message }}
+ {{ .error_message }}
+ {{ end }}
+
+
+{{ template "footer" . }}
+{{ end }}
\ No newline at end of file
diff --git a/static/public/web-templates/index.html b/static/public/web-templates/index.html
new file mode 100644
index 0000000..4414c5b
--- /dev/null
+++ b/static/public/web-templates/index.html
@@ -0,0 +1,42 @@
+{{ define "header" }}
+
+
+
+
+
{{ .Data.Title }} - {{ .SiteName }}
+
+
+
+
+
+
+
+ {{ if ne .FaviconURL "" }}
+
+ {{ else }}
+
+ {{ end }}
+
+
+
+
+{{ end }}
+
+{{ define "footer" }}
+
+
+
+
+
+{{ end }}
\ No newline at end of file
diff --git a/static/public/web-templates/info.html b/static/public/web-templates/info.html
new file mode 100644
index 0000000..5c48095
--- /dev/null
+++ b/static/public/web-templates/info.html
@@ -0,0 +1,11 @@
+{{ define "info" }}
+{{ template "header" . }}
+
+
+ {{ if .message }}
+ {{ .message }}
+ {{ end }}
+
+
+{{ template "footer" . }}
+{{ end }}
\ No newline at end of file