diff --git a/appview/db/db.go b/appview/db/db.go index 91421c62..bed57875 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1409,6 +1409,21 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "add-newsletter-preferences", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table if not exists newsletter_preferences ( + id integer primary key autoincrement, + user_did text not null unique, + status text not null check (status in ('subscribed', 'dismissed')), + email text, + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + create index if not exists idx_newsletter_prefs_user_did + on newsletter_preferences(user_did); + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/newsletter.go b/appview/db/newsletter.go new file mode 100644 index 00000000..b60a914f --- /dev/null +++ b/appview/db/newsletter.go @@ -0,0 +1,90 @@ +package db + +import ( + "database/sql" + "fmt" + "time" +) + +// Newsletter preference status values. Both states hide the signup widget; +// they're distinguished so we can later reconcile with Resend or re-prompt +// dismissed-but-not-subscribed users if we ever want to. +const ( + NewsletterStatusSubscribed = "subscribed" + NewsletterStatusDismissed = "dismissed" +) + +type NewsletterPref struct { + ID int64 + UserDid string + Status string + Email string + UpdatedAt time.Time +} + +// GetNewsletterPref returns the newsletter preference row for a user, or nil +// when no row exists (the caller should treat nil as "show the widget"). +func GetNewsletterPref(e Execer, did string) (*NewsletterPref, error) { + var ( + pref NewsletterPref + email sql.NullString + updatedAt string + ) + + err := e.QueryRow( + `select id, user_did, status, email, updated_at + from newsletter_preferences + where user_did = ?`, + did, + ).Scan(&pref.ID, &pref.UserDid, &pref.Status, &email, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if email.Valid { + pref.Email = email.String + } + // Best-effort: the column's default format is ISO-8601, but older rows + // (or manual inserts) might use other layouts. A parse failure is not + // fatal — the zero time is acceptable for a UI gating check. + if t, perr := time.Parse("2006-01-02T15:04:05Z", updatedAt); perr == nil { + pref.UpdatedAt = t + } + + return &pref, nil +} + +// UpsertNewsletterPref writes or replaces a user's newsletter preference and +// refreshes updated_at. Passing an empty email is fine — the column is +// nullable and is only meaningful for subscribed rows. +func UpsertNewsletterPref(e Execer, did, status, email string) error { + if status != NewsletterStatusSubscribed && status != NewsletterStatusDismissed { + return fmt.Errorf("invalid newsletter status %q", status) + } + + var emailArg any + if email != "" { + emailArg = email + } else { + emailArg = nil + } + + _, err := e.Exec( + `insert into newsletter_preferences (user_did, status, email, updated_at) + values (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + on conflict(user_did) do update set + status = excluded.status, + email = coalesce(excluded.email, newsletter_preferences.email), + updated_at = excluded.updated_at`, + did, + status, + emailArg, + ) + if err != nil { + return fmt.Errorf("upsert newsletter pref: %w", err) + } + return nil +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 15dff888..d86486f7 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -407,6 +407,11 @@ type TimelineParams struct { Repos []models.Repo GfiLabel *models.LabelDefinition BlueskyPosts []models.BskyPost + // ShowNewsletter controls whether the newsletter widget/CTA is rendered. + // For logged-in users it reflects their newsletter_preferences row; for + // anonymous visitors it is always true (dismissal falls back to + // localStorage on the client). + ShowNewsletter bool } func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { diff --git a/appview/pages/templates/timeline/home.html b/appview/pages/templates/timeline/home.html index a0ca103b..4e0ef24f 100644 --- a/appview/pages/templates/timeline/home.html +++ b/appview/pages/templates/timeline/home.html @@ -29,8 +29,7 @@ {{ template "features1" . }} {{ template "features2" . }} {{ template "recentUpdates" . }} - {{ template "newsletter" . }} - + {{ if .ShowNewsletter }}{{ template "newsletter" . }}{{ end }} {{ end }} diff --git a/appview/pages/templates/timeline/timeline.html b/appview/pages/templates/timeline/timeline.html index e2d6f344..03466cbd 100644 --- a/appview/pages/templates/timeline/timeline.html +++ b/appview/pages/templates/timeline/timeline.html @@ -26,19 +26,22 @@ row 1: [ gfi-banner (col-span-2) ] [ newsletter (col 3) ] row 2: [ timeline (col-span-2) ] [ trendingSidebar (col 3) ] - If the newsletter is dismissed, the gfi-banner widens to col-span-3 - (see the small init script at the bottom of this block). + When the newsletter is hidden (server-side via .ShowNewsletter, or + client-side via the dismiss button + localStorage), the gfi-banner + widens to col-span-3. */}}