From 225f7ae3d26742fd2d776af51a89c17dd88e367e Mon Sep 17 00:00:00 2001 From: Nick Savage Date: Fri, 3 Jan 2025 16:43:44 -0500 Subject: [PATCH] admin: unsubscribing users from mailing list --- go-backend/handlers/mail.go | 43 ++++++++++ go-backend/handlers/mail_test.go | 85 +++++++++++++++++++ go-backend/main.go | 1 + go-backend/tests/conftest.go | 6 ++ zettelkasten-front/src/api/users.ts | 22 +++++ .../src/pages/admin/AdminMailingListPage.tsx | 57 ++++++++++--- 6 files changed, 204 insertions(+), 10 deletions(-) diff --git a/go-backend/handlers/mail.go b/go-backend/handlers/mail.go index d725323..d2ddcbf 100644 --- a/go-backend/handlers/mail.go +++ b/go-backend/handlers/mail.go @@ -200,3 +200,46 @@ func (s *Handler) GetMessageRecipientsRoute(w http.ResponseWriter, r *http.Reque w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(recipients) } + +func (s *Handler) UnsubscribeMailingListRoute(w http.ResponseWriter, r *http.Request) { + // Admin check + userID := r.Context().Value("current_user").(int) + user, err := s.QueryUser(userID) + if err != nil { + http.Error(w, "User not found", http.StatusBadRequest) + return + } + if !user.IsAdmin { + http.Error(w, "Access denied", http.StatusUnauthorized) + return + } + + // Parse the request body + var request struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update the subscriber's status in the database + query := ` + UPDATE mailing_list + SET subscribed = false, updated_at = NOW() + WHERE email = $1 AND subscribed = true + RETURNING id + ` + var id int + err = s.DB.QueryRow(query, request.Email).Scan(&id) + if err != nil { + log.Printf("Error unsubscribing email %s: %v", request.Email, err) + http.Error(w, "Failed to unsubscribe email", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": fmt.Sprintf("Successfully unsubscribed %s", request.Email), + }) +} diff --git a/go-backend/handlers/mail_test.go b/go-backend/handlers/mail_test.go index 2bb9b55..1c51713 100644 --- a/go-backend/handlers/mail_test.go +++ b/go-backend/handlers/mail_test.go @@ -65,3 +65,88 @@ func TestGetMailingListSubscribersUnauthorized(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) } } + +func TestUnsubscribeMailingListSuccess(t *testing.T) { + s := setup() + defer tests.Teardown() + + // Add a test subscriber first + _, err := s.DB.Exec(` + INSERT INTO mailing_list (email, welcome_email_sent, subscribed) + VALUES ($1, $2, $3) + `, "test@example.com", true, true) + if err != nil { + t.Fatal(err) + } + + token, _ := tests.GenerateTestJWT(1) // Admin user + body := `{"email": "test@example.com"}` + req, err := http.NewRequest("POST", "/api/mailing-list/unsubscribe", tests.StringToReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(s.JwtMiddleware(s.UnsubscribeMailingListRoute)) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + // Verify the subscriber was actually unsubscribed + var subscribed bool + err = s.DB.QueryRow("SELECT subscribed FROM mailing_list WHERE email = $1", "test@example.com").Scan(&subscribed) + if err != nil { + t.Fatal(err) + } + if subscribed { + t.Error("subscriber was not unsubscribed") + } +} + +func TestUnsubscribeMailingListUnauthorized(t *testing.T) { + s := setup() + defer tests.Teardown() + + token, _ := tests.GenerateTestJWT(2) // Non-admin user + body := `{"email": "test@example.com"}` + req, err := http.NewRequest("POST", "/api/mailing-list/unsubscribe", tests.StringToReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(s.JwtMiddleware(s.UnsubscribeMailingListRoute)) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusUnauthorized { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) + } +} + +func TestUnsubscribeMailingListInvalidEmail(t *testing.T) { + s := setup() + defer tests.Teardown() + + token, _ := tests.GenerateTestJWT(1) // Admin user + body := `{"email": "nonexistent@example.com"}` + req, err := http.NewRequest("POST", "/api/mailing-list/unsubscribe", tests.StringToReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(s.JwtMiddleware(s.UnsubscribeMailingListRoute)) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } +} diff --git a/go-backend/main.go b/go-backend/main.go index c5985b2..150cb2c 100644 --- a/go-backend/main.go +++ b/go-backend/main.go @@ -218,6 +218,7 @@ func main() { addProtectedRoute(r, "/api/mailing-list/messages", h.GetMailingListMessagesRoute, "GET") addProtectedRoute(r, "/api/mailing-list/messages/send", h.SendMailingListMessageRoute, "POST") addProtectedRoute(r, "/api/mailing-list/messages/recipients", h.GetMessageRecipientsRoute, "GET") + addProtectedRoute(r, "/api/mailing-list/unsubscribe", h.UnsubscribeMailingListRoute, "POST") addRoute(r, "/api/billing/create_checkout_session", h.CreateCheckoutSession, "POST") addRoute(r, "/api/billing/success", h.GetSuccessfulSessionData, "GET") diff --git a/go-backend/tests/conftest.go b/go-backend/tests/conftest.go index b07b75a..3f228dc 100644 --- a/go-backend/tests/conftest.go +++ b/go-backend/tests/conftest.go @@ -11,6 +11,7 @@ import ( "math/rand" "os" "strconv" + "strings" "sync" "testing" "time" @@ -71,6 +72,11 @@ func ParseJsonResponse(t *testing.T, body []byte, x interface{}) { t.Fatalf("could not unmarshal response: %v", err) } } + +func StringToReader(s string) *strings.Reader { + return strings.NewReader(s) +} + func importTestData(s *server.Server) error { data := generateData() users := data["users"].([]models.User) diff --git a/zettelkasten-front/src/api/users.ts b/zettelkasten-front/src/api/users.ts index 91a77e1..f5a29d2 100644 --- a/zettelkasten-front/src/api/users.ts +++ b/zettelkasten-front/src/api/users.ts @@ -226,3 +226,25 @@ export function getMailingListSubscribers(): Promise { } }); } + +export function unsubscribeMailingList(email: string): Promise<{ message: string }> { + const url = `${base_url}/mailing-list/unsubscribe`; + let token = localStorage.getItem("token"); + + return fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }) + .then(checkStatus) + .then((response) => { + if (response) { + return response.json() as Promise<{ message: string }>; + } else { + return Promise.reject(new Error("Response is undefined")); + } + }); +} diff --git a/zettelkasten-front/src/pages/admin/AdminMailingListPage.tsx b/zettelkasten-front/src/pages/admin/AdminMailingListPage.tsx index bf128d8..8c9c8c8 100644 --- a/zettelkasten-front/src/pages/admin/AdminMailingListPage.tsx +++ b/zettelkasten-front/src/pages/admin/AdminMailingListPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; -import { getMailingListSubscribers, MailingListSubscriber } from "../../api/users"; +import { getMailingListSubscribers, MailingListSubscriber, unsubscribeMailingList } from "../../api/users"; import { useReactTable, getCoreRowModel, @@ -23,19 +23,39 @@ export function AdminMailingListPage() { const [subscribers, setSubscribers] = useState([]); const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const fetchSubscribers = async () => { + try { + const data = await getMailingListSubscribers(); + setSubscribers(data); + } catch (error) { + console.error("Error fetching subscribers:", error); + } + }; useEffect(() => { - const fetchSubscribers = async () => { - try { - const data = await getMailingListSubscribers(); - setSubscribers(data); - } catch (error) { - console.error("Error fetching subscribers:", error); - } - }; fetchSubscribers(); }, []); + const handleUnsubscribe = async (email: string) => { + if (!window.confirm(`Are you sure you want to unsubscribe ${email}?`)) { + return; + } + + setIsLoading(true); + try { + await unsubscribeMailingList(email); + // Refresh the subscribers list + await fetchSubscribers(); + } catch (error) { + console.error("Error unsubscribing:", error); + alert("Failed to unsubscribe. Please try again."); + } finally { + setIsLoading(false); + } + }; + const columnHelper = createColumnHelper(); const columns = useMemo[]>( @@ -84,8 +104,25 @@ export function AdminMailingListPage() { header: "Updated At", cell: (info) => new Date(info.getValue()).toLocaleString(), }), + columnHelper.display({ + id: "actions", + header: "Actions", + cell: (info) => ( + + ), + }), ], - [] + [isLoading] ); const table = useReactTable({