Skip to content

Commit 162ec3a

Browse files
Merge pull request #7 from MohamadObeid9/go-backend-migration
feat(admin): enhance link click and page view tracking for admin users
2 parents 7b50d76 + 1061994 commit 162ec3a

9 files changed

Lines changed: 174 additions & 3 deletions

File tree

frontend/js/admin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ async function renderAdminAnalytics() {
162162

163163
const topLinks = Object.entries(clickMap)
164164
.sort((a, b) => b[1] - a[1])
165-
.slice(0, 5)
165+
.slice(0, 10)
166166
.map(([linkId, count]) => {
167167
// Resolve link label and course
168168
let info = { label: "Unknown Link", courseName: "Unknown Course" };

frontend/js/supabase.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ async function sbLogout() {
154154
}
155155

156156
async function trackVisit() {
157+
if (AppState.adminLoggedIn) return;
157158
if (sessionStorage.getItem("pv_tracked")) return;
158159
try {
159160
await apiRequest(`/api/page_views`, {
@@ -165,7 +166,7 @@ async function trackVisit() {
165166
}
166167

167168
function trackLinkClick(linkId) {
168-
if (!linkId) return;
169+
if (!linkId || AppState.adminLoggedIn) return;
169170
apiRequest(`/api/link_clicks`, {
170171
method: "POST",
171172
body: { link_id: linkId },

internal/api/linkclicks_handlers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ package api
33
import (
44
"net/http"
55

6+
"infolinks-backend/internal/middleware"
67
"infolinks-backend/internal/models"
78
)
89

910
func (h *Handler) handlePostLinkClick(w http.ResponseWriter, r *http.Request) {
11+
if middleware.IsAuthenticatedAdmin(string(h.jwtSecret), r.Header.Get("Authorization")) {
12+
w.WriteHeader(http.StatusNoContent)
13+
return
14+
}
15+
1016
var lc models.LinkClick
1117
if !decodeJSONBody(w, r, &lc) {
1218
return

internal/api/linkclicks_handlers_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"net/http/httptest"
99
"reflect"
1010
"testing"
11+
"time"
1112

13+
"github.com/golang-jwt/jwt/v5"
1214
"infolinks-backend/internal/errs"
1315
"infolinks-backend/internal/models"
1416
)
@@ -38,9 +40,24 @@ func (f *fakeLinkClickService) List(ctx context.Context) ([]models.LinkClick, er
3840
}
3941

4042
func TestHandlePostLinkClick(t *testing.T) {
43+
h := testHandler(t)
44+
validAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
45+
"admin": true,
46+
"exp": time.Now().Add(time.Hour).Unix(),
47+
})
48+
nonAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
49+
"admin": false,
50+
"exp": time.Now().Add(time.Hour).Unix(),
51+
})
52+
expiredAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
53+
"admin": true,
54+
"exp": time.Now().Add(-time.Hour).Unix(),
55+
})
56+
4157
tests := []struct {
4258
name string
4359
body string
60+
authHeader string
4461
createErr error
4562
statusWanted int
4663
errMsg string
@@ -54,6 +71,37 @@ func TestHandlePostLinkClick(t *testing.T) {
5471
wantCalls: 1,
5572
resultWanted: &models.LinkClick{LinkID: 42},
5673
},
74+
{
75+
name: "204 skips insert for valid admin bearer token",
76+
body: `{"link_id":42}`,
77+
authHeader: "Bearer " + validAdminToken,
78+
statusWanted: http.StatusNoContent,
79+
wantCalls: 0,
80+
},
81+
{
82+
name: "201 records click when admin token is expired",
83+
body: `{"link_id":42}`,
84+
authHeader: "Bearer " + expiredAdminToken,
85+
statusWanted: http.StatusCreated,
86+
wantCalls: 1,
87+
resultWanted: &models.LinkClick{LinkID: 42},
88+
},
89+
{
90+
name: "201 records click when token is not admin",
91+
body: `{"link_id":42}`,
92+
authHeader: "Bearer " + nonAdminToken,
93+
statusWanted: http.StatusCreated,
94+
wantCalls: 1,
95+
resultWanted: &models.LinkClick{LinkID: 42},
96+
},
97+
{
98+
name: "201 records click when bearer token is invalid",
99+
body: `{"link_id":42}`,
100+
authHeader: "Bearer not-a-jwt",
101+
statusWanted: http.StatusCreated,
102+
wantCalls: 1,
103+
resultWanted: &models.LinkClick{LinkID: 42},
104+
},
57105
{
58106
name: "400 invalid JSON body",
59107
body: `{`,
@@ -76,6 +124,9 @@ func TestHandlePostLinkClick(t *testing.T) {
76124
h := testHandler(t, withlinkClick(fake))
77125
req := httptest.NewRequest(http.MethodPost, "/api/link_clicks", bytes.NewBufferString(tt.body))
78126
req.Header.Set("Content-Type", "application/json")
127+
if tt.authHeader != "" {
128+
req.Header.Set("Authorization", tt.authHeader)
129+
}
79130
rr := httptest.NewRecorder()
80131

81132
h.handlePostLinkClick(rr, req)
@@ -84,6 +135,16 @@ func TestHandlePostLinkClick(t *testing.T) {
84135
t.Fatalf("status: got %d want %d body=%q", rr.Code, tt.statusWanted, rr.Body.String())
85136
}
86137

138+
if tt.statusWanted == http.StatusNoContent {
139+
if fake.createCalls != tt.wantCalls {
140+
t.Fatalf("service.Create calls: got %d want %d", fake.createCalls, tt.wantCalls)
141+
}
142+
if rr.Body.Len() != 0 {
143+
t.Fatalf("expected empty body, got %q", rr.Body.String())
144+
}
145+
return
146+
}
147+
87148
if tt.statusWanted != http.StatusCreated {
88149
var got map[string]string
89150
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {

internal/api/pageviews_handlers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ package api
33
import (
44
"net/http"
55

6+
"infolinks-backend/internal/middleware"
67
"infolinks-backend/internal/models"
78
)
89

910
func (h *Handler) handlePostPageView(w http.ResponseWriter, r *http.Request) {
11+
if middleware.IsAuthenticatedAdmin(string(h.jwtSecret), r.Header.Get("Authorization")) {
12+
w.WriteHeader(http.StatusNoContent)
13+
return
14+
}
15+
1016
var pv models.PageView
1117
if !decodeJSONBody(w, r, &pv) {
1218
return

internal/api/pageviews_handlers_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"net/http/httptest"
99
"reflect"
1010
"testing"
11+
"time"
1112

13+
"github.com/golang-jwt/jwt/v5"
1214
"infolinks-backend/internal/errs"
1315
"infolinks-backend/internal/models"
1416
)
@@ -38,9 +40,24 @@ func (f *fakePageViewService) List(ctx context.Context) ([]models.PageView, erro
3840
}
3941

4042
func TestHandlePostPageView(t *testing.T) {
43+
h := testHandler(t)
44+
validAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
45+
"admin": true,
46+
"exp": time.Now().Add(time.Hour).Unix(),
47+
})
48+
nonAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
49+
"admin": false,
50+
"exp": time.Now().Add(time.Hour).Unix(),
51+
})
52+
expiredAdminToken := signTestToken(t, h.jwtSecret, jwt.MapClaims{
53+
"admin": true,
54+
"exp": time.Now().Add(-time.Hour).Unix(),
55+
})
56+
4157
tests := []struct {
4258
name string
4359
body string
60+
authHeader string
4461
createErr error
4562
statusWanted int
4663
errMsg string
@@ -54,6 +71,37 @@ func TestHandlePostPageView(t *testing.T) {
5471
wantCalls: 1,
5572
resultWanted: &models.PageView{Page: "home"},
5673
},
74+
{
75+
name: "204 skips insert for valid admin bearer token",
76+
body: `{"page":"home"}`,
77+
authHeader: "Bearer " + validAdminToken,
78+
statusWanted: http.StatusNoContent,
79+
wantCalls: 0,
80+
},
81+
{
82+
name: "201 records visit when admin token is expired",
83+
body: `{"page":"home"}`,
84+
authHeader: "Bearer " + expiredAdminToken,
85+
statusWanted: http.StatusCreated,
86+
wantCalls: 1,
87+
resultWanted: &models.PageView{Page: "home"},
88+
},
89+
{
90+
name: "201 records visit when token is not admin",
91+
body: `{"page":"home"}`,
92+
authHeader: "Bearer " + nonAdminToken,
93+
statusWanted: http.StatusCreated,
94+
wantCalls: 1,
95+
resultWanted: &models.PageView{Page: "home"},
96+
},
97+
{
98+
name: "201 records visit when bearer token is invalid",
99+
body: `{"page":"home"}`,
100+
authHeader: "Bearer not-a-jwt",
101+
statusWanted: http.StatusCreated,
102+
wantCalls: 1,
103+
resultWanted: &models.PageView{Page: "home"},
104+
},
57105
{
58106
name: "400 invalid JSON body",
59107
body: `{`,
@@ -76,6 +124,9 @@ func TestHandlePostPageView(t *testing.T) {
76124
h := testHandler(t, withPageView(fake))
77125
req := httptest.NewRequest(http.MethodPost, "/api/page_views", bytes.NewBufferString(tt.body))
78126
req.Header.Set("Content-Type", "application/json")
127+
if tt.authHeader != "" {
128+
req.Header.Set("Authorization", tt.authHeader)
129+
}
79130
rr := httptest.NewRecorder()
80131

81132
h.handlePostPageView(rr, req)
@@ -84,6 +135,16 @@ func TestHandlePostPageView(t *testing.T) {
84135
t.Fatalf("status: got %d want %d body=%q", rr.Code, tt.statusWanted, rr.Body.String())
85136
}
86137

138+
if tt.statusWanted == http.StatusNoContent {
139+
if fake.createCalls != tt.wantCalls {
140+
t.Fatalf("service.Create calls: got %d want %d", fake.createCalls, tt.wantCalls)
141+
}
142+
if rr.Body.Len() != 0 {
143+
t.Fatalf("expected empty body, got %q", rr.Body.String())
144+
}
145+
return
146+
}
147+
87148
if tt.statusWanted != http.StatusCreated {
88149
var got map[string]string
89150
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {

internal/middleware/access_log.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const (
1616

1717
func accessLogDecision(method, path, appEnv string, status int) accessLogAction {
1818
switch {
19-
case isNoisyPath(path, method):
19+
case isNoisyPath(path, method), status == http.StatusNoContent:
2020
return accessLogSkip
2121
case status >= 400:
2222
return accessLogWarn

internal/middleware/access_log_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ func Test_accessLogDecision(t *testing.T) {
7979
method: http.MethodHead,
8080
want: accessLogSkip,
8181
},
82+
{
83+
name: "skip 204 status",
84+
status: http.StatusNoContent,
85+
want: accessLogSkip,
86+
},
8287
{
8388
name: "debug /api/admin paths",
8489
path: "/api/admin/reports",

internal/middleware/auth_middleware.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,37 @@ func RequireAdmin(jwtSecret string, next http.HandlerFunc) http.HandlerFunc {
4747
}
4848
}
4949

50+
func IsAuthenticatedAdmin(jwtSecret, tokenString string) bool {
51+
if tokenString == "" {
52+
return false
53+
}
54+
55+
// Handle "Bearer <token>" format
56+
if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
57+
tokenString = tokenString[7:]
58+
}
59+
60+
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
61+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
62+
return nil, errors.New("unexpected signing method")
63+
}
64+
return []byte(jwtSecret), nil
65+
})
66+
67+
if err != nil || !token.Valid {
68+
return false
69+
}
70+
claims, ok := token.Claims.(jwt.MapClaims)
71+
if !ok {
72+
return false
73+
}
74+
adminClaim, ok := claims["admin"].(bool)
75+
if !ok || !adminClaim {
76+
return false
77+
}
78+
return true
79+
}
80+
5081
func writeJSONErr(w http.ResponseWriter, status int, msg string) {
5182
w.Header().Set("Content-Type", "application/json")
5283
w.WriteHeader(status)

0 commit comments

Comments
 (0)