Skip to content

Commit

Permalink
feat(plus): when purchasing membership, automatically enroll in rss feed
Browse files Browse the repository at this point in the history
In an effort to improve our communication with pico+ users, we want to
automatically enroll them into our user notification feed.
  • Loading branch information
neurosnap committed Jan 12, 2025
1 parent 4c0618e commit e889405
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 74 deletions.
132 changes: 58 additions & 74 deletions auth/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"strings"
"time"

"github.com/gorilla/feeds"
"github.com/picosh/pico/db"
"github.com/picosh/pico/db/postgres"
"github.com/picosh/pico/shared"
Expand Down Expand Up @@ -326,27 +325,6 @@ func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
}
}

func genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
if now.After(warning) {
content := fmt.Sprintf(
"Your pico+ membership is going to expire on %s",
expiresAt.Format("2006-01-02 15:04:05"),
)
return &feeds.Item{
Id: fmt.Sprintf("%d", warning.Unix()),
Title: fmt.Sprintf("pico+ %s expiration notice", txt),
Link: &feeds.Link{Href: "https://pico.sh"},
Content: content,
Created: warning,
Updated: warning,
Description: content,
Author: &feeds.Author{Name: "team pico"},
}
}

return nil
}

func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
apiToken := r.PathValue("token")
Expand All @@ -361,62 +339,15 @@ func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
return
}

href := fmt.Sprintf("https://auth.pico.sh/rss/%s", apiToken)

feed := &feeds.Feed{
Title: "pico+",
Link: &feeds.Link{Href: href},
Description: "get notified of important membership updates",
Author: &feeds.Author{Name: "team pico"},
}
var feedItems []*feeds.Item

now := time.Now()
ff, err := apiConfig.Dbpool.FindFeatureForUser(user.ID, "plus")
feed, err := shared.UserFeed(apiConfig.Dbpool, user.ID, apiToken)
if err != nil {
// still want to send an empty feed
} else {
createdAt := ff.CreatedAt
createdAtStr := createdAt.Format("2006-01-02 15:04:05")
id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
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.`
plus := &feeds.Item{
Id: id,
Title: fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
Link: &feeds.Link{Href: "https://pico.sh"},
Content: content,
Created: *createdAt,
Updated: *createdAt,
Description: content,
Author: &feeds.Author{Name: "team pico"},
}
feedItems = append(feedItems, plus)

oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
if mo != nil {
feedItems = append(feedItems, mo)
}

oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
if wk != nil {
feedItems = append(feedItems, wk)
}

oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
if day != nil {
feedItems = append(feedItems, day)
}
return
}

feed.Items = feedItems

rss, err := feed.ToAtom()
if err != nil {
apiConfig.Cfg.Logger.Error(err.Error())
http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
apiConfig.Cfg.Logger.Error("could not generate atom rss feed", "err", err.Error())
http.Error(w, "could not generate atom rss feed", http.StatusInternalServerError)
}

w.Header().Add("Content-Type", "application/atom+xml")
Expand Down Expand Up @@ -528,13 +459,23 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
status := event.Data.Attr.Status
txID := fmt.Sprint(event.Data.Attr.OrderNumber)

user, err := apiConfig.Dbpool.FindUserForName(username)
if err != nil {
logger.Error("no user found with username", "username", username)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("no user found with username"))
return
}

log := logger.With(
"username", username,
"email", email,
"created", created,
"paymentStatus", status,
"txId", txID,
)
log = shared.LoggerWithUser(log, user)

log.Info(
"order_created event",
)
Expand Down Expand Up @@ -562,13 +503,56 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
return
}

err = AddPlusFeedForUser(dbpool, user.ID, email)
if err != nil {
log.Error("failed to add feed for user", "err", err)
}

log.Info("successfully added pico+ user")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("successfully added pico+ user"))
}
}

// URL shortener for out pico+ URL.
func AddPlusFeedForUser(dbpool db.DB, userID, email string) error {
// check if they already have a post grepping for the auth rss url
posts, err := dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, userID, "feeds")
if err != nil {
return err
}

found := false
for _, post := range posts.Data {
if strings.Contains(post.Text, "https://auth.pico.sh/rss/") {
found = true
}
}

// don't need to do anything, they already have an auth post
if found {
return nil
}

token, err := dbpool.UpsertToken(userID, "pico-rss")
if err != nil {
return err
}

href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)
text := fmt.Sprintf(`=: email %s
=: digest_interval 1day
=> %s`, email, href)
_, err = dbpool.InsertPost(&db.Post{
UserID: userID,
Text: text,
Space: "feeds",
Slug: "pico-plus",
Filename: "pico-plus",
})
return err
}

// URL shortener for our pico+ URL.
func checkoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
Expand Down
32 changes: 32 additions & 0 deletions auth/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ func TestPaymentWebhook(t *testing.T) {
mux.ServeHTTP(responseRecorder, request)

testResponse(t, responseRecorder, 200, "text/plain")

posts, err := apiConfig.Dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, testUserID, "feeds")
if err != nil {
t.Error("could not find posts for user")
}
for _, post := range posts.Data {
if post.Filename != "pico-plus" {
continue
}
expectedText := `=: email [email protected]
=: digest_interval 1day
=> https://auth.pico.sh/rss/123`
if post.Text != expectedText {
t.Errorf("Want pico plus feed file %s, got %s", expectedText, post.Text)
}
}
}

func TestUser(t *testing.T) {
Expand Down Expand Up @@ -195,6 +211,7 @@ type ApiExample struct {

type AuthDb struct {
*stub.StubDB
Posts []*db.Post
}

func (a *AuthDb) AddPicoPlusUser(username, email, from, txid string) error {
Expand Down Expand Up @@ -230,6 +247,21 @@ func (a *AuthDb) FindFeatureForUser(userID string, feature string) (*db.FeatureF
return &db.FeatureFlag{ID: "2", UserID: userID, Name: "plus", ExpiresAt: &oneDayWarning, CreatedAt: &now}, nil
}

func (a *AuthDb) InsertPost(post *db.Post) (*db.Post, error) {
a.Posts = append(a.Posts, post)
return post, nil
}

func (a *AuthDb) FindPostsForUser(pager *db.Pager, userID, space string) (*db.Paginate[*db.Post], error) {
return &db.Paginate[*db.Post]{
Data: a.Posts,
}, nil
}

func (a *AuthDb) UpsertToken(string, string) (string, error) {
return "123", nil
}

func NewAuthDb(logger *slog.Logger) *AuthDb {
sb := stub.NewStubDB(logger)
return &AuthDb{
Expand Down
93 changes: 93 additions & 0 deletions shared/feed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package shared

import (
"fmt"
"time"

"github.com/gorilla/feeds"
"github.com/picosh/pico/db"
)

func UserFeed(me db.DB, userID, token string) (*feeds.Feed, error) {
var err error
if token == "" {
token, err = me.UpsertToken(userID, "pico-rss")
if err != nil {
return nil, err
}
}

href := fmt.Sprintf("https://auth.pico.sh/rss/%s", token)

feed := &feeds.Feed{
Title: "pico+",
Link: &feeds.Link{Href: href},
Description: "get notified of important membership updates",
Author: &feeds.Author{Name: "team pico"},
}
var feedItems []*feeds.Item

now := time.Now()
ff, err := me.FindFeatureForUser(userID, "plus")
if err != nil {
// still want to send an empty feed
} else {
createdAt := ff.CreatedAt
createdAtStr := createdAt.Format("2006-01-02 15:04:05")
id := fmt.Sprintf("pico-plus-activated-%d", createdAt.Unix())
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.`
plus := &feeds.Item{
Id: id,
Title: fmt.Sprintf("pico+ membership activated on %s", createdAtStr),
Link: &feeds.Link{Href: "https://pico.sh"},
Content: content,
Created: *createdAt,
Updated: *createdAt,
Description: content,
Author: &feeds.Author{Name: "team pico"},
}
feedItems = append(feedItems, plus)

oneMonthWarning := ff.ExpiresAt.AddDate(0, -1, 0)
mo := genFeedItem(now, *ff.ExpiresAt, oneMonthWarning, "1-month")
if mo != nil {
feedItems = append(feedItems, mo)
}

oneWeekWarning := ff.ExpiresAt.AddDate(0, 0, -7)
wk := genFeedItem(now, *ff.ExpiresAt, oneWeekWarning, "1-week")
if wk != nil {
feedItems = append(feedItems, wk)
}

oneDayWarning := ff.ExpiresAt.AddDate(0, 0, -2)
day := genFeedItem(now, *ff.ExpiresAt, oneDayWarning, "1-day")
if day != nil {
feedItems = append(feedItems, day)
}
}

feed.Items = feedItems
return feed, nil
}

func genFeedItem(now time.Time, expiresAt time.Time, warning time.Time, txt string) *feeds.Item {
if now.After(warning) {
content := fmt.Sprintf(
"Your pico+ membership is going to expire on %s",
expiresAt.Format("2006-01-02 15:04:05"),
)
return &feeds.Item{
Id: fmt.Sprintf("%d", warning.Unix()),
Title: fmt.Sprintf("pico+ %s expiration notice", txt),
Link: &feeds.Link{Href: "https://pico.sh"},
Content: content,
Created: warning,
Updated: warning,
Description: content,
Author: &feeds.Author{Name: "team pico"},
}
}

return nil
}
3 changes: 3 additions & 0 deletions tui/notifications/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ user-specific notifications. This is where we will send pico+
expiration notices, among other alerts. To be clear, this is
optional but **highly** recommended.
> As of 2025/01/11 we automatically add this feed for pico+ users
> when they purchase a membership.
Add this URL to your RSS feed reader:
%s
Expand Down

0 comments on commit e889405

Please sign in to comment.