diff --git a/.env.example b/.env.example index dfc1bff..ea85214 100644 --- a/.env.example +++ b/.env.example @@ -25,13 +25,18 @@ R2_BUCKET= # Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev) R2_PUBLIC_URL= -# SMTP (password-reset + email-verification) +# SMTP (password-reset + email-verification + /feedback admin notifications) SMTP_HOST= SMTP_PORT=587 SMTP_USERNAME= SMTP_PASSWORD= SMTP_FROM="Prabhupāda Says " +# Comma-separated recipients for /feedback admin notifications. Empty +# disables the email fan-out (feedback rows still persist and show on +# the /stats page either way). +FEEDBACK_ADMIN_EMAILS= + # Push Notifications FCM_CREDENTIALS_FILE= APNS_KEY_FILE= diff --git a/internal/config/config.go b/internal/config/config.go index 0ac3ec9..55ab56d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" "github.com/joho/godotenv" @@ -36,13 +37,19 @@ type Config struct { R2Bucket string R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL - // SMTP — used for password-reset and email-verification mails. + // SMTP — used for password-reset, email-verification, and + // /feedback admin-notification mails. SMTPHost string SMTPPort int SMTPUsername string SMTPPassword string SMTPFrom string // "Prabhupāda Says " + // Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the + // /feedback notification fan-out. Empty disables the email send + // (the DB row is still written and visible via /stats). + FeedbackAdminEmails []string + // Push Notifications (existing — unchanged). FCMCredentialsFile string APNsKeyFile string @@ -95,6 +102,8 @@ func Load() (*Config, error) { SMTPPassword: getEnv("SMTP_PASSWORD", ""), SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"), + FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")), + FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""), APNsKeyID: getEnv("APNS_KEY_ID", ""), @@ -147,3 +156,20 @@ func getEnvBool(key string, fallback bool) bool { } return fallback } + +// parseList splits a comma-separated env value, trimming whitespace +// and discarding empty entries. Returns nil for empty input so +// callers can range over it safely without a length check. +func parseList(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/handler/feedback.go b/internal/handler/feedback.go new file mode 100644 index 0000000..8d203fa --- /dev/null +++ b/internal/handler/feedback.go @@ -0,0 +1,70 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/response" + "github.com/prabhupadasays/spsays-bff/pkg/validator" +) + +// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it +// with OptionalAuth so a session cookie populates user_id when one is +// present, and anonymous callers are still accepted (they have to +// supply contact_email instead). +type FeedbackHandler struct{ svc *service.FeedbackService } + +func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler { + return &FeedbackHandler{svc: svc} +} + +type submitFeedbackReq struct { + Category string `json:"category"` + Message string `json:"message"` + ContactEmail *string `json:"contact_email,omitempty"` +} + +func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) { + var req submitFeedbackReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + + v := validator.New() + validator.Required(v, "category", req.Category) + validator.Required(v, "message", req.Message) + validator.MaxLength(v, "message", req.Message, 5000) + if !v.Valid() { + response.Error(w, http.StatusBadRequest, v.Error()) + return + } + + // OptionalAuth seeds user_id when a valid session cookie was + // present; "" means anonymous. + uid := middleware.UserID(r.Context()) + var userIDPtr *string + if uid != "" { + userIDPtr = &uid + } + + // Soft-validate the contact email for the anonymous path. Authed + // callers' contact_email is ignored downstream, so skip the check. + if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" { + ev := validator.New() + validator.Email(ev, "contact_email", *req.ContactEmail) + if !ev.Valid() { + response.Error(w, http.StatusBadRequest, ev.Error()) + return + } + } + + f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail) + if err != nil { + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.JSON(w, http.StatusCreated, f) +} diff --git a/internal/handler/stats.go b/internal/handler/stats.go new file mode 100644 index 0000000..2026164 --- /dev/null +++ b/internal/handler/stats.go @@ -0,0 +1,130 @@ +package handler + +import ( + "html/template" + "net/http" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/repository" +) + +// StatsHandler renders a single-page HTML snapshot of the system — +// aggregate counts plus the most-recent feedback rows. Mounted at /stats +// (outside /api/v1, since it returns HTML not JSON) and intentionally +// public per current product call. If that ever needs to change, wrap +// the route in middleware in server.go. +type StatsHandler struct { + stats *repository.StatsRepo + feedback *repository.FeedbackRepo +} + +func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler { + return &StatsHandler{stats: stats, feedback: feedback} +} + +func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) { + snap, err := h.stats.Snapshot(r.Context()) + if err != nil { + http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError) + return + } + recent, err := h.feedback.Recent(r.Context(), 25) + if err != nil { + http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Caching off — this is an ops snapshot; staleness defeats the point. + w.Header().Set("Cache-Control", "no-store") + if err := statsTpl.Execute(w, map[string]any{ + "Stats": snap, + "Feedback": recent, + "GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"), + }); err != nil { + // Headers already flushed — best we can do is bail mid-stream. + return + } +} + +var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{ + "fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, +}).Parse(statsHTML)) + +const statsHTML = ` + + + +Stats · Prabhupāda Says + + + + +

System snapshot

+

Generated {{.GeneratedAt}}

+ +

Users & activity

+
+
Users
{{.Stats.Users}}+{{.Stats.UsersLast7Days}} / 7d
+
Posts
{{.Stats.Posts}}+{{.Stats.PostsLast7Days}} / 7d
+
Collections
{{.Stats.Collections}}{{.Stats.PublicCollections}} public
+
Bookmarks
{{.Stats.Bookmarks}}
+
Reshares
{{.Stats.Reshares}}
+
Feedback
{{.Stats.Feedback}}+{{.Stats.FeedbackLast7Days}} / 7d
+
+ +

Feedback by category

+{{if .Stats.FeedbackByCategory}} + {{range .Stats.FeedbackByCategory}} +
{{.Category}}{{.Count}}
+ {{end}} +{{else}} +

No feedback yet.

+{{end}} + +

Recent feedback

+{{if .Feedback}} + {{range .Feedback}} +
+
+ {{.Category}} + {{fmtTime .CreatedAt}} +
+
{{.Message}}
+
+ {{if .AuthorHandle}} + @{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}} + {{else if .ContactEmail}} + anonymous · {{.ContactEmail}} + {{else}} + anonymous + {{end}} +
+
+ {{end}} +{{else}} +

No feedback yet.

+{{end}} + +` diff --git a/internal/model/feedback.go b/internal/model/feedback.go new file mode 100644 index 0000000..4779892 --- /dev/null +++ b/internal/model/feedback.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// Feedback is a user-submitted message — bug report, feature request, +// or general comment — captured by the /feedback page. Surfaced to +// admins both via the /stats page and via an email fan-out on insert. +// +// UserID is a pointer so the model can carry anonymous submissions +// (logged-out visitors) and orphaned rows (the submitter has since +// deleted their account — the FK is set null). ContactEmail is the +// optional reply-to filled in by anonymous submitters. +// +// AuthorHandle / AuthorName / AuthorEmail are populated by joins in +// the repo's lookup helpers (used by the admin email template and the +// /stats page). They're nil for anonymous rows. +type Feedback struct { + ID string `json:"id"` + UserID *string `json:"user_id,omitempty"` + Category string `json:"category"` + Message string `json:"message"` + ContactEmail *string `json:"contact_email,omitempty"` + CreatedAt time.Time `json:"created_at"` + + AuthorHandle *string `json:"author_handle,omitempty"` + AuthorName *string `json:"author_name,omitempty"` + AuthorEmail *string `json:"-"` // server-side only; never serialised +} diff --git a/internal/model/social.go b/internal/model/social.go index aae40a3..9e21347 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -25,6 +25,14 @@ type Post struct { // always false for anonymous viewers (no viewer = no reshare state). // Drives the post-card "Reshare ↔ Send" swap on the frontend. ViewerHasReshared bool `json:"viewer_has_reshared"` + + // ResharedAt is the timestamp at which the feed owner reshared this + // post. Non-nil only for rows that appear in a profile feed because + // the profile owner reshared them (i.e. the row comes from the + // post_reshares union branch of ListByUser / ListByHandle). + // Authored-by-feed-owner rows leave this nil. Drives the + // "↻ Reshared" badge in the post card. + ResharedAt *time.Time `json:"reshared_at,omitempty"` } // PostImage represents one attached image on a post. diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go new file mode 100644 index 0000000..a16d0e6 --- /dev/null +++ b/internal/repository/feedback_repo.go @@ -0,0 +1,81 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// FeedbackRepo wraps Postgres access for the feedback table. +type FeedbackRepo struct{ pg *Postgres } + +func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} } + +// Create inserts a single feedback row. userID is a pointer so the +// authed and anonymous paths share one method; contactEmail is also a +// pointer because authed submissions don't carry one (we read the +// submitter's email from the joined user row at notify time). +func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) { + f := &model.Feedback{} + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO feedback (user_id, category, message, contact_email) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, category, message, contact_email, created_at`, + userID, category, message, contactEmail, + ).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt) + if err != nil { + return nil, fmt.Errorf("insert feedback: %w", err) + } + return f, nil +} + +// FindByID returns one feedback row joined with the submitter's +// handle + name + email if a user_id is present. Used after Create to +// assemble the admin notification email with the proper author block. +func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) { + f := &model.Feedback{} + err := r.pg.Pool.QueryRow(ctx, ` + SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at, + u.handle, u.name, u.email + FROM feedback f + LEFT JOIN users u ON u.id = f.user_id + WHERE f.id = $1`, id, + ).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, + &f.AuthorHandle, &f.AuthorName, &f.AuthorEmail) + if err != nil { + return nil, fmt.Errorf("find feedback: %w", err) + } + return f, nil +} + +// Recent returns the most-recent N feedback rows joined with users +// for display on /stats. NULL author columns mean anonymous (or the +// original submitter's account was since deleted). +func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) { + if limit <= 0 || limit > 200 { + limit = 25 + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at, + u.handle, u.name + FROM feedback f + LEFT JOIN users u ON u.id = f.user_id + ORDER BY f.created_at DESC + LIMIT $1`, limit, + ) + if err != nil { + return nil, fmt.Errorf("query recent feedback: %w", err) + } + defer rows.Close() + out := []model.Feedback{} + for rows.Next() { + var f model.Feedback + if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt, + &f.AuthorHandle, &f.AuthorName); err != nil { + return nil, fmt.Errorf("scan feedback: %w", err) + } + out = append(out, f) + } + return out, rows.Err() +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 66bd526..8452f84 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -126,41 +126,68 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) return p, nil } -// ListByHandle returns the public feed for a user, newest-first, paginated. +// ListByHandle returns the profile feed for a user — authored posts +// plus posts the user reshared — newest-first, paginated. Resolves +// handle → user_id and then delegates to ListByUser so both code paths +// produce the same shape (and so the union query lives in one place). +// Unknown handles return an empty page (not an error) — that matches +// the pre-reshare behaviour. func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { - var total int - if err := r.pg.Pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM posts p - JOIN users u ON u.id = p.user_id - WHERE u.handle = $1`, handle, - ).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count posts: %w", err) + var userID string + if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []model.Post{}, 0, nil + } + return nil, 0, fmt.Errorf("lookup user by handle: %w", err) } - - const q = ` - SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''), - p.created_at, p.updated_at, u.name, u.handle, u.avatar_url - FROM posts p - JOIN users u ON u.id = p.user_id - WHERE u.handle = $1 - ORDER BY p.created_at DESC - LIMIT $2 OFFSET $3` - return r.collectPosts(ctx, total, q, handle, limit, offset) + return r.ListByUser(ctx, userID, limit, offset) } -// ListByUser returns the user's own posts (private listing — same shape). +// ListByUser returns the user's profile feed — authored posts plus +// posts they reshared. Each row carries a reshared_at timestamp that +// is NULL for authored rows and equal to post_reshares.created_at for +// reshared rows; rows are sorted by COALESCE(reshared_at, created_at) +// so fresh reshares of old posts surface at the top, matching the +// Twitter/X model. +// +// `total` is the union count so pagination spans the combined feed. func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { var total int - if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count posts: %w", err) + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT + (SELECT COUNT(*) FROM posts WHERE user_id = $1) + + (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`, + userID, + ).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count feed: %w", err) } const q = ` - SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''), - p.created_at, p.updated_at, u.name, u.handle, u.avatar_url - FROM posts p - JOIN users u ON u.id = p.user_id - WHERE p.user_id = $1 - ORDER BY p.created_at DESC + SELECT id, user_id, caption, source_url, source_title, + created_at, updated_at, author_name, author_handle, + author_avatar_url, reshared_at + FROM ( + SELECT p.id, p.user_id, p.caption, p.source_url, + COALESCE(p.source_title, '') AS source_title, + p.created_at, p.updated_at, + u.name AS author_name, u.handle AS author_handle, + u.avatar_url AS author_avatar_url, + NULL::timestamptz AS reshared_at + FROM posts p + JOIN users u ON u.id = p.user_id + WHERE p.user_id = $1 + UNION ALL + SELECT p.id, p.user_id, p.caption, p.source_url, + COALESCE(p.source_title, '') AS source_title, + p.created_at, p.updated_at, + u.name AS author_name, u.handle AS author_handle, + u.avatar_url AS author_avatar_url, + r.created_at AS reshared_at + FROM post_reshares r + JOIN posts p ON p.id = r.post_id + JOIN users u ON u.id = p.user_id + WHERE r.user_id = $1 + ) feed + ORDER BY COALESCE(reshared_at, created_at) DESC, id LIMIT $2 OFFSET $3` return r.collectPosts(ctx, total, q, userID, limit, offset) } @@ -178,13 +205,20 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error { return nil } -// CountByUser is used by services that need to know how many posts a user -// has — currently the public-profile endpoint only. +// CountByUser returns the profile-feed total — authored + reshared — +// which is what the profile header's "N posts" badge displays. Stays +// in sync with ListByUser's total so the badge never disagrees with +// the page contents below it. func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) { var n int - err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n) + err := r.pg.Pool.QueryRow(ctx, ` + SELECT + (SELECT COUNT(*) FROM posts WHERE user_id = $1) + + (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`, + userID, + ).Scan(&n) if err != nil { - return 0, fmt.Errorf("count user posts: %w", err) + return 0, fmt.Errorf("count feed: %w", err) } return n, nil } @@ -206,6 +240,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar &p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt, &p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, + &p.ResharedAt, ); err != nil { return nil, 0, fmt.Errorf("scan post: %w", err) } diff --git a/internal/repository/stats_repo.go b/internal/repository/stats_repo.go new file mode 100644 index 0000000..0d0d898 --- /dev/null +++ b/internal/repository/stats_repo.go @@ -0,0 +1,84 @@ +package repository + +import ( + "context" + "fmt" + "time" +) + +// StatsRepo runs the aggregate count queries that power /stats. Lives +// in its own file rather than spreading COUNT methods across every +// domain repo — keeps the at-a-glance "system snapshot" queries in +// one place and one trip into the pool. +type StatsRepo struct{ pg *Postgres } + +func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} } + +// StatsSnapshot bundles totals, last-7-days counts, and feedback +// category breakdowns. One value rendered by /stats per request. +type StatsSnapshot struct { + Users int + UsersLast7Days int + Posts int + PostsLast7Days int + Collections int + PublicCollections int + Bookmarks int + Reshares int + Feedback int + FeedbackLast7Days int + FeedbackByCategory []CategoryCount +} + +type CategoryCount struct { + Category string + Count int +} + +// Snapshot runs every count needed by /stats. Queries run in series — +// the table sizes are modest enough that chasing parallelism here +// would buy sub-ms wins for added complexity. +func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) { + s := &StatsSnapshot{} + sevenAgo := time.Now().Add(-7 * 24 * time.Hour) + + scalars := []struct { + sql string + args []any + dst *int + }{ + {`SELECT COUNT(*) FROM users`, nil, &s.Users}, + {`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days}, + {`SELECT COUNT(*) FROM posts`, nil, &s.Posts}, + {`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days}, + {`SELECT COUNT(*) FROM collections`, nil, &s.Collections}, + {`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections}, + {`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks}, + {`SELECT COUNT(*) FROM post_reshares`, nil, &s.Reshares}, + {`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback}, + {`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days}, + } + for _, q := range scalars { + if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil { + return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err) + } + } + + rows, err := r.pg.Pool.Query(ctx, ` + SELECT category, COUNT(*) + FROM feedback + GROUP BY category + ORDER BY COUNT(*) DESC, category ASC`) + if err != nil { + return nil, fmt.Errorf("feedback by category: %w", err) + } + defer rows.Close() + for rows.Next() { + var cc CategoryCount + if err := rows.Scan(&cc.Category, &cc.Count); err != nil { + return nil, fmt.Errorf("scan category: %w", err) + } + s.FeedbackByCategory = append(s.FeedbackByCategory, cc) + } + return s, rows.Err() +} diff --git a/internal/server/server.go b/internal/server/server.go index 5ccd009..16aed2a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -50,6 +50,8 @@ type Server struct { bookmarkH *handler.BookmarkHandler collectionH *handler.CollectionHandler profileH *handler.ProfileHandler + feedbackH *handler.FeedbackHandler + statsH *handler.StatsHandler // Auth state (nil when Postgres is unavailable — /auth and /users return 503) authIssuer *auth.Issuer @@ -164,6 +166,14 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc) s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo) s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo) + + // Feedback + stats — share Postgres with the rest of /api/v1 and + // the existing mailer instance. + feedbackRepo := repository.NewFeedbackRepo(s.pg) + statsRepo := repository.NewStatsRepo(s.pg) + feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger) + s.feedbackH = handler.NewFeedbackHandler(feedbackSvc) + s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo) } // initShortener wires Postgres + Redis + the shorturl handler/workers. @@ -276,6 +286,14 @@ func (s *Server) setupRouter() { w.Write([]byte(`{"status":"ok"}`)) }) + // Public ops snapshot — aggregate counts + recent feedback rendered + // as HTML. Lives outside /api/v1 because it returns HTML, not JSON. + // Currently unauthenticated by product decision; gate here if that + // ever needs to change. + if s.statsH != nil { + r.Get("/stats", s.statsH.Render) + } + // API v1 r.Route("/api/v1", func(r chi.Router) { // Books @@ -601,6 +619,20 @@ func (s *Server) setupRouter() { } }) + // --- Feedback --- + // OptionalAuth so authed callers' user_id is captured + // automatically (handler reads middleware.UserID), and + // anonymous callers are still accepted (they supply + // contact_email instead). + r.Group(func(r chi.Router) { + if s.feedbackH == nil { + r.HandleFunc("/feedback", s.authUnavailable) + return + } + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) + r.Post("/feedback", s.feedbackH.Submit) + }) + // Push Notifications r.Route("/notifications", func(r chi.Router) { // TODO: register push notification handlers diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go new file mode 100644 index 0000000..2b56cbe --- /dev/null +++ b/internal/service/feedback_service.go @@ -0,0 +1,167 @@ +// Package service — feedback flow. Sits between the feedback handler +// and FeedbackRepo: validates the submission, writes the row, then +// fans an HTML notification email out to every address listed in +// FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient +// send failures are logged and don't fail the request, because the +// row is already in the DB and visible on /stats. +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "strings" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/mailer" + "go.uber.org/zap" +) + +type FeedbackService struct { + repo *repository.FeedbackRepo + mail mailer.Mailer + admins []string + logger *zap.Logger +} + +func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService { + return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger} +} + +// Allowed categories — kept lowercase in storage so dashboards group +// cleanly. The frontend dropdown sends one of these exact strings. +var feedbackCategories = map[string]bool{ + "bug": true, + "feature": true, + "general": true, + "other": true, +} + +// Submit persists a feedback row and fires the admin email. +// +// userID is nil for anonymous submissions; contactEmail is the +// optional reply-to in that case. For authed users we ignore whatever +// the form sent and read the canonical email off the joined user row +// when assembling the notification — that keeps the reply path +// trustworthy (the form can't lie about who's writing). +func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) { + category = strings.ToLower(strings.TrimSpace(category)) + if !feedbackCategories[category] { + return nil, errors.New("invalid category") + } + message = strings.TrimSpace(message) + if message == "" { + return nil, errors.New("message is required") + } + if len(message) > 5000 { + return nil, errors.New("message too long (max 5000)") + } + + if userID == nil { + if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" { + return nil, errors.New("contact_email is required when not logged in") + } + trimmed := strings.TrimSpace(*contactEmail) + contactEmail = &trimmed + } else { + // Authed: ignore client-supplied contact_email; user row owns it. + contactEmail = nil + } + + f, err := s.repo.Create(ctx, userID, category, message, contactEmail) + if err != nil { + return nil, err + } + + // Hydrate the joined author fields for the email template. Failing + // the lookup isn't worth failing the request — we fall back to + // "anonymous" rather than 500ing on the user. + if userID != nil { + if full, err := s.repo.FindByID(ctx, f.ID); err == nil { + f = full + } else { + s.logger.Warn("feedback hydrate failed; sending email without author info", + zap.String("feedback_id", f.ID), zap.Error(err)) + } + } + + s.notifyAdmins(f) + return f, nil +} + +// notifyAdmins fires one HTML email per configured admin. Each +// failure logs and falls through — the row is persisted so a missed +// email is annoying but not data-losing. +func (s *FeedbackService) notifyAdmins(f *model.Feedback) { + if len(s.admins) == 0 { + s.logger.Info("feedback received but no admin recipients configured", + zap.String("feedback_id", f.ID)) + return + } + body, err := renderFeedbackEmail(f) + if err != nil { + s.logger.Warn("render feedback email failed", zap.Error(err)) + return + } + subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60)) + for _, to := range s.admins { + if err := s.mail.Send(to, subject, body); err != nil { + s.logger.Warn("feedback email send failed", + zap.String("to", to), zap.Error(err)) + } + } +} + +func summarize(s string, max int) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} + +var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`

New feedback received

+

Category: {{.Category}}
+Submitted: {{.CreatedAt}}
+From: {{.From}}

+

Message:

+
{{.Message}}
+

Feedback ID: {{.ID}}

`)) + +func renderFeedbackEmail(f *model.Feedback) (string, error) { + var from string + switch { + case f.AuthorHandle != nil && *f.AuthorHandle != "": + from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle) + if f.AuthorEmail != nil && *f.AuthorEmail != "" { + from += " — " + *f.AuthorEmail + } + case f.ContactEmail != nil && *f.ContactEmail != "": + from = "anonymous — " + *f.ContactEmail + default: + from = "anonymous" + } + var buf bytes.Buffer + err := feedbackEmailTpl.Execute(&buf, map[string]any{ + "Category": f.Category, + "Message": f.Message, + "From": from, + "CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"), + "ID": f.ID, + }) + if err != nil { + return "", err + } + return buf.String(), nil +} + +func derefOr(p *string, fallback string) string { + if p == nil || *p == "" { + return fallback + } + return *p +} diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 3e9d10a..8092661 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -180,8 +180,12 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, return posts, total, nil } -// ListMine is the owner's view of their own posts. Reshare hydration -// is skipped — the caller can't reshare their own posts. +// ListMine is the owner's view of their own profile feed — authored +// posts plus reshares (the repo's ListByUser unions the two). The +// viewer here is by construction the feed owner, so every reshared +// row is one the viewer themselves reshared — we flip +// ViewerHasReshared inline rather than spending a hydration round-trip +// to learn what we already know. func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) { if limit <= 0 || limit > 50 { limit = 20 @@ -189,7 +193,16 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset if offset < 0 { offset = 0 } - return s.posts.ListByUser(ctx, userID, limit, offset) + posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset) + if err != nil { + return nil, 0, err + } + for i := range posts { + if posts[i].ResharedAt != nil { + posts[i].ViewerHasReshared = true + } + } + return posts, total, nil } // ImagePresign returns a one-shot R2 PUT URL for a post image. diff --git a/migrations/0011_create_feedback.down.sql b/migrations/0011_create_feedback.down.sql new file mode 100644 index 0000000..b361141 --- /dev/null +++ b/migrations/0011_create_feedback.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_feedback_created; +DROP TABLE IF EXISTS feedback; diff --git a/migrations/0011_create_feedback.up.sql b/migrations/0011_create_feedback.up.sql new file mode 100644 index 0000000..04221c1 --- /dev/null +++ b/migrations/0011_create_feedback.up.sql @@ -0,0 +1,33 @@ +-- Feedback collects user-submitted bug reports, feature requests, and +-- general comments captured by the frontend's /feedback page. Two +-- consumers: +-- 1. The team — every insert fans out an HTML notification to every +-- address in FEEDBACK_ADMIN_EMAILS via the existing mailer. +-- 2. The /stats ops page — aggregate counts + the most-recent N rows +-- render together so we can scan recent feedback without having to +-- open the DB. +-- +-- user_id is nullable (and ON DELETE SET NULL on cascade) because: +-- • Anonymous visitors submit feedback too — they fill in the optional +-- contact_email field instead of leaving an account behind. +-- • When a user later deletes their account we keep the row but +-- forget who sent it. The content stays useful as product signal +-- even after the author is gone. +-- +-- contact_email is the soft reply-to surfaced only for the anonymous +-- case; the logged-in case derives reply-to from the joined user row's +-- email at admin-notification time. +-- +-- Index on created_at DESC keeps the /stats "recent feedback" list +-- cheap as the table grows. +CREATE TABLE IF NOT EXISTS feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + category TEXT NOT NULL, + message TEXT NOT NULL, + contact_email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_feedback_created + ON feedback (created_at DESC);