- commit
- e889405
- parent
- 4c0618e
- author
- Eric Bower
- date
- 2025-01-10 21:21:20 -0500 EST
feat(plus): when purchasing membership, automatically enroll in rss feed In an effort to improve our communication with pico+ users, we want to automatically enroll them into our user notification feed.
4 files changed,
+186,
-74
+58,
-74
1@@ -16,7 +16,6 @@ import (
2 "strings"
3 "time"
4
5- "github.com/gorilla/feeds"
6 "github.com/picosh/pico/db"
7 "github.com/picosh/pico/db/postgres"
8 "github.com/picosh/pico/shared"
9@@ -326,27 +325,6 @@ func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
10 }
11 }
12
13-func genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
14- if now.After(warning) {
15- content := fmt.Sprintf(
16- "Your pico+ membership is going to expire on %s",
17- expiresAt.Format("2006-01-02 15:04:05"),
18- )
19- return &feeds.Item{
20- Id: fmt.Sprintf("%d", warning.Unix()),
21- Title: fmt.Sprintf("pico+ %s expiration notice", txt),
22- Link: &feeds.Link{Href: "https://pico.sh"},
23- Content: content,
24- Created: warning,
25- Updated: warning,
26- Description: content,
27- Author: &feeds.Author{Name: "team pico"},
28- }
29- }
30-
31- return nil
32-}
33-
34 func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
35 return func(w http.ResponseWriter, r *http.Request) {
36 apiToken := r.PathValue("token")
37@@ -361,62 +339,15 @@ func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
38 return
39 }
40
41- href := fmt.Sprintf("https://auth.pico.sh/rss/%s", apiToken)
42-
43- feed := &feeds.Feed{
44- Title: "pico+",
45- Link: &feeds.Link{Href: href},
46- Description: "get notified of important membership updates",
47- Author: &feeds.Author{Name: "team pico"},
48- }
49- var feedItems []*feeds.Item
50-
51- now := time.Now()
52- ff, err := apiConfig.Dbpool.FindFeatureForUser(user.ID, "plus")
53+ feed, err := shared.UserFeed(apiConfig.Dbpool, user.ID, apiToken)
54 if err != nil {
55- // still want to send an empty feed
56- } else {
57- createdAt := ff.CreatedAt
58- createdAtStr := createdAt.Format("2006-01-02 15:04:05")
59- id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
60- content := `Thanks for joining pico+! You now have access to all our premium services for exactly one year. We will send you pico+ expiration notifications through this RSS feed. Go to <a href="https://pico.sh/getting-started#next-steps">pico.sh/getting-started#next-steps</a> to start using our services.`
61- plus := &feeds.Item{
62- Id: id,
63- Title: fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
64- Link: &feeds.Link{Href: "https://pico.sh"},
65- Content: content,
66- Created: *createdAt,
67- Updated: *createdAt,
68- Description: content,
69- Author: &feeds.Author{Name: "team pico"},
70- }
71- feedItems = append(feedItems, plus)
72-
73- oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
74- mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
75- if mo != nil {
76- feedItems = append(feedItems, mo)
77- }
78-
79- oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
80- wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
81- if wk != nil {
82- feedItems = append(feedItems, wk)
83- }
84-
85- oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
86- day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
87- if day != nil {
88- feedItems = append(feedItems, day)
89- }
90+ return
91 }
92
93- feed.Items = feedItems
94-
95 rss, err := feed.ToAtom()
96 if err != nil {
97- apiConfig.Cfg.Logger.Error(err.Error())
98- http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
99+ apiConfig.Cfg.Logger.Error("could not generate atom rss feed", "err", err.Error())
100+ http.Error(w, "could not generate atom rss feed", http.StatusInternalServerError)
101 }
102
103 w.Header().Add("Content-Type", "application/atom+xml")
104@@ -528,6 +459,14 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
105 status := event.Data.Attr.Status
106 txID := fmt.Sprint(event.Data.Attr.OrderNumber)
107
108+ user, err := apiConfig.Dbpool.FindUserForName(username)
109+ if err != nil {
110+ logger.Error("no user found with username", "username", username)
111+ w.WriteHeader(http.StatusOK)
112+ _, _ = w.Write([]byte("no user found with username"))
113+ return
114+ }
115+
116 log := logger.With(
117 "username", username,
118 "email", email,
119@@ -535,6 +474,8 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
120 "paymentStatus", status,
121 "txId", txID,
122 )
123+ log = shared.LoggerWithUser(log, user)
124+
125 log.Info(
126 "order_created event",
127 )
128@@ -562,13 +503,56 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
129 return
130 }
131
132+ err = AddPlusFeedForUser(dbpool, user.ID, email)
133+ if err != nil {
134+ log.Error("failed to add feed for user", "err", err)
135+ }
136+
137 log.Info("successfully added pico+ user")
138 w.WriteHeader(http.StatusOK)
139 _, _ = w.Write([]byte("successfully added pico+ user"))
140 }
141 }
142
143-// URL shortener for out pico+ URL.
144+func AddPlusFeedForUser(dbpool db.DB, userID, email string) error {
145+ // check if they already have a post grepping for the auth rss url
146+ posts, err := dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, userID, "feeds")
147+ if err != nil {
148+ return err
149+ }
150+
151+ found := false
152+ for _, post := range posts.Data {
153+ if strings.Contains(post.Text, "https://auth.pico.sh/rss/") {
154+ found = true
155+ }
156+ }
157+
158+ // don't need to do anything, they already have an auth post
159+ if found {
160+ return nil
161+ }
162+
163+ token, err := dbpool.UpsertToken(userID, "pico-rss")
164+ if err != nil {
165+ return err
166+ }
167+
168+ href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)
169+ text := fmt.Sprintf(`=: email %s
170+=: digest_interval 1day
171+=> %s`, email, href)
172+ _, err = dbpool.InsertPost(&db.Post{
173+ UserID: userID,
174+ Text: text,
175+ Space: "feeds",
176+ Slug: "pico-plus",
177+ Filename: "pico-plus",
178+ })
179+ return err
180+}
181+
182+// URL shortener for our pico+ URL.
183 func checkoutHandler() http.HandlerFunc {
184 return func(w http.ResponseWriter, r *http.Request) {
185 username := r.PathValue("username")
+32,
-0
1@@ -52,6 +52,22 @@ func TestPaymentWebhook(t *testing.T) {
2 mux.ServeHTTP(responseRecorder, request)
3
4 testResponse(t, responseRecorder, 200, "text/plain")
5+
6+ posts, err := apiConfig.Dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, testUserID, "feeds")
7+ if err != nil {
8+ t.Error("could not find posts for user")
9+ }
10+ for _, post := range posts.Data {
11+ if post.Filename != "pico-plus" {
12+ continue
13+ }
14+ expectedText := `=: email auth@pico.test
15+=: digest_interval 1day
16+=> https://auth.pico.sh/rss/123`
17+ if post.Text != expectedText {
18+ t.Errorf("Want pico plus feed file %s, got %s", expectedText, post.Text)
19+ }
20+ }
21 }
22
23 func TestUser(t *testing.T) {
24@@ -195,6 +211,7 @@ type ApiExample struct {
25
26 type AuthDb struct {
27 *stub.StubDB
28+ Posts []*db.Post
29 }
30
31 func (a *AuthDb) AddPicoPlusUser(username, email, from, txid string) error {
32@@ -230,6 +247,21 @@ func (a *AuthDb) FindFeatureForUser(userID string, feature string) (*db.FeatureF
33 return &db.FeatureFlag{ID: "2", UserID: userID, Name: "plus", ExpiresAt: &oneDayWarning, CreatedAt: &now}, nil
34 }
35
36+func (a *AuthDb) InsertPost(post *db.Post) (*db.Post, error) {
37+ a.Posts = append(a.Posts, post)
38+ return post, nil
39+}
40+
41+func (a *AuthDb) FindPostsForUser(pager *db.Pager, userID, space string) (*db.Paginate[*db.Post], error) {
42+ return &db.Paginate[*db.Post]{
43+ Data: a.Posts,
44+ }, nil
45+}
46+
47+func (a *AuthDb) UpsertToken(string, string) (string, error) {
48+ return "123", nil
49+}
50+
51 func NewAuthDb(logger *slog.Logger) *AuthDb {
52 sb := stub.NewStubDB(logger)
53 return &AuthDb{
1@@ -0,0 +1,93 @@
2+package shared
3+
4+import (
5+ "fmt"
6+ "time"
7+
8+ "github.com/gorilla/feeds"
9+ "github.com/picosh/pico/db"
10+)
11+
12+func UserFeed(me db.DB, userID, token string) (*feeds.Feed, error) {
13+ var err error
14+ if token == "" {
15+ token, err = me.UpsertToken(userID, "pico-rss")
16+ if err != nil {
17+ return nil, err
18+ }
19+ }
20+
21+ href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)
22+
23+ feed := &feeds.Feed{
24+ Title: "pico+",
25+ Link: &feeds.Link{Href: href},
26+ Description: "get notified of important membership updates",
27+ Author: &feeds.Author{Name: "team pico"},
28+ }
29+ var feedItems []*feeds.Item
30+
31+ now := time.Now()
32+ ff, err := me.FindFeatureForUser(userID, "plus")
33+ if err != nil {
34+ // still want to send an empty feed
35+ } else {
36+ createdAt := ff.CreatedAt
37+ createdAtStr := createdAt.Format("2006-01-02 15:04:05")
38+ id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
39+ content := `Thanks for joining pico+! You now have access to all our premium services for exactly one year. We will send you pico+ expiration notifications through this RSS feed. Go to <a href="https://pico.sh/getting-started#next-steps">pico.sh/getting-started#next-steps</a> to start using our services.`
40+ plus := &feeds.Item{
41+ Id: id,
42+ Title: fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
43+ Link: &feeds.Link{Href: "https://pico.sh"},
44+ Content: content,
45+ Created: *createdAt,
46+ Updated: *createdAt,
47+ Description: content,
48+ Author: &feeds.Author{Name: "team pico"},
49+ }
50+ feedItems = append(feedItems, plus)
51+
52+ oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
53+ mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
54+ if mo != nil {
55+ feedItems = append(feedItems, mo)
56+ }
57+
58+ oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
59+ wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
60+ if wk != nil {
61+ feedItems = append(feedItems, wk)
62+ }
63+
64+ oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
65+ day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
66+ if day != nil {
67+ feedItems = append(feedItems, day)
68+ }
69+ }
70+
71+ feed.Items = feedItems
72+ return feed, nil
73+}
74+
75+func genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
76+ if now.After(warning) {
77+ content := fmt.Sprintf(
78+ "Your pico+ membership is going to expire on %s",
79+ expiresAt.Format("2006-01-02 15:04:05"),
80+ )
81+ return &feeds.Item{
82+ Id: fmt.Sprintf("%d", warning.Unix()),
83+ Title: fmt.Sprintf("pico+ %s expiration notice", txt),
84+ Link: &feeds.Link{Href: "https://pico.sh"},
85+ Content: content,
86+ Created: warning,
87+ Updated: warning,
88+ Description: content,
89+ Author: &feeds.Author{Name: "team pico"},
90+ }
91+ }
92+
93+ return nil
94+}
+3,
-0
1@@ -22,6 +22,9 @@ user-specific notifications. This is where we will send pico+
2 expiration notices, among other alerts. To be clear, this is
3 optional but **highly** recommended.
4
5+> As of 2025/01/11 we automatically add this feed for pico+ users
6+> when they purchase a membership.
7+
8 Add this URL to your RSS feed reader:
9
10 %s