Skip to content

Commit 856a84b

Browse files
committed
feat(feedback): admin-only in-app feedback inbox at /dashboard/feedback
- Add ListFeedback SQL query (sqlc generated) to fetch up to 200 rows newest-first - Add SHOPLIT_ADMIN_USER_IDS config (default "1") for controlling inbox access - Extend feedback.Service with admins set; change NewService signature to accept []int64 - Add ListHandler (GET /api/v1/feedback) — 403 for non-admins, JSON array for admins - Parse admin IDs from cfg.AdminUserIDs in main.go; register route in /api/v1 group - Add listFeedback() + FeedbackItem to web/lib/api-client.ts - Add /dashboard/feedback server component: editorial card list, 403 guard, empty state
1 parent 96d2646 commit 856a84b

8 files changed

Lines changed: 213 additions & 3 deletions

File tree

cmd/shoplit-api/main.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"net/http"
88
"os"
99
"os/signal"
10+
"strconv"
11+
"strings"
1012
"syscall"
1113
"time"
1214

@@ -63,7 +65,18 @@ func run() error {
6365

6466
q := sqlcgen.New(pool)
6567
mlr := mailer.NewResend(cfg.ResendAPIKey, cfg.FeedbackFrom)
66-
fb := feedback.NewService(q, mlr, cfg.FeedbackEmail)
68+
69+
var adminIDs []int64
70+
for _, part := range strings.Split(cfg.AdminUserIDs, ",") {
71+
part = strings.TrimSpace(part)
72+
if part == "" {
73+
continue
74+
}
75+
if id, err := strconv.ParseInt(part, 10, 64); err == nil {
76+
adminIDs = append(adminIDs, id)
77+
}
78+
}
79+
fb := feedback.NewService(q, mlr, cfg.FeedbackEmail, adminIDs)
6780

6881
sm := auth.NewSessionManager(cfg.SessionSecret).
6982
WithSecure(cfg.CookieSecure).
@@ -110,6 +123,7 @@ func run() error {
110123
r.Route("/api/v1", func(r chi.Router) {
111124
r.Use(sm.RequireUser())
112125
r.Post("/extension/token", exttoken.MintHandler(q))
126+
r.Get("/feedback", fb.ListHandler())
113127
carts.RegisterRoutes(r, svc, fetcher)
114128
})
115129

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type Config struct {
5757
// Where feature-request notifications are sent, and the From line Resend uses.
5858
FeedbackEmail string `env:"SHOPLIT_FEEDBACK_EMAIL" envDefault:"mayur.das4@gmail.com"`
5959
FeedbackFrom string `env:"SHOPLIT_FEEDBACK_FROM" envDefault:"shoplit <onboarding@resend.dev>"`
60+
// Comma-separated user IDs allowed to view the in-app feedback inbox.
61+
AdminUserIDs string `env:"SHOPLIT_ADMIN_USER_IDS" envDefault:"1"`
6062

6163
// Origin allowed to make credentialed cross-origin requests to the API.
6264
// In dev this is the Next.js frontend.

internal/db/queries.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ UPDATE extension_tokens SET last_used_at = now() WHERE id = $1;
136136
-- name: InsertFeedback :exec
137137
INSERT INTO feedback (message, email, name, page) VALUES ($1, $2, $3, $4);
138138

139+
-- name: ListFeedback :many
140+
SELECT id, message, email, name, page, created_at
141+
FROM feedback ORDER BY created_at DESC LIMIT 200;
142+
139143
-- ─── ANALYTICS (reads) ──────────────────────────────────────────────────────
140144

141145
-- name: CartViews7d :one

internal/db/sqlc/querier.go

Lines changed: 1 addition & 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: 32 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: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import (
66
"encoding/json"
77
"log/slog"
88
"net/http"
9+
"strconv"
910
"strings"
1011
"time"
1112

1213
"github.com/jackc/pgx/v5/pgtype"
14+
"github.com/mayur-tolexo/shoplit/internal/auth"
1315
"github.com/mayur-tolexo/shoplit/internal/mailer"
1416
sqlcgen "github.com/mayur-tolexo/shoplit/internal/db/sqlc"
1517
)
@@ -24,11 +26,16 @@ type Service struct {
2426
q *sqlcgen.Queries
2527
mailer Mailer
2628
to string
29+
admins map[int64]bool
2730
}
2831

2932
// NewService constructs a Service.
30-
func NewService(q *sqlcgen.Queries, m Mailer, to string) *Service {
31-
return &Service{q: q, mailer: m, to: to}
33+
func NewService(q *sqlcgen.Queries, m Mailer, to string, adminIDs []int64) *Service {
34+
admins := make(map[int64]bool, len(adminIDs))
35+
for _, id := range adminIDs {
36+
admins[id] = true
37+
}
38+
return &Service{q: q, mailer: m, to: to, admins: admins}
3239
}
3340

3441
type feedbackRequest struct {
@@ -107,3 +114,48 @@ func (s *Service) Handler() http.HandlerFunc {
107114
w.WriteHeader(http.StatusNoContent)
108115
}
109116
}
117+
118+
type feedbackItem struct {
119+
ID string `json:"id"`
120+
Message string `json:"message"`
121+
Email string `json:"email"`
122+
Name string `json:"name"`
123+
Page string `json:"page"`
124+
CreatedAt string `json:"createdAt"`
125+
}
126+
127+
// ListHandler returns an http.HandlerFunc for admin-only listing of feedback.
128+
// It must be registered under the authenticated /api/v1 group.
129+
func (s *Service) ListHandler() http.HandlerFunc {
130+
return func(w http.ResponseWriter, r *http.Request) {
131+
uid, ok := auth.UserIDFromContext(r.Context())
132+
if !ok || !s.admins[uid] {
133+
http.Error(w, "forbidden", http.StatusForbidden)
134+
return
135+
}
136+
137+
rows, err := s.q.ListFeedback(r.Context())
138+
if err != nil {
139+
slog.Error("feedback: list", "err", err)
140+
http.Error(w, "internal error", http.StatusInternalServerError)
141+
return
142+
}
143+
144+
items := make([]feedbackItem, 0, len(rows))
145+
for _, row := range rows {
146+
items = append(items, feedbackItem{
147+
ID: strconv.FormatInt(row.ID, 10),
148+
Message: row.Message,
149+
Email: row.Email.String,
150+
Name: row.Name.String,
151+
Page: row.Page.String,
152+
CreatedAt: row.CreatedAt.Time.Format(time.RFC3339),
153+
})
154+
}
155+
156+
w.Header().Set("Content-Type", "application/json")
157+
if err := json.NewEncoder(w).Encode(items); err != nil {
158+
slog.Error("feedback: encode list", "err", err)
159+
}
160+
}
161+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { cookies } from "next/headers";
2+
import { listFeedback, type FeedbackItem } from "@/lib/api-client";
3+
4+
export const dynamic = "force-dynamic";
5+
6+
function formatDate(iso: string): string {
7+
try {
8+
return new Date(iso).toLocaleDateString("en-IN", {
9+
day: "numeric",
10+
month: "short",
11+
year: "numeric",
12+
});
13+
} catch {
14+
return iso;
15+
}
16+
}
17+
18+
export default async function FeedbackInboxPage() {
19+
const cookie = cookies().toString();
20+
21+
let items: FeedbackItem[] = [];
22+
let forbidden = false;
23+
let error = false;
24+
25+
try {
26+
items = await listFeedback({ cookie });
27+
} catch (e) {
28+
const msg = e instanceof Error ? e.message : String(e);
29+
if (msg.includes("403") || msg.toLowerCase().includes("forbidden")) {
30+
forbidden = true;
31+
} else {
32+
error = true;
33+
}
34+
}
35+
36+
if (forbidden) {
37+
return (
38+
<div className="flex items-center justify-center min-h-[40vh]">
39+
<p className="text-muted text-sm">You don&apos;t have access to this page.</p>
40+
</div>
41+
);
42+
}
43+
44+
if (error) {
45+
return (
46+
<div className="flex items-center justify-center min-h-[40vh]">
47+
<p className="text-muted text-sm">Something went wrong loading the feedback inbox.</p>
48+
</div>
49+
);
50+
}
51+
52+
return (
53+
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-10">
54+
<div className="mb-8">
55+
<h1 className="font-serif text-3xl sm:text-4xl tracking-tight leading-none mb-1">
56+
Feature requests
57+
</h1>
58+
<p className="text-muted text-sm">
59+
{items.length === 0
60+
? "No requests yet."
61+
: `${items.length} ${items.length === 1 ? "submission" : "submissions"}`}
62+
</p>
63+
</div>
64+
65+
{items.length === 0 ? (
66+
<div className="rounded-xl border border-rule bg-cream px-6 py-10 text-center">
67+
<p className="text-muted text-sm">No requests yet.</p>
68+
</div>
69+
) : (
70+
<ul className="flex flex-col gap-4">
71+
{items.map((item) => (
72+
<li
73+
key={item.id}
74+
className="rounded-xl border border-rule bg-cream px-5 py-4"
75+
>
76+
<p className="text-ink leading-relaxed mb-3 whitespace-pre-wrap">{item.message}</p>
77+
<p className="text-muted text-xs flex flex-wrap gap-x-2 gap-y-0.5">
78+
{(item.name || item.email) && (
79+
<span>
80+
{item.name && item.email
81+
? `${item.name} (${item.email})`
82+
: item.name || item.email}
83+
</span>
84+
)}
85+
{item.page && (
86+
<>
87+
<span aria-hidden="true">&middot;</span>
88+
<span className="truncate max-w-[200px]">{item.page}</span>
89+
</>
90+
)}
91+
<span aria-hidden="true">&middot;</span>
92+
<span>{formatDate(item.createdAt)}</span>
93+
</p>
94+
</li>
95+
))}
96+
</ul>
97+
)}
98+
</div>
99+
);
100+
}

web/lib/api-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,8 @@ export async function logout(): Promise<void> {
198198
export async function submitFeedback(input: { message: string; email?: string; name?: string; page?: string }): Promise<void> {
199199
await jsonFetch("/api/public/feedback", { method: "POST", body: JSON.stringify(input) });
200200
}
201+
202+
export interface FeedbackItem { id: string; message: string; email: string; name: string; page: string; createdAt: string }
203+
export async function listFeedback(opts?: { cookie?: string }): Promise<FeedbackItem[]> {
204+
return jsonFetch<FeedbackItem[]>("/api/v1/feedback", opts);
205+
}

0 commit comments

Comments
 (0)