Skip to content

Commit

Permalink
admin: unsubscribing users from mailing list
Browse files Browse the repository at this point in the history
  • Loading branch information
NickSavage committed Jan 3, 2025
1 parent 3f7ccaf commit 225f7ae
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 10 deletions.
43 changes: 43 additions & 0 deletions go-backend/handlers/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}
85 changes: 85 additions & 0 deletions go-backend/handlers/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
`, "[email protected]", true, true)
if err != nil {
t.Fatal(err)
}

token, _ := tests.GenerateTestJWT(1) // Admin user
body := `{"email": "[email protected]"}`
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", "[email protected]").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": "[email protected]"}`
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": "[email protected]"}`
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)
}
}
1 change: 1 addition & 0 deletions go-backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions go-backend/tests/conftest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"math/rand"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions zettelkasten-front/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,25 @@ export function getMailingListSubscribers(): Promise<MailingListSubscriber[]> {
}
});
}

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"));
}
});
}
57 changes: 47 additions & 10 deletions zettelkasten-front/src/pages/admin/AdminMailingListPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,19 +23,39 @@ export function AdminMailingListPage() {
const [subscribers, setSubscribers] = useState<MailingListSubscriber[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);
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<MailingListSubscriber>();

const columns = useMemo<ColumnDef<MailingListSubscriber, any>[]>(
Expand Down Expand Up @@ -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) => (
<button
onClick={() => handleUnsubscribe(info.row.original.email)}
disabled={!info.row.original.subscribed || isLoading}
className={`px-3 py-1 rounded text-sm ${
!info.row.original.subscribed || isLoading
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "bg-red-500 text-white hover:bg-red-600"
}`}
>
{isLoading ? "..." : "Unsubscribe"}
</button>
),
}),
],
[]
[isLoading]
);

const table = useReactTable({
Expand Down

0 comments on commit 225f7ae

Please sign in to comment.