Commit 9e473b2

Eric Bower  ·  2026-09-28 21:51:38 -0400 EDT
parent 7ac310c
refactor(prose): trending discovery feed

This changes the discovery feed to only show users that have prose access after
the invite system changes.

It also captures all post analytics for all prose users so we can use it for
the discovery feed ranking system.
9 files changed,  +323, -70
+15, -7
......@@ -717,14 +717,22 @@ func deserializeCaddyAccessLog(dbpool db.DB, access *AccessLog) (*db.AnalyticsVi
717717 }
718718 projectID = project.ID
719719 case "prose": // figure out post ID
720- if path == "" || path == "/" {
721- // ignore
722- } else {
723- cleanPath := strings.TrimPrefix(path, "/")
720+ cleanPath := path
721+ u, err := url.Parse(path)
722+ if err == nil {
723+ cleanPath = u.Path
724+ }
725+ cleanPath, _ = url.PathUnescape(cleanPath)
726+ cleanPath = strings.TrimPrefix(cleanPath, "/")
727+ cleanPath = strings.TrimSuffix(cleanPath, "/")
728+ cleanPath = strings.TrimSuffix(cleanPath, ".html")
729+ cleanPath = strings.TrimSuffix(cleanPath, ".md")
730+ cleanPath = strings.TrimSuffix(cleanPath, ".lxt")
731+ cleanPath = strings.TrimPrefix(cleanPath, "raw/")
732+
733+ if cleanPath != "" {
724734 post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space)
725- if err != nil {
726- // skip
727- } else {
735+ if err == nil {
728736 postID = post.ID
729737 }
730738 }
+91, -0
......@@ -3,6 +3,7 @@ package auth
33 import (
44 "bytes"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "log/slog"
89 "net/http"
......@@ -316,3 +317,93 @@ func bail(err error) {
316317 panic(bail)
317318 }
318319 }
320+
321+type stubDBWithFeatures struct {
322+ *stub.StubDB
323+ features map[string]bool
324+}
325+
326+func (s *stubDBWithFeatures) HasFeatureByUser(userID string, feature string) bool {
327+ return s.features[feature]
328+}
329+
330+func newStubDBWithFeatures(features ...string) *stubDBWithFeatures {
331+ featMap := make(map[string]bool)
332+ for _, f := range features {
333+ featMap[f] = true
334+ }
335+ return &stubDBWithFeatures{
336+ StubDB: stub.NewStubDB(shared.CreateLogger("test", false)),
337+ features: featMap,
338+ }
339+}
340+
341+func TestProsePostMetricDrainFeatureFlags(t *testing.T) {
342+ newPostVisit := func() *db.AnalyticsVisits {
343+ return &db.AnalyticsVisits{
344+ UserID: testUserID,
345+ PostID: "post-123",
346+ Host: "user-a.prose.sh",
347+ Path: "/my-post",
348+ IpAddress: "1.1.1.1",
349+ UserAgent: "Mozilla/5.0",
350+ Status: 200,
351+ }
352+ }
353+
354+ newNonPostVisit := func() *db.AnalyticsVisits {
355+ return &db.AnalyticsVisits{
356+ UserID: testUserID,
357+ PostID: "",
358+ Host: "user-a.prose.sh",
359+ Path: "/",
360+ IpAddress: "1.1.1.1",
361+ UserAgent: "Mozilla/5.0",
362+ Status: 200,
363+ }
364+ }
365+
366+ // 1. User without any features: both post and non-post should fail
367+ noFeaturesDB := newStubDBWithFeatures()
368+ err := router.AnalyticsVisitFromVisit(newPostVisit(), noFeaturesDB, "secret")
369+ if !errors.Is(err, router.ErrAnalyticsDisabled) {
370+ t.Fatalf("expected ErrAnalyticsDisabled for post without prose/plus, got: %v", err)
371+ }
372+ err = router.AnalyticsVisitFromVisit(newNonPostVisit(), noFeaturesDB, "secret")
373+ if !errors.Is(err, router.ErrAnalyticsDisabled) {
374+ t.Fatalf("expected ErrAnalyticsDisabled for non-post without analytics, got: %v", err)
375+ }
376+
377+ // 2. User with "prose" feature: post visit succeeds, non-post fails
378+ proseDB := newStubDBWithFeatures("prose")
379+ err = router.AnalyticsVisitFromVisit(newPostVisit(), proseDB, "secret")
380+ if err != nil {
381+ t.Fatalf("expected post visit to succeed with prose feature, got: %v", err)
382+ }
383+ err = router.AnalyticsVisitFromVisit(newNonPostVisit(), proseDB, "secret")
384+ if !errors.Is(err, router.ErrAnalyticsDisabled) {
385+ t.Fatalf("expected non-post visit to fail without analytics feature, got: %v", err)
386+ }
387+
388+ // 3. User with "plus" feature: post visit succeeds, non-post fails
389+ plusDB := newStubDBWithFeatures("plus")
390+ err = router.AnalyticsVisitFromVisit(newPostVisit(), plusDB, "secret")
391+ if err != nil {
392+ t.Fatalf("expected post visit to succeed with plus feature, got: %v", err)
393+ }
394+ err = router.AnalyticsVisitFromVisit(newNonPostVisit(), plusDB, "secret")
395+ if !errors.Is(err, router.ErrAnalyticsDisabled) {
396+ t.Fatalf("expected non-post visit to fail without analytics feature, got: %v", err)
397+ }
398+
399+ // 4. User with "analytics" only: post visit fails (requires prose/plus), non-post succeeds
400+ analyticsDB := newStubDBWithFeatures("analytics")
401+ err = router.AnalyticsVisitFromVisit(newPostVisit(), analyticsDB, "secret")
402+ if !errors.Is(err, router.ErrAnalyticsDisabled) {
403+ t.Fatalf("expected post visit to fail without prose or plus feature, got: %v", err)
404+ }
405+ err = router.AnalyticsVisitFromVisit(newNonPostVisit(), analyticsDB, "secret")
406+ if err != nil {
407+ t.Fatalf("expected non-post visit to succeed with analytics feature, got: %v", err)
408+ }
409+}
+7, -40
......@@ -8,7 +8,6 @@ import (
88 "net/url"
99 "os"
1010 "path/filepath"
11- "strconv"
1211 "strings"
1312 "time"
1413
......@@ -38,6 +37,7 @@ type PostItemData struct {
3837 UpdatedAtISO string
3938 UpdatedTimeAgo string
4039 Padding string
40+ Score int
4141 }
4242
4343 type BlogPageData struct {
......@@ -566,16 +566,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
566566 logger := router.GetLogger(r)
567567 cfg := router.GetCfg(r)
568568
569- page, _ := strconv.Atoi(r.URL.Query().Get("page"))
570- tag := r.URL.Query().Get("tag")
571- var pager *db.Paginate[*db.Post]
572- var err error
573- if tag == "" {
574- pager, err = dbpool.FindPostsByFeed(&db.Pager{Num: 30, Page: page}, cfg.Space)
575- } else {
576- pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
577- }
578-
569+ posts, err := dbpool.FindPopularPosts()
579570 if err != nil {
580571 logger.Error("finding posts", "err", err.Error())
581572 http.Error(w, err.Error(), http.StatusInternalServerError)
......@@ -590,37 +581,12 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
590581 http.Error(w, err.Error(), http.StatusInternalServerError)
591582 }
592583
593- nextPage := ""
594- if page < pager.Total-1 {
595- nextPage = fmt.Sprintf("/read?page=%d", page+1)
596- if tag != "" {
597- nextPage = fmt.Sprintf("%s&tag=%s", nextPage, tag)
598- }
599- }
600-
601- prevPage := ""
602- if page > 0 {
603- prevPage = fmt.Sprintf("/read?page=%d", page-1)
604- if tag != "" {
605- prevPage = fmt.Sprintf("%s&tag=%s", prevPage, tag)
606- }
607- }
608-
609- tags, err := dbpool.FindPopularTags(cfg.Space)
610- if err != nil {
611- logger.Error("find popular tags", "err", err.Error())
612- }
613-
614584 data := ReadPageData{
615- Site: *cfg.GetSiteData(),
616- NextPage: nextPage,
617- PrevPage: prevPage,
618- Tags: tags,
619- HasFilter: tag != "",
585+ Site: *cfg.GetSiteData(),
620586 }
621587
622588 curl := shared.NewCreateURL(cfg)
623- for _, post := range pager.Data {
589+ for _, post := range posts {
624590 item := PostItemData{
625591 URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
626592 BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
......@@ -631,6 +597,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
631597 PublishAtISO: post.PublishAt.Format(time.RFC3339),
632598 UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
633599 UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339),
600+ Score: post.Score,
634601 }
635602 data.Posts = append(data.Posts, item)
636603 }
......@@ -808,7 +775,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
808775 logger := router.GetLogger(r)
809776 cfg := router.GetCfg(r)
810777
811- pager, err := dbpool.FindPostsByFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
778+ posts, err := dbpool.FindPopularPosts()
812779 if err != nil {
813780 logger.Error("find all posts", "err", err.Error())
814781 http.Error(w, err.Error(), http.StatusInternalServerError)
......@@ -836,7 +803,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
836803 curl := shared.CreateURLFromRequest(cfg, r)
837804
838805 var feedItems []*feeds.Item
839- for _, post := range pager.Data {
806+ for _, post := range posts {
840807 content := ""
841808 ext := filepath.Ext(post.Filename)
842809 switch ext {
+1, -0
......@@ -51,6 +51,7 @@
5151 <a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a>
5252 </address>
5353 </div>
54+ <span class="text-sm text-grey font-italic">{{.Score}}</span>
5455 </div>
5556 </article>
5657 {{end}}
+3, -1
......@@ -158,6 +158,7 @@ type Post struct {
158158
159159 // computed
160160 IsVirtual bool `db:"-"`
161+ Score int `json:"score" db:"score"`
161162 }
162163
163164 type Paginate[T any] struct {
......@@ -601,11 +602,12 @@ type DB interface {
601602 FindExpiredPosts(space string) ([]*Post, error)
602603 FindPostWithFilename(filename string, userID string, space string) (*Post, error)
603604 FindPostWithSlug(slug string, userID string, space string) (*Post, error)
604- FindPostsByFeed(pager *Pager, space string) (*Paginate[*Post], error)
605605 InsertPost(post *Post) (*Post, error)
606606 UpdatePost(post *Post) (*Post, error)
607607 RemovePosts(postIDs []string) error
608608
609+ FindPopularPosts() ([]*Post, error)
610+
609611 ReplaceTagsByPost(tags []string, postID string) error
610612 FindUserPostsByTag(pager *Pager, tag, userID, space string) (*Paginate[*Post], error)
611613 FindPostsByTag(pager *Pager, tag, space string) (*Paginate[*Post], error)
+116, -12
......@@ -12,6 +12,7 @@ import (
1212 "sync"
1313 "time"
1414
15+ "github.com/hashicorp/golang-lru/v2/expirable"
1516 "github.com/jmoiron/sqlx"
1617 _ "github.com/lib/pq"
1718 "github.com/picosh/pico/pkg/db"
......@@ -30,6 +31,9 @@ var SelectPost = `
3031 type PsqlDB struct {
3132 Logger *slog.Logger
3233 Db *sqlx.DB
34+
35+ popularPostsCache *expirable.LRU[string, []*db.Post]
36+ popularPostsMu sync.Mutex
3337 }
3438
3539 type RowScanner interface {
......@@ -79,7 +83,8 @@ func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
7983 func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
8084 var err error
8185 d := &PsqlDB{
82- Logger: logger,
86+ Logger: logger,
87+ popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute),
8388 }
8489 d.Logger.Info("Connecting to postgres", "databaseUrl", databaseUrl)
8590
......@@ -91,6 +96,12 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
9196 return d
9297 }
9398
99+func (me *PsqlDB) ClearPopularPostsCache() {
100+ if me.popularPostsCache != nil {
101+ me.popularPostsCache.Purge()
102+ }
103+}
104+
94105 func (me *PsqlDB) shouldBlockSingup(ip string) error {
95106 blocked := &db.BlockSignups{}
96107 err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
......@@ -494,11 +505,25 @@ func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string
494505 return pager, nil
495506 }
496507
497-func (me *PsqlDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
508+func (me *PsqlDB) FindPopularPosts() ([]*db.Post, error) {
509+ if me.popularPostsCache != nil {
510+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
511+ return posts, nil
512+ }
513+ }
514+
515+ me.popularPostsMu.Lock()
516+ defer me.popularPostsMu.Unlock()
517+
518+ if me.popularPostsCache != nil {
519+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
520+ return posts, nil
521+ }
522+ }
523+
498524 query := `
499- SELECT *
500- FROM (
501- SELECT DISTINCT ON (posts.user_id)
525+ WITH candidate_posts AS (
526+ SELECT
502527 posts.id,
503528 posts.user_id,
504529 posts.filename,
......@@ -515,17 +540,96 @@ func (me *PsqlDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*d
515540 WHERE
516541 hidden = FALSE
517542 AND publish_at::date <= CURRENT_DATE
518- AND cur_space = $3
519- ORDER BY posts.user_id, publish_at DESC
520- ) AS latest_posts
521- ORDER BY publish_at DESC
522- LIMIT $1 OFFSET $2`
523- rs, err := me.Db.Queryx(query, page.Num, page.Num*page.Page, space)
543+ AND publish_at >= NOW() - INTERVAL '30 days'
544+ AND cur_space = 'prose'
545+ AND EXISTS (
546+ SELECT 1 FROM feature_flags
547+ WHERE feature_flags.user_id = posts.user_id
548+ AND (name = 'plus' OR name = 'prose')
549+ AND expires_at > NOW()
550+ )
551+ ),
552+ post_visitors AS (
553+ SELECT post_id, COUNT(DISTINCT ip_address) AS unique_visitors
554+ FROM analytics_visits
555+ WHERE post_id IN (SELECT id FROM candidate_posts)
556+ AND status = 200
557+ AND created_at >= NOW() - INTERVAL '30 days'
558+ GROUP BY post_id
559+ ),
560+ scored_posts AS (
561+ SELECT
562+ cp.*,
563+ COALESCE(pv.unique_visitors, 0) AS score,
564+ (
565+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
566+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
567+ ) AS rank_score,
568+ ROW_NUMBER() OVER (
569+ PARTITION BY cp.user_id
570+ ORDER BY (
571+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
572+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
573+ ) DESC, cp.publish_at DESC
574+ ) AS user_rank
575+ FROM candidate_posts cp
576+ LEFT JOIN post_visitors pv ON pv.post_id = cp.id
577+ )
578+ SELECT
579+ id,
580+ user_id,
581+ filename,
582+ slug,
583+ title,
584+ text,
585+ description,
586+ publish_at,
587+ username,
588+ updated_at,
589+ mime_type,
590+ score
591+ FROM scored_posts
592+ WHERE user_rank <= 3
593+ ORDER BY rank_score DESC, publish_at DESC
594+ LIMIT 30`
595+ rs, err := me.Db.Queryx(query)
524596 if err != nil {
525597 return nil, err
526598 }
527599 defer func() { _ = rs.Close() }()
528- return me.postPager(rs, page.Num, space, "")
600+
601+ var posts []*db.Post
602+ for rs.Next() {
603+ post := &db.Post{}
604+ err := rs.Scan(
605+ &post.ID,
606+ &post.UserID,
607+ &post.Filename,
608+ &post.Slug,
609+ &post.Title,
610+ &post.Text,
611+ &post.Description,
612+ &post.PublishAt,
613+ &post.Username,
614+ &post.UpdatedAt,
615+ &post.MimeType,
616+ &post.Score,
617+ )
618+ if err != nil {
619+ return nil, err
620+ }
621+
622+ posts = append(posts, post)
623+ }
624+ if rs.Err() != nil {
625+ return nil, rs.Err()
626+ }
627+
628+ if me.popularPostsCache != nil {
629+ me.popularPostsCache.Add("popular_posts", posts)
630+ }
631+
632+ return posts, nil
529633 }
530634
531635 func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
+83, -7
......@@ -155,6 +155,7 @@ func setupTestSchema(db *sqlx.DB) error {
155155
156156 func cleanupTestData(t *testing.T) {
157157 t.Helper()
158+ testDB.ClearPopularPostsCache()
158159 tables := []string{
159160 "access_logs", "tuns_event_logs", "analytics_visits",
160161 "feed_items", "post_aliases", "post_tags", "posts",
......@@ -760,21 +761,96 @@ func TestFindExpiredPosts(t *testing.T) {
760761 }
761762 }
762763
763-func TestFindPostsByFeed(t *testing.T) {
764+func TestFindPopularPosts(t *testing.T) {
764765 cleanupTestData(t)
765766
766767 user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
767768
768769 now := time.Now()
769- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
770+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
770771
771- pager := &db.Pager{Num: 10, Page: 0}
772- result, err := testDB.FindPostsByFeed(pager, "prose")
772+ // Before having feature flag, should not appear
773+ posts, err := testDB.FindPopularPosts()
774+ if err != nil {
775+ t.Fatalf("FindPopularPosts failed: %v", err)
776+ }
777+ if len(posts) != 0 {
778+ t.Errorf("expected 0 posts without feature flag, got %d", len(posts))
779+ }
780+
781+ // Add prose feature flag
782+ _, err = testDB.InsertFeature(user.ID, "prose", time.Now().Add(time.Hour*24))
783+ if err != nil {
784+ t.Fatalf("InsertFeature failed: %v", err)
785+ }
786+ testDB.ClearPopularPostsCache()
787+
788+ posts, err = testDB.FindPopularPosts()
789+ if err != nil {
790+ t.Fatalf("FindPopularPosts failed: %v", err)
791+ }
792+ if len(posts) != 1 {
793+ t.Fatalf("expected 1 post in popular posts, got %d", len(posts))
794+ }
795+ if posts[0].Score != 0 {
796+ t.Errorf("expected score 0, got %d", posts[0].Score)
797+ }
798+
799+ // Add visits
800+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200})
801+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.2", Status: 200})
802+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200}) // duplicate IP
803+
804+ // Before clearing cache, should return cached result (score 0)
805+ cachedPosts, err := testDB.FindPopularPosts()
806+ if err != nil {
807+ t.Fatalf("FindPopularPosts failed: %v", err)
808+ }
809+ if cachedPosts[0].Score != 0 {
810+ t.Errorf("expected cached score 0, got %d", cachedPosts[0].Score)
811+ }
812+
813+ // After clearing cache, should re-query and return updated score 2
814+ testDB.ClearPopularPostsCache()
815+ posts, err = testDB.FindPopularPosts()
816+ if err != nil {
817+ t.Fatalf("FindPopularPosts failed: %v", err)
818+ }
819+ if len(posts) != 1 {
820+ t.Fatalf("expected 1 post, got %d", len(posts))
821+ }
822+ if posts[0].Score != 2 {
823+ t.Errorf("expected score 2 (unique visitors), got %d", posts[0].Score)
824+ }
825+
826+ // Verify max 3 posts per user
827+ testDB.ClearPopularPostsCache()
828+ post2Time := now.Add(-1 * time.Hour)
829+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost2.md", Slug: "feedpost2", Title: "Feed Post 2", Space: "prose", PublishAt: &post2Time})
830+ post3Time := now.Add(-2 * time.Hour)
831+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost3.md", Slug: "feedpost3", Title: "Feed Post 3", Space: "prose", PublishAt: &post3Time})
832+ post4Time := now.Add(-3 * time.Hour)
833+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost4.md", Slug: "feedpost4", Title: "Feed Post 4", Space: "prose", PublishAt: &post4Time})
834+
835+ posts, err = testDB.FindPopularPosts()
836+ if err != nil {
837+ t.Fatalf("FindPopularPosts failed: %v", err)
838+ }
839+ if len(posts) != 3 {
840+ t.Errorf("expected max 3 posts for single user, got %d", len(posts))
841+ }
842+
843+ // Post older than 30 days should be excluded
844+ testDB.ClearPopularPostsCache()
845+ oldTime := now.Add(-31 * 24 * time.Hour)
846+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "oldpost.md", Slug: "oldpost", Title: "Old Post", Space: "prose", PublishAt: &oldTime})
847+
848+ posts, err = testDB.FindPopularPosts()
773849 if err != nil {
774- t.Fatalf("FindPostsByFeed failed: %v", err)
850+ t.Fatalf("FindPopularPosts failed: %v", err)
775851 }
776- if len(result.Data) < 1 {
777- t.Errorf("expected at least 1 post in feed, got %d", len(result.Data))
852+ if len(posts) != 3 {
853+ t.Errorf("expected still 3 posts (old post excluded), got %d", len(posts))
778854 }
779855 }
780856
+2, -2
......@@ -76,8 +76,8 @@ func (me *StubDB) FindPost(postID string) (*db.Post, error) {
7676 return nil, errNotImpl
7777 }
7878
79-func (me *StubDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
80- return &db.Paginate[*db.Post]{}, errNotImpl
79+func (me *StubDB) FindPopularPosts() ([]*db.Post, error) {
80+ return nil, errNotImpl
8181 }
8282
8383 func (me *StubDB) InsertPost(post *db.Post) (*db.Post, error) {
+5, -1
......@@ -156,7 +156,11 @@ func CleanHost(raw string) (string, error) {
156156 var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled")
157157
158158 func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool db.DB, secret string) error {
159- if !dbpool.HasFeatureByUser(visit.UserID, "analytics") {
159+ if visit.PostID != "" {
160+ if !dbpool.HasFeatureByUser(visit.UserID, "plus") && !dbpool.HasFeatureByUser(visit.UserID, "prose") {
161+ return ErrAnalyticsDisabled
162+ }
163+ } else if !dbpool.HasFeatureByUser(visit.UserID, "analytics") {
160164 return ErrAnalyticsDisabled
161165 }
162166