repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

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
M auth/api.go
+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")
M auth/api_test.go
+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{
A shared/feed.go
+93, -0
 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+}
M tui/notifications/notifications.go
+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