Skip to content

Commit 047fd2c

Browse files
committed
feat(feedback): feature-request form — stores feedback + emails owner via Resend
- Migration 0005: adds `feedback` table (id, message, email, name, page, created_at) - sqlc query InsertFeedback; regenerated querier/models - internal/mailer: Resend HTTP client; ErrNotConfigured sentinel for no-op when key absent - internal/feedback: Service + http.HandlerFunc — honeypot drop, 4000-char truncation, best-effort email goroutine - Config: RESEND_API_KEY, SHOPLIT_FEEDBACK_EMAIL, SHOPLIT_FEEDBACK_FROM - main.go: wires mailer + feedback service; mounts POST /api/public/feedback - web: submitFeedback() api-client helper; /feedback page (hero + form card, success/error states) - NavBar (marketing): Feedback link before Sign in - Footer: mailto → /feedback Link - Roadmap: both CTAs switched from mailto to /feedback Link
1 parent ac9ebb9 commit 047fd2c

15 files changed

Lines changed: 410 additions & 21 deletions

File tree

cmd/shoplit-api/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import (
1919
"github.com/mayur-tolexo/shoplit/internal/db"
2020
sqlcgen "github.com/mayur-tolexo/shoplit/internal/db/sqlc"
2121
"github.com/mayur-tolexo/shoplit/internal/exttoken"
22+
"github.com/mayur-tolexo/shoplit/internal/feedback"
2223
"github.com/mayur-tolexo/shoplit/internal/httpx"
24+
"github.com/mayur-tolexo/shoplit/internal/mailer"
2325
"github.com/mayur-tolexo/shoplit/internal/ogfetch"
2426
"github.com/mayur-tolexo/shoplit/internal/publicapi"
2527
"github.com/mayur-tolexo/shoplit/internal/redis"
@@ -60,6 +62,8 @@ func run() error {
6062
}
6163

6264
q := sqlcgen.New(pool)
65+
mlr := mailer.NewResend(cfg.ResendAPIKey, cfg.FeedbackFrom)
66+
fb := feedback.NewService(q, mlr, cfg.FeedbackEmail)
6367

6468
sm := auth.NewSessionManager(cfg.SessionSecret).
6569
WithSecure(cfg.CookieSecure).
@@ -99,6 +103,7 @@ func run() error {
99103
// Public, unauthenticated read endpoints
100104
r.Route("/api/public", func(r chi.Router) {
101105
publicapi.RegisterRoutes(r, svc)
106+
r.Post("/feedback", fb.Handler())
102107
})
103108

104109
// Authenticated creator endpoints

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ type Config struct {
5151
// XHR calls reach the backend cross-origin with credentials.
5252
GoogleOAuthRedirectURL string `env:"GOOGLE_OAUTH_REDIRECT_URL" envDefault:"http://localhost:8080/api/v1/auth/google/callback"`
5353

54+
// Resend (resend.com) API key for sending the feedback notification email.
55+
// Optional: if empty, feedback is still stored; the email is skipped (logged).
56+
ResendAPIKey string `env:"RESEND_API_KEY"`
57+
// Where feature-request notifications are sent, and the From line Resend uses.
58+
FeedbackEmail string `env:"SHOPLIT_FEEDBACK_EMAIL" envDefault:"mayur.das4@gmail.com"`
59+
FeedbackFrom string `env:"SHOPLIT_FEEDBACK_FROM" envDefault:"shoplit <onboarding@resend.dev>"`
60+
5461
// Origin allowed to make credentialed cross-origin requests to the API.
5562
// In dev this is the Next.js frontend.
5663
CORSAllowedOrigin string `env:"SHOPLIT_CORS_ORIGIN" envDefault:"http://localhost:3000"`
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE feedback;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Feature requests / feedback submitted from the website. Stored durably so
2+
-- nothing is lost even if the notification email fails.
3+
CREATE TABLE feedback (
4+
id BIGSERIAL PRIMARY KEY,
5+
message TEXT NOT NULL,
6+
email TEXT,
7+
name TEXT,
8+
page TEXT,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
10+
);

internal/db/queries.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,8 @@ SELECT id, user_id, revoked_at FROM extension_tokens WHERE token_hash = $1;
130130

131131
-- name: TouchExtensionToken :exec
132132
UPDATE extension_tokens SET last_used_at = now() WHERE id = $1;
133+
134+
-- ─── FEEDBACK ────────────────────────────────────────────────────────────────
135+
136+
-- name: InsertFeedback :exec
137+
INSERT INTO feedback (message, email, name, page) VALUES ($1, $2, $3, $4);

internal/db/sqlc/models.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/sqlc/querier.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/sqlc/queries.sql.go

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/feedback/feedback.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Package feedback handles feature-request and feedback submissions.
2+
package feedback
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"log/slog"
8+
"net/http"
9+
"strings"
10+
"time"
11+
12+
"github.com/jackc/pgx/v5/pgtype"
13+
"github.com/mayur-tolexo/shoplit/internal/mailer"
14+
sqlcgen "github.com/mayur-tolexo/shoplit/internal/db/sqlc"
15+
)
16+
17+
// Mailer is the interface used to send notification emails.
18+
type Mailer interface {
19+
Send(ctx context.Context, to, subject, text string) error
20+
}
21+
22+
// Service stores feedback and fires notification emails.
23+
type Service struct {
24+
q *sqlcgen.Queries
25+
mailer Mailer
26+
to string
27+
}
28+
29+
// NewService constructs a Service.
30+
func NewService(q *sqlcgen.Queries, m Mailer, to string) *Service {
31+
return &Service{q: q, mailer: m, to: to}
32+
}
33+
34+
type feedbackRequest struct {
35+
Message string `json:"message"`
36+
Email string `json:"email"`
37+
Name string `json:"name"`
38+
Page string `json:"page"`
39+
HP string `json:"hp"`
40+
}
41+
42+
// Handler returns an http.HandlerFunc that accepts feedback submissions.
43+
func (s *Service) Handler() http.HandlerFunc {
44+
return func(w http.ResponseWriter, r *http.Request) {
45+
var req feedbackRequest
46+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
47+
http.Error(w, "invalid JSON", http.StatusBadRequest)
48+
return
49+
}
50+
51+
// Honeypot — silently drop bots.
52+
if req.HP != "" {
53+
w.WriteHeader(http.StatusNoContent)
54+
return
55+
}
56+
57+
req.Message = strings.TrimSpace(req.Message)
58+
if req.Message == "" {
59+
http.Error(w, "message required", http.StatusBadRequest)
60+
return
61+
}
62+
const maxLen = 4000
63+
if len(req.Message) > maxLen {
64+
req.Message = req.Message[:maxLen]
65+
}
66+
67+
toText := func(v string) pgtype.Text {
68+
return pgtype.Text{String: v, Valid: v != ""}
69+
}
70+
71+
if err := s.q.InsertFeedback(r.Context(), sqlcgen.InsertFeedbackParams{
72+
Message: req.Message,
73+
Email: toText(req.Email),
74+
Name: toText(req.Name),
75+
Page: toText(req.Page),
76+
}); err != nil {
77+
slog.Error("feedback: insert", "err", err)
78+
http.Error(w, "internal error", http.StatusInternalServerError)
79+
return
80+
}
81+
82+
// Best-effort email — run in background so a Resend hiccup never fails the user.
83+
go func() {
84+
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
85+
defer cancel()
86+
87+
var parts []string
88+
parts = append(parts, "Message:\n"+req.Message)
89+
if req.Name != "" {
90+
parts = append(parts, "From: "+req.Name)
91+
}
92+
if req.Email != "" {
93+
parts = append(parts, "Email: "+req.Email)
94+
}
95+
if req.Page != "" {
96+
parts = append(parts, "Page: "+req.Page)
97+
}
98+
body := strings.Join(parts, "\n\n")
99+
100+
if err := s.mailer.Send(ctx, s.to, "shoplit feature request", body); err != nil {
101+
if err != mailer.ErrNotConfigured {
102+
slog.Error("feedback: send email", "err", err)
103+
}
104+
}
105+
}()
106+
107+
w.WriteHeader(http.StatusNoContent)
108+
}
109+
}

internal/mailer/resend.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Package mailer sends transactional email via the Resend HTTP API.
2+
package mailer
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"net/http"
11+
"time"
12+
)
13+
14+
// ErrNotConfigured is returned when no API key is set (email is a no-op).
15+
var ErrNotConfigured = errors.New("mailer: not configured")
16+
17+
type Resend struct {
18+
key, from string
19+
client *http.Client
20+
}
21+
22+
func NewResend(key, from string) *Resend {
23+
return &Resend{key: key, from: from, client: &http.Client{Timeout: 10 * time.Second}}
24+
}
25+
26+
// Send delivers a plain-text email. No-op error if not configured.
27+
func (r *Resend) Send(ctx context.Context, to, subject, text string) error {
28+
if r.key == "" {
29+
return ErrNotConfigured
30+
}
31+
payload, _ := json.Marshal(map[string]any{
32+
"from": r.from, "to": []string{to}, "subject": subject, "text": text,
33+
})
34+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(payload))
35+
if err != nil {
36+
return err
37+
}
38+
req.Header.Set("Authorization", "Bearer "+r.key)
39+
req.Header.Set("Content-Type", "application/json")
40+
resp, err := r.client.Do(req)
41+
if err != nil {
42+
return err
43+
}
44+
defer resp.Body.Close()
45+
if resp.StatusCode >= 300 {
46+
return fmt.Errorf("mailer: resend status %d", resp.StatusCode)
47+
}
48+
return nil
49+
}

0 commit comments

Comments
 (0)