diff --git a/cmd/handlers.go b/cmd/handlers.go
index 002b2704..c117d080 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -37,7 +37,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Settings.
g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
- g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
+ g.PUT("/api/v1/settings/general", perm(clearsHelpCenterCache(handleUpdateGeneralSettings), "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
@@ -295,24 +295,24 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/help-centers/locales", perm(handleGetHelpCenterLocales, "help_center:manage"))
g.GET("/api/v1/help-centers/{id}", perm(handleGetHelpCenter, "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/tree", perm(handleGetHelpCenterTree, "help_center:manage"))
- g.POST("/api/v1/help-centers", perm(handleCreateHelpCenter, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{id}", perm(handleUpdateHelpCenter, "help_center:manage"))
+ g.POST("/api/v1/help-centers", perm(clearsHelpCenterCache(handleCreateHelpCenter), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{id}", perm(clearsHelpCenterCache(handleUpdateHelpCenter), "help_center:manage"))
g.POST("/api/v1/help-centers/{id}/preview", perm(handleHelpCenterPreview, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{id}/toggle", perm(handleToggleHelpCenterActive, "help_center:manage"))
- g.DELETE("/api/v1/help-centers/{id}", perm(handleDeleteHelpCenter, "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{id}/toggle", perm(clearsHelpCenterCache(handleToggleHelpCenterActive), "help_center:manage"))
+ g.DELETE("/api/v1/help-centers/{id}", perm(clearsHelpCenterCache(handleDeleteHelpCenter), "help_center:manage"))
g.GET("/api/v1/help-centers/{hc_id}/collections", perm(handleGetCollections, "help_center:manage"))
- g.POST("/api/v1/help-centers/{hc_id}/collections", perm(handleCreateCollection, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleUpdateCollection, "help_center:manage"))
- g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleDeleteCollection, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{hc_id}/collection-sort-order", perm(handleUpdateCollectionSortOrders, "help_center:manage"))
- g.PUT("/api/v1/collections/{id}/toggle", perm(handleToggleCollection, "help_center:manage"))
- g.PUT("/api/v1/collections/{col_id}/article-sort-order", perm(handleUpdateArticleSortOrders, "help_center:manage"))
+ g.POST("/api/v1/help-centers/{hc_id}/collections", perm(clearsHelpCenterCache(handleCreateCollection), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(clearsHelpCenterCache(handleUpdateCollection), "help_center:manage"))
+ g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(clearsHelpCenterCache(handleDeleteCollection), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{hc_id}/collection-sort-order", perm(clearsHelpCenterCache(handleUpdateCollectionSortOrders), "help_center:manage"))
+ g.PUT("/api/v1/collections/{id}/toggle", perm(clearsHelpCenterCache(handleToggleCollection), "help_center:manage"))
+ g.PUT("/api/v1/collections/{col_id}/article-sort-order", perm(clearsHelpCenterCache(handleUpdateArticleSortOrders), "help_center:manage"))
g.GET("/api/v1/collections/{col_id}/articles/{id}", perm(handleGetArticle, "help_center:manage"))
- g.POST("/api/v1/collections/{col_id}/articles", perm(handleCreateArticle, "help_center:manage"))
- g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(handleDeleteArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}", perm(handleUpdateArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}/collection", perm(handleMoveArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}/status", perm(handleUpdateArticleStatus, "help_center:manage"))
+ g.POST("/api/v1/collections/{col_id}/articles", perm(clearsHelpCenterCache(handleCreateArticle), "help_center:manage"))
+ g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(clearsHelpCenterCache(handleDeleteArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}", perm(clearsHelpCenterCache(handleUpdateArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}/collection", perm(clearsHelpCenterCache(handleMoveArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}/status", perm(clearsHelpCenterCache(handleUpdateArticleStatus), "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/insights", perm(handleGetHelpCenterInsights, "help_center:manage"))
// Public help center JSON API.
@@ -393,11 +393,11 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
getAndHead("/robots.txt", rateLimit(handleRobotsTxt, "public"))
getAndHead("/sitemap.xml", rateLimit(handleSitemapIndex, "public"))
getAndHead("/hc/{slug}", rateLimit(handleRedirectHelpCenterHome, "public"))
- getAndHead("/hc/{slug}/{locale}", rateLimit(handleShowHelpCenterHome, "public"))
+ getAndHead("/hc/{slug}/{locale}", rateLimit(cachedHelpCenterPage(handleShowHelpCenterHome), "public"))
getAndHead("/hc/{slug}/{locale}/sitemap.xml", rateLimit(handleHelpCenterSitemap, "public"))
- getAndHead("/hc/{slug}/{locale}/search", rateLimit(handleHelpCenterSearch, "public"))
- getAndHead("/hc/{slug}/{locale}/collections/{collection_slug}", rateLimit(handleShowHelpCenterCollection, "public"))
- getAndHead("/hc/{slug}/{locale}/articles/{article_slug}", rateLimit(handleShowHelpCenterArticle, "public"))
+ getAndHead("/hc/{slug}/{locale}/search", rateLimit(cachedHelpCenterPage(handleHelpCenterSearch), "public"))
+ getAndHead("/hc/{slug}/{locale}/collections/{collection_slug}", rateLimit(cachedHelpCenterPage(handleShowHelpCenterCollection), "public"))
+ getAndHead("/hc/{slug}/{locale}/articles/{article_slug}", rateLimit(countArticleView(cachedHelpCenterPage(handleShowHelpCenterArticle)), "public"))
g.GET("/csat/{uuid}", rateLimit(handleShowCSAT, "public"))
g.GET("/csat/{uuid}/widget", rateLimit(handleShowCSATWidget, "public"))
diff --git a/cmd/helpcenter.go b/cmd/helpcenter.go
index 3d1e0d74..cadf4434 100644
--- a/cmd/helpcenter.go
+++ b/cmd/helpcenter.go
@@ -6,6 +6,7 @@ import (
"fmt"
"hash/fnv"
"html/template"
+ "io"
"log"
"math"
"net"
@@ -25,6 +26,7 @@ import (
realip "github.com/ferluci/fast-realip"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
+ "github.com/zerodha/fastcache/v4"
"github.com/zerodha/fastglue"
)
@@ -43,7 +45,15 @@ const (
sitemapDate = "2006-01-02"
schemaOrgContext = "https://schema.org"
- helpCenterCacheControl = "public, max-age=300, stale-while-revalidate=3600"
+ helpCenterCacheControl = "public, no-cache"
+
+ fastCachePrefix = "libredesk:cache:"
+
+ helpCenterCacheTTL = 24 * time.Hour
+
+ helpCenterCacheGroup = "helpcenter"
+
+ helpCenterXMLCacheControl = "public, max-age=300, stale-while-revalidate=3600"
// articleFeedbackDedupTTL is how long a reader IP's vote on an article suppresses further votes.
articleFeedbackDedupTTL = 24 * time.Hour
@@ -60,6 +70,16 @@ const (
)
var (
+ // Logger is set because fastcache writes to the field when it finds it nil, racing across requests.
+ helpCenterCacheOpts = &fastcache.Options{
+ NamespaceKey: "slug",
+ ETag: true,
+ IncludeQueryString: true,
+ // Backstop for keys nothing invalidates, such as a deleted help center's.
+ TTL: helpCenterCacheTTL,
+ Logger: log.New(io.Discard, "", 0),
+ }
+
// crawlerUARe matches search and preview bots, whose hits must not count as reader views.
crawlerUARe = regexp.MustCompile(`(?i)bot\b|bot/|crawler|spider|crawling|slurp|facebookexternalhit|preview|headlesschrome|lighthouse|feedfetcher|python-requests|curl/|wget/`)
@@ -689,9 +709,6 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
}
// The JSON-LD embeds the author too, not just the byline.
hideArticleAuthor(helpCenterTheme(helpCenter), &article)
- if !isCrawler(r) {
- app.helpcenter.IncrementArticleViewCount(article.ID)
- }
if markdown {
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
@@ -1055,11 +1072,13 @@ func handleUpdateArticle(r *fastglue.Request) error {
// no-store default that RenderWebPage applies for the app's authenticated pages.
func renderHelpCenterPage(r *fastglue.Request, name string, data map[string]interface{}) error {
app := r.Context.(*App)
- err := app.tmpl.RenderWebPage(r.RequestCtx, name, data)
+ if err := app.tmpl.RenderWebPage(r.RequestCtx, name, data); err != nil {
+ return err
+ }
r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
r.RequestCtx.Response.Header.Del("Pragma")
r.RequestCtx.Response.Header.Del("Expires")
- return err
+ return nil
}
// sendXML writes v as an XML document.
@@ -1069,12 +1088,77 @@ func sendXML(r *fastglue.Request, v any) error {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetContentType("application/xml; charset=utf-8")
- r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
+ r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterXMLCacheControl)
fmt.Fprint(r.RequestCtx, xml.Header)
r.RequestCtx.Write(out)
return nil
}
+// countArticleView records a view after the page is served, counting cache hits too.
+func countArticleView(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
+ return func(r *fastglue.Request) error {
+ err := h(r)
+ switch r.RequestCtx.Response.StatusCode() {
+ case fasthttp.StatusOK, fasthttp.StatusNotModified:
+ if !isCrawler(r) {
+ app := r.Context.(*App)
+ slug, _ := r.RequestCtx.UserValue("slug").(string)
+ articleSlug, _ := r.RequestCtx.UserValue("article_slug").(string)
+ locale, _ := r.RequestCtx.UserValue("locale").(string)
+ app.helpcenter.IncrementPublishedArticleView(slug, strings.TrimSuffix(articleSlug, markdownSlugExtension), locale)
+ }
+ }
+ return err
+ }
+}
+
+// cachedHelpCenterPage serves a public help center page from the response cache, which
+// stores the body alone, so the caching headers are re-applied on the hit and 304 paths.
+func cachedHelpCenterPage(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
+ return func(r *fastglue.Request) error {
+ app := r.Context.(*App)
+ err := app.fastCache.Cached(h, helpCenterCacheOpts, helpCenterCacheGroup)(r)
+ switch r.RequestCtx.Response.StatusCode() {
+ case fasthttp.StatusOK, fasthttp.StatusNotModified:
+ r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
+ r.RequestCtx.Response.Header.Del("Pragma")
+ r.RequestCtx.Response.Header.Del("Expires")
+ }
+ return err
+ }
+}
+
+// clearsHelpCenterCache drops the cached public pages after a successful write.
+func clearsHelpCenterCache(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
+ return func(r *fastglue.Request) error {
+ err := h(r)
+ if r.RequestCtx.Response.StatusCode() < fasthttp.StatusMultipleChoices {
+ clearHelpCenterCache(r.Context.(*App), "")
+ }
+ return err
+ }
+}
+
+// clearHelpCenterCache drops every cached public page for slug, or for all when slug is empty.
+func clearHelpCenterCache(app *App, slug string) {
+ if slug != "" {
+ if err := app.fastCache.DelGroup(slug, helpCenterCacheGroup); err != nil {
+ app.lo.Error("error clearing help center cache", "slug", slug, "error", err)
+ }
+ return
+ }
+ helpCenters, err := app.helpcenter.GetAllHelpCenters()
+ if err != nil {
+ app.lo.Error("error listing help centers to clear cache", "error", err)
+ return
+ }
+ for _, hc := range helpCenters {
+ if err := app.fastCache.DelGroup(hc.Slug, helpCenterCacheGroup); err != nil {
+ app.lo.Error("error clearing help center cache", "slug", hc.Slug, "error", err)
+ }
+ }
+}
+
// isCrawler reports whether the request came from a bot rather than a reader; HEAD probes never count as readers.
func isCrawler(r *fastglue.Request) bool {
return r.RequestCtx.IsHead() || crawlerUARe.Match(r.RequestCtx.Request.Header.UserAgent())
diff --git a/cmd/helpcenter_cache_test.go b/cmd/helpcenter_cache_test.go
new file mode 100644
index 00000000..874dfef2
--- /dev/null
+++ b/cmd/helpcenter_cache_test.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/redis/go-redis/v9"
+ "github.com/valyala/fasthttp"
+ goredis "github.com/zerodha/fastcache/stores/goredis/v9"
+ "github.com/zerodha/fastcache/v4"
+ "github.com/zerodha/fastglue"
+)
+
+func TestHelpCenterPageCacheServesAndInvalidates(t *testing.T) {
+ fc := newTestFastCache(t)
+
+ renders := 0
+ body := "
original
"
+ handler := func(r *fastglue.Request) error {
+ renders++
+ r.RequestCtx.SetContentType("text/html; charset=utf-8")
+ r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
+ r.RequestCtx.Response.SetBodyString(body)
+ return nil
+ }
+ cached := fc.Cached(handler, helpCenterCacheOpts, helpCenterCacheGroup)
+
+ const uri = "/hc/support/en/articles/refunds"
+
+ first := request(uri, "support", "")
+ if err := cached(first); err != nil {
+ t.Fatalf("first request: %v", err)
+ }
+ if renders != 1 {
+ t.Fatalf("first request should render, renders = %d", renders)
+ }
+ etag := string(first.RequestCtx.Response.Header.Peek("ETag"))
+ if etag == "" {
+ t.Fatal("expected an ETag on the rendered page")
+ }
+
+ second := request(uri, "support", "")
+ if err := cached(second); err != nil {
+ t.Fatalf("second request: %v", err)
+ }
+ if renders != 1 {
+ t.Errorf("second request re-rendered instead of using the cache, renders = %d", renders)
+ }
+ if got := string(second.RequestCtx.Response.Body()); got != body {
+ t.Errorf("cached body = %q, want %q", got, body)
+ }
+
+ revalidate := request(uri, "support", etag)
+ if err := cached(revalidate); err != nil {
+ t.Fatalf("revalidation: %v", err)
+ }
+ if got := revalidate.RequestCtx.Response.StatusCode(); got != fasthttp.StatusNotModified {
+ t.Errorf("matching ETag returned %d, want 304", got)
+ }
+ if got := revalidate.RequestCtx.Response.Body(); len(got) != 0 {
+ t.Errorf("304 carried a body of %d bytes", len(got))
+ }
+
+ // An admin write clears the group, so the next read must re-render the edit.
+ if err := fc.DelGroup("support", helpCenterCacheGroup); err != nil {
+ t.Fatalf("clearing the group: %v", err)
+ }
+ body = "edited
"
+ afterEdit := request(uri, "support", "")
+ if err := cached(afterEdit); err != nil {
+ t.Fatalf("request after edit: %v", err)
+ }
+ if renders != 2 {
+ t.Errorf("cleared cache did not re-render, renders = %d", renders)
+ }
+ if got := string(afterEdit.RequestCtx.Response.Body()); got != body {
+ t.Errorf("stale body served after an edit: got %q, want %q", got, body)
+ }
+ if newETag := string(afterEdit.RequestCtx.Response.Header.Peek("ETag")); newETag == etag {
+ t.Error("ETag survived an edit, readers holding the old copy would never refetch")
+ }
+}
+
+func TestHelpCenterPageCacheIsolatesHelpCentersAndQueries(t *testing.T) {
+ fc := newTestFastCache(t)
+
+ served := map[string]int{}
+ handler := func(r *fastglue.Request) error {
+ uri := string(r.RequestCtx.URI().RequestURI())
+ served[uri]++
+ r.RequestCtx.Response.SetBodyString(uri)
+ return nil
+ }
+ cached := fc.Cached(handler, helpCenterCacheOpts, helpCenterCacheGroup)
+
+ for _, req := range []*fastglue.Request{
+ request("/hc/support/en/search?q=refund", "support", ""),
+ request("/hc/support/en/search?q=billing", "support", ""),
+ request("/hc/docs/en/search?q=refund", "docs", ""),
+ } {
+ if err := cached(req); err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ }
+ if len(served) != 3 {
+ t.Fatalf("expected 3 distinct cache entries, got %d: %v", len(served), served)
+ }
+
+ // Clearing one help center must not touch another's pages.
+ if err := fc.DelGroup("support", helpCenterCacheGroup); err != nil {
+ t.Fatalf("clearing the group: %v", err)
+ }
+ if err := cached(request("/hc/docs/en/search?q=refund", "docs", "")); err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ if got := served["/hc/docs/en/search?q=refund"]; got != 1 {
+ t.Errorf("clearing 'support' evicted 'docs', renders = %d", got)
+ }
+}
+
+// newTestFastCache returns a cache backed by an in-memory redis.
+func newTestFastCache(t *testing.T) *fastcache.FastCache {
+ t.Helper()
+ mr := miniredis.RunT(t)
+ return fastcache.New(goredis.New(goredis.Config{Prefix: fastCachePrefix}, redis.NewClient(&redis.Options{Addr: mr.Addr()})))
+}
+
+// request builds a GET for the given URI with the slug namespace the options read.
+func request(uri, slug, ifNoneMatch string) *fastglue.Request {
+ ctx := &fasthttp.RequestCtx{}
+ ctx.Request.Header.SetMethod("GET")
+ ctx.Request.SetRequestURI(uri)
+ ctx.SetUserValue("slug", slug)
+ if ifNoneMatch != "" {
+ ctx.Request.Header.Set("If-None-Match", ifNoneMatch)
+ }
+ return &fastglue.Request{RequestCtx: ctx}
+}
diff --git a/cmd/init.go b/cmd/init.go
index d4e21772..d717ac78 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -70,6 +70,8 @@ import (
"github.com/redis/go-redis/v9"
flag "github.com/spf13/pflag"
"github.com/volatiletech/null/v9"
+ goredis "github.com/zerodha/fastcache/stores/goredis/v9"
+ "github.com/zerodha/fastcache/v4"
"github.com/zerodha/logf"
)
@@ -940,6 +942,11 @@ func initRedis() *redis.Client {
})
}
+// initFastCache inits the shared HTTP response cache.
+func initFastCache(rdb *redis.Client) *fastcache.FastCache {
+ return fastcache.New(goredis.New(goredis.Config{Prefix: fastCachePrefix}, rdb))
+}
+
// initRedis inits postgres DB.
func initDB() *sqlx.DB {
dsn := fmt.Sprintf(
diff --git a/cmd/main.go b/cmd/main.go
index 95f5a1ac..98520aee 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -57,6 +57,7 @@ import (
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
+ "github.com/zerodha/fastcache/v4"
"github.com/zerodha/fastglue"
"github.com/zerodha/logf"
)
@@ -128,6 +129,7 @@ type App struct {
contextLink *contextlink.Manager
rateLimit *ratelimit.Limiter
redis *redis.Client
+ fastCache *fastcache.FastCache
importer *importer.Importer
wsHub *ws.Hub
@@ -322,6 +324,7 @@ func main() {
contextLink: initContextLink(db, i18n),
rateLimit: rateLimiter,
redis: rdb,
+ fastCache: initFastCache(rdb),
userNotification: userNotification,
wsHub: wsHub,
}
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 036e7cfa..06e10dbb 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -92,7 +92,7 @@ In light mode `card` is pure white against a near-white background. In dark mode
## 3. Typography
-Font: Instrument Sans. Sizes: `text-xs` 12 · `text-sm` 14 · `text-base` 16 · `text-lg` 18 ·
+Font: Geist. Sizes: `text-xs` 12 · `text-sm` 14 · `text-base` 16 · `text-lg` 18 ·
`text-xl` 20 · `text-2xl` 24 · `text-3xl` 30. Weights: 400 body · 500 labels · 600 headings ·
700 rare emphasis.
diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html
index 9361f948..781f17db 100644
--- a/frontend/apps/main/index.html
+++ b/frontend/apps/main/index.html
@@ -5,10 +5,8 @@
-
-
-
+
+
diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
index 5df27d1c..52c5fa83 100644
--- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
+++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBar.vue
@@ -18,7 +18,7 @@
{
const priorityOptions = computed(() => conversationStore.priorityOptions)
+const agentOptions = computed(() => {
+ const none = { value: 'none', label: t('globals.terms.none') }
+ const isMe = (option) => String(option.value) === String(userStore.userID)
+ const me = usersStore.options.find(isMe)
+ if (!me) return [none, ...usersStore.options]
+ return [none, me, ...usersStore.options.filter((option) => !isMe(option))]
+})
+
const fetchTags = async () => {
await tagStore.fetchTags()
tags.value = tagStore.tags.map((item) => item.name)
diff --git a/frontend/apps/widget/index.html b/frontend/apps/widget/index.html
index 59fe1f2b..66ac7efe 100644
--- a/frontend/apps/widget/index.html
+++ b/frontend/apps/widget/index.html
@@ -6,10 +6,8 @@
libredesk Chat Widget
-
-
-
+
+
diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss
index db785c53..125a12e2 100644
--- a/frontend/shared-ui/assets/styles/main.scss
+++ b/frontend/shared-ui/assets/styles/main.scss
@@ -22,7 +22,7 @@
@layer base {
html,
body {
- font-family: 'Instrument Sans', sans-serif;
+ @apply font-sans;
min-height: 100%;
overflow: hidden;
margin: 0;
diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs
index f0da9695..3c8aef7a 100644
--- a/frontend/tailwind.config.cjs
+++ b/frontend/tailwind.config.cjs
@@ -1,6 +1,7 @@
const animate = require("tailwindcss-animate")
const typography = require("@tailwindcss/typography")
const plugin = require("tailwindcss/plugin")
+const defaultTheme = require("tailwindcss/defaultTheme")
const canHover = plugin(({ addVariant }) => addVariant("can-hover", "@media (hover: hover)"))
@@ -26,6 +27,9 @@ module.exports = {
}
},
extend: {
+ fontFamily: {
+ sans: ['Geist', ...defaultTheme.fontFamily.sans]
+ },
height: {
screen: '100dvh'
},
diff --git a/go.mod b/go.mod
index efcd19f1..1855f052 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.25.0
require (
github.com/abhinavxd/ssrfguard v0.1.0
- github.com/alicebob/miniredis/v2 v2.32.1
+ github.com/alicebob/miniredis/v2 v2.33.0
github.com/coreos/go-oidc/v3 v3.11.0
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-imap/v2 v2.0.0-beta.3
@@ -42,6 +42,8 @@ require (
github.com/valyala/fasthttp v1.62.0
github.com/volatiletech/null/v9 v9.0.0
github.com/yuin/goldmark v1.8.4
+ github.com/zerodha/fastcache/stores/goredis/v9 v9.1.0
+ github.com/zerodha/fastcache/v4 v4.2.1
github.com/zerodha/fastglue v1.8.0
github.com/zerodha/logf v0.5.5
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
diff --git a/go.sum b/go.sum
index 8a694217..ad4cc84a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,14 +1,19 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/abhinavxd/ssrfguard v0.1.0 h1:Ns/llAQ63uGFehxSvhCd+WGDKmBEEmIH+E1AW1CGgWM=
github.com/abhinavxd/ssrfguard v0.1.0/go.mod h1:eNVubb+m/r3KrKWYdG6hxzeAfj+t2ZmZss4V/x7D6Ws=
-github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
-github.com/alicebob/miniredis/v2 v2.32.1 h1:Bz7CciDnYSaa0mX5xODh6GUITRSx+cVhjNoOR4JssBo=
-github.com/alicebob/miniredis/v2 v2.32.1/go.mod h1:AqkLNAfUm0K07J28hnAyyQKf/x0YkCY/g5DCtuL01Mw=
+github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
+github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
@@ -19,19 +24,26 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
+github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
+github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
+github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E=
+github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -39,8 +51,16 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
+github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emersion/go-imap/v2 v2.0.0-beta.3 h1:z0TLMfYnDsFupXLhzRXgOzXenD3uPvNniQSu5fN1teg=
github.com/emersion/go-imap/v2 v2.0.0-beta.3/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
@@ -54,6 +74,8 @@ github.com/fasthttp/websocket v1.5.9 h1:9deGuzYcCRKjk940kNwSN6Hd14hk4zYwropm4UsU
github.com/fasthttp/websocket v1.5.9/go.mod h1:NLzHBFur260OMuZHohOfYQwMTpR7sfSpUnuqKxMpgKA=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ferluci/fast-realip v1.0.1 h1:zPi0iv7zgOOlM/qJt9mozLz5IjVRAezaqHJoQ0JJ/yI=
github.com/ferluci/fast-realip v1.0.1/go.mod h1:Ag7xdRQ9GOCL/pwbDe4zJv6SlfYdROArc8O+qIKhRc4=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
@@ -62,6 +84,12 @@ github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
+github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -71,6 +99,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -131,8 +161,12 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mackee/go-readability v0.3.1 h1:DUwcwlhNLPtrBkGyJPcKp51oOKBvZvvMDPPFFLUIcKc=
github.com/mackee/go-readability v0.3.1/go.mod h1:lfyLr0PJ+fQ+z6r6IBrexFxP4AoVsaDJAGvMcoJ4UAM=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -148,6 +182,18 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
+github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
+github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
+github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1 h1:YYLFUQMdeCyUVUAxE/IkPbkDfOVkUl9mbBdwkzU4UbE=
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1/go.mod h1:YMjMm+2l1ye+v1MeuUJ1QPxXKzWp+x8iqg4vWuKB3Ao=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
@@ -158,6 +204,10 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -168,6 +218,8 @@ github.com/pkoukk/tiktoken-go-loader v0.0.2 h1:LUKws63GV3pVHwH1srkBplBv+7URgmOmh
github.com/pkoukk/tiktoken-go-loader v0.0.2/go.mod h1:4mIkYyZooFlnenDlormIo6cd5wrlUKNr97wp9nGgEKo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rhnvrm/simples3 v0.10.1 h1:V/EbG6tC3F5MS7Vw5D02qIbNJom2jU+FVtGdrmsD67o=
@@ -175,6 +227,12 @@ github.com/rhnvrm/simples3 v0.10.1/go.mod h1:c2xW30bukipkBlWNnXG1wDjq3gykQ6ww2AB
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
+github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
+github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
+github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
+github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -185,6 +243,12 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
+github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.9.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
@@ -203,6 +267,12 @@ github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
+github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
+github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/zerodha/fastcache/stores/goredis/v9 v9.1.0 h1:DtP3AflFjGTyHjXgd3UxuFfvGqk0rmfzm9PRbuiYwjU=
+github.com/zerodha/fastcache/stores/goredis/v9 v9.1.0/go.mod h1:Pds6hwIUaduCzChTTS3JWKdXLhpdvjvF3r8rgB0huYk=
+github.com/zerodha/fastcache/v4 v4.2.1 h1:MbkZv/lwSgAqoCnP+b023wxNlqHch0CU/B6RXaLgZKI=
+github.com/zerodha/fastcache/v4 v4.2.1/go.mod h1:jfFLkiuyMIO8u7KlojdeLGaVyPuVXES0PRGxS7byr6s=
github.com/zerodha/fastglue v1.8.0 h1:yCfb8YwZLoFrzHiojRcie19olLDT48vjuinVn1Ge5Uc=
github.com/zerodha/fastglue v1.8.0/go.mod h1:+fB3j+iAz9Et56KapvdVoL79+m3h7NphR92TU4exWgk=
github.com/zerodha/logf v0.5.5 h1:AhxHlixHNYwhFjvlgTv6uO4VBKYKxx2I6SbHoHtWLBk=
@@ -211,6 +281,14 @@ github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0 h1:wkFp+/IM1cXjmidprZw8
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0/go.mod h1:HxUlesaeO/JuymHHoPQ+7GKVjrkCwEYiM/0+oyiaaDo=
github.com/zerodha/simplesessions/v3 v3.0.0 h1:seHwxVNnlCbp5nG8GFxSsRUdiHnfb39QdEW3J536O9Y=
github.com/zerodha/simplesessions/v3 v3.0.0/go.mod h1:lAK+CJmZRlbvfq+OnkB8Iyf6LWgjzvUuWYKX1XA51P0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
+go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
+go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
+go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
+go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
+go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
+go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -240,7 +318,6 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/internal/helpcenter/helpcenter.go b/internal/helpcenter/helpcenter.go
index 784e1440..a721faa7 100644
--- a/internal/helpcenter/helpcenter.go
+++ b/internal/helpcenter/helpcenter.go
@@ -203,6 +203,7 @@ type queries struct {
GetPublishedArticlesByCollection *sqlx.Stmt `query:"get-published-articles-by-collection"`
SearchPublishedArticles *sqlx.Stmt `query:"search-published-articles"`
IncrementArticleViewCount *sqlx.Stmt `query:"increment-article-view-count"`
+ IncrementPublishedArticleView *sqlx.Stmt `query:"increment-published-article-view-count"`
InsertArticleFeedback *sqlx.Stmt `query:"insert-article-feedback"`
InsertSearchQuery *sqlx.Stmt `query:"insert-search-query"`
@@ -957,6 +958,13 @@ func (m *Manager) IncrementArticleViewCount(id int) {
}
}
+// IncrementPublishedArticleView increments a published article's view count by its URL parts.
+func (m *Manager) IncrementPublishedArticleView(helpCenterSlug, articleSlug, locale string) {
+ if _, err := m.q.IncrementPublishedArticleView.Exec(helpCenterSlug, articleSlug, locale); err != nil {
+ m.lo.Error("error incrementing article view count", "error", err, "slug", articleSlug)
+ }
+}
+
// RecordArticleFeedback stores a reader's helpful/not-helpful vote for a published article.
func (m *Manager) RecordArticleFeedback(articleID int, isHelpful bool) error {
if _, err := m.q.InsertArticleFeedback.Exec(articleID, isHelpful); err != nil {
diff --git a/internal/helpcenter/queries.sql b/internal/helpcenter/queries.sql
index 8aad9891..5469796f 100644
--- a/internal/helpcenter/queries.sql
+++ b/internal/helpcenter/queries.sql
@@ -424,6 +424,13 @@ UPDATE help_articles
SET view_count = view_count + 1
WHERE id = $1;
+-- name: increment-published-article-view-count
+UPDATE help_articles a
+SET view_count = view_count + 1
+FROM article_collections c, help_centers h
+WHERE a.collection_id = c.id AND c.help_center_id = h.id
+ AND h.slug = $1 AND a.slug = $2 AND a.locale = $3 AND a.status = 'published';
+
-- name: insert-article-feedback
INSERT INTO help_article_feedback (article_id, is_helpful)
SELECT $1, $2
diff --git a/static/email-templates/base.html b/static/email-templates/base.html
index c22e6c47..5e8e40be 100644
--- a/static/email-templates/base.html
+++ b/static/email-templates/base.html
@@ -6,14 +6,10 @@
-
-
-