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 | |
| 717 | 717 | } | |
| 718 | 718 | projectID = project.ID | |
| 719 | 719 | 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 != "" { | |
| 724 | 734 | post, err := dbpool.FindPostWithSlug(cleanPath, user.ID, space) | |
| 725 | - | if err != nil { | |
| 726 | - | // skip | |
| 727 | - | } else { | |
| 735 | + | if err == nil { | |
| 728 | 736 | postID = post.ID | |
| 729 | 737 | } | |
| 730 | 738 | } |
+91,
-0
| ... | ... | @@ -3,6 +3,7 @@ package auth | |
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "log/slog" | |
| 8 | 9 | "net/http" |
| ... | ... | @@ -316,3 +317,93 @@ func bail(err error) { | |
| 316 | 317 | panic(bail) | |
| 317 | 318 | } | |
| 318 | 319 | } | |
| 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 ( | |
| 8 | 8 | "net/url" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | - | "strconv" | |
| 12 | 11 | "strings" | |
| 13 | 12 | "time" | |
| 14 | 13 |
| ... | ... | @@ -38,6 +37,7 @@ type PostItemData struct { | |
| 38 | 37 | UpdatedAtISO string | |
| 39 | 38 | UpdatedTimeAgo string | |
| 40 | 39 | Padding string | |
| 40 | + | Score int | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | type BlogPageData struct { |
| ... | ... | @@ -566,16 +566,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) { | |
| 566 | 566 | logger := router.GetLogger(r) | |
| 567 | 567 | cfg := router.GetCfg(r) | |
| 568 | 568 | ||
| 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() | |
| 579 | 570 | if err != nil { | |
| 580 | 571 | logger.Error("finding posts", "err", err.Error()) | |
| 581 | 572 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| ... | ... | @@ -590,37 +581,12 @@ func readHandler(w http.ResponseWriter, r *http.Request) { | |
| 590 | 581 | http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 591 | 582 | } | |
| 592 | 583 | ||
| 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 | - | ||
| 614 | 584 | data := ReadPageData{ | |
| 615 | - | Site: *cfg.GetSiteData(), | |
| 616 | - | NextPage: nextPage, | |
| 617 | - | PrevPage: prevPage, | |
| 618 | - | Tags: tags, | |
| 619 | - | HasFilter: tag != "", | |
| 585 | + | Site: *cfg.GetSiteData(), | |
| 620 | 586 | } | |
| 621 | 587 | ||
| 622 | 588 | curl := shared.NewCreateURL(cfg) | |
| 623 | - | for _, post := range pager.Data { | |
| 589 | + | for _, post := range posts { | |
| 624 | 590 | item := PostItemData{ | |
| 625 | 591 | URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)), | |
| 626 | 592 | BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)), |
| ... | ... | @@ -631,6 +597,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) { | |
| 631 | 597 | PublishAtISO: post.PublishAt.Format(time.RFC3339), | |
| 632 | 598 | UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt), | |
| 633 | 599 | UpdatedAtISO: post.UpdatedAt.Format(time.RFC3339), | |
| 600 | + | Score: post.Score, | |
| 634 | 601 | } | |
| 635 | 602 | data.Posts = append(data.Posts, item) | |
| 636 | 603 | } |
| ... | ... | @@ -808,7 +775,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 808 | 775 | logger := router.GetLogger(r) | |
| 809 | 776 | cfg := router.GetCfg(r) | |
| 810 | 777 | ||
| 811 | - | pager, err := dbpool.FindPostsByFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space) | |
| 778 | + | posts, err := dbpool.FindPopularPosts() | |
| 812 | 779 | if err != nil { | |
| 813 | 780 | logger.Error("find all posts", "err", err.Error()) | |
| 814 | 781 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| ... | ... | @@ -836,7 +803,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) { | |
| 836 | 803 | curl := shared.CreateURLFromRequest(cfg, r) | |
| 837 | 804 | ||
| 838 | 805 | var feedItems []*feeds.Item | |
| 839 | - | for _, post := range pager.Data { | |
| 806 | + | for _, post := range posts { | |
| 840 | 807 | content := "" | |
| 841 | 808 | ext := filepath.Ext(post.Filename) | |
| 842 | 809 | switch ext { |
+1,
-0
| ... | ... | @@ -51,6 +51,7 @@ | |
| 51 | 51 | <a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a> | |
| 52 | 52 | </address> | |
| 53 | 53 | </div> | |
| 54 | + | <span class="text-sm text-grey font-italic">{{.Score}}</span> | |
| 54 | 55 | </div> | |
| 55 | 56 | </article> | |
| 56 | 57 | {{end}} |
+3,
-1
| ... | ... | @@ -158,6 +158,7 @@ type Post struct { | |
| 158 | 158 | ||
| 159 | 159 | // computed | |
| 160 | 160 | IsVirtual bool `db:"-"` | |
| 161 | + | Score int `json:"score" db:"score"` | |
| 161 | 162 | } | |
| 162 | 163 | ||
| 163 | 164 | type Paginate[T any] struct { |
| ... | ... | @@ -601,11 +602,12 @@ type DB interface { | |
| 601 | 602 | FindExpiredPosts(space string) ([]*Post, error) | |
| 602 | 603 | FindPostWithFilename(filename string, userID string, space string) (*Post, error) | |
| 603 | 604 | FindPostWithSlug(slug string, userID string, space string) (*Post, error) | |
| 604 | - | FindPostsByFeed(pager *Pager, space string) (*Paginate[*Post], error) | |
| 605 | 605 | InsertPost(post *Post) (*Post, error) | |
| 606 | 606 | UpdatePost(post *Post) (*Post, error) | |
| 607 | 607 | RemovePosts(postIDs []string) error | |
| 608 | 608 | ||
| 609 | + | FindPopularPosts() ([]*Post, error) | |
| 610 | + | ||
| 609 | 611 | ReplaceTagsByPost(tags []string, postID string) error | |
| 610 | 612 | FindUserPostsByTag(pager *Pager, tag, userID, space string) (*Paginate[*Post], error) | |
| 611 | 613 | FindPostsByTag(pager *Pager, tag, space string) (*Paginate[*Post], error) |
+116,
-12
| ... | ... | @@ -12,6 +12,7 @@ import ( | |
| 12 | 12 | "sync" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/hashicorp/golang-lru/v2/expirable" | |
| 15 | 16 | "github.com/jmoiron/sqlx" | |
| 16 | 17 | _ "github.com/lib/pq" | |
| 17 | 18 | "github.com/picosh/pico/pkg/db" |
| ... | ... | @@ -30,6 +31,9 @@ var SelectPost = ` | |
| 30 | 31 | type PsqlDB struct { | |
| 31 | 32 | Logger *slog.Logger | |
| 32 | 33 | Db *sqlx.DB | |
| 34 | + | ||
| 35 | + | popularPostsCache *expirable.LRU[string, []*db.Post] | |
| 36 | + | popularPostsMu sync.Mutex | |
| 33 | 37 | } | |
| 34 | 38 | ||
| 35 | 39 | type RowScanner interface { |
| ... | ... | @@ -79,7 +83,8 @@ func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) { | |
| 79 | 83 | func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB { | |
| 80 | 84 | var err error | |
| 81 | 85 | d := &PsqlDB{ | |
| 82 | - | Logger: logger, | |
| 86 | + | Logger: logger, | |
| 87 | + | popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute), | |
| 83 | 88 | } | |
| 84 | 89 | d.Logger.Info("Connecting to postgres", "databaseUrl", databaseUrl) | |
| 85 | 90 |
| ... | ... | @@ -91,6 +96,12 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB { | |
| 91 | 96 | return d | |
| 92 | 97 | } | |
| 93 | 98 | ||
| 99 | + | func (me *PsqlDB) ClearPopularPostsCache() { | |
| 100 | + | if me.popularPostsCache != nil { | |
| 101 | + | me.popularPostsCache.Purge() | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 94 | 105 | func (me *PsqlDB) shouldBlockSingup(ip string) error { | |
| 95 | 106 | blocked := &db.BlockSignups{} | |
| 96 | 107 | 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 | |
| 494 | 505 | return pager, nil | |
| 495 | 506 | } | |
| 496 | 507 | ||
| 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 | + | ||
| 498 | 524 | query := ` | |
| 499 | - | SELECT * | |
| 500 | - | FROM ( | |
| 501 | - | SELECT DISTINCT ON (posts.user_id) | |
| 525 | + | WITH candidate_posts AS ( | |
| 526 | + | SELECT | |
| 502 | 527 | posts.id, | |
| 503 | 528 | posts.user_id, | |
| 504 | 529 | posts.filename, |
| ... | ... | @@ -515,17 +540,96 @@ func (me *PsqlDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*d | |
| 515 | 540 | WHERE | |
| 516 | 541 | hidden = FALSE | |
| 517 | 542 | 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) | |
| 524 | 596 | if err != nil { | |
| 525 | 597 | return nil, err | |
| 526 | 598 | } | |
| 527 | 599 | 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 | |
| 529 | 633 | } | |
| 530 | 634 | ||
| 531 | 635 | func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) { |
+83,
-7
| ... | ... | @@ -155,6 +155,7 @@ func setupTestSchema(db *sqlx.DB) error { | |
| 155 | 155 | ||
| 156 | 156 | func cleanupTestData(t *testing.T) { | |
| 157 | 157 | t.Helper() | |
| 158 | + | testDB.ClearPopularPostsCache() | |
| 158 | 159 | tables := []string{ | |
| 159 | 160 | "access_logs", "tuns_event_logs", "analytics_visits", | |
| 160 | 161 | "feed_items", "post_aliases", "post_tags", "posts", |
| ... | ... | @@ -760,21 +761,96 @@ func TestFindExpiredPosts(t *testing.T) { | |
| 760 | 761 | } | |
| 761 | 762 | } | |
| 762 | 763 | ||
| 763 | - | func TestFindPostsByFeed(t *testing.T) { | |
| 764 | + | func TestFindPopularPosts(t *testing.T) { | |
| 764 | 765 | cleanupTestData(t) | |
| 765 | 766 | ||
| 766 | 767 | user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "") | |
| 767 | 768 | ||
| 768 | 769 | 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}) | |
| 770 | 771 | ||
| 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() | |
| 773 | 849 | if err != nil { | |
| 774 | - | t.Fatalf("FindPostsByFeed failed: %v", err) | |
| 850 | + | t.Fatalf("FindPopularPosts failed: %v", err) | |
| 775 | 851 | } | |
| 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)) | |
| 778 | 854 | } | |
| 779 | 855 | } | |
| 780 | 856 |
+2,
-2
| ... | ... | @@ -76,8 +76,8 @@ func (me *StubDB) FindPost(postID string) (*db.Post, error) { | |
| 76 | 76 | return nil, errNotImpl | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 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 | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 83 | func (me *StubDB) InsertPost(post *db.Post) (*db.Post, error) { |
| ... | ... | @@ -156,7 +156,11 @@ func CleanHost(raw string) (string, error) { | |
| 156 | 156 | var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled") | |
| 157 | 157 | ||
| 158 | 158 | 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") { | |
| 160 | 164 | return ErrAnalyticsDisabled | |
| 161 | 165 | } | |
| 162 | 166 |