Skip to content

Commit 434bbf9

Browse files
TECH-107: Add mailing list (#40)
* Sqlc code and goose * Added mailing list functionality * ref: change to event interest submissions * fix: move to pgconn for error handling * fix: change to satisfy linter --------- Co-authored-by: Alexander Wang <alexander.yisu.wang@outlook.com>
1 parent 5896131 commit 434bbf9

File tree

15 files changed

+305
-17
lines changed

15 files changed

+305
-17
lines changed

apps/api/cmd/api/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ func main() {
4545
userRepo := repository.NewUserRepository(database)
4646
accountRepo := repository.NewAccountRespository(database)
4747
sessionRepo := repository.NewSessionRepository(database)
48+
eventInterestRepo := repository.NewEventInterestRepository(database)
4849

4950
// Injections into services
5051
authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth)
52+
eventInterestService := services.NewEventInterestService(eventInterestRepo, logger)
5153

5254
// Injections into handlers
53-
apiHandlers := handlers.NewHandlers(authService, cfg, logger)
55+
apiHandlers := handlers.NewHandlers(authService, eventInterestService, cfg, logger)
5456

5557
api := api.NewAPI(&logger, apiHandlers, mw)
5658

apps/api/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/google/uuid v1.6.0
1010
github.com/jackc/pgx/v5 v5.7.4
1111
github.com/joho/godotenv v1.5.1
12+
github.com/lib/pq v1.10.9
1213
github.com/rs/zerolog v1.34.0
1314
)
1415

apps/api/internal/api/api.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/go-chi/chi/v5/middleware"
88
"github.com/go-chi/cors"
99
"github.com/rs/zerolog"
10+
"github.com/rs/zerolog/log"
1011
"github.com/swamphacks/core/apps/api/internal/api/handlers"
1112
mw "github.com/swamphacks/core/apps/api/internal/api/middleware"
1213
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
@@ -44,55 +45,56 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
4445
MaxAge: 300,
4546
}))
4647

48+
// Health check
4749
api.Router.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
4850
api.Logger.Trace().Str("method", r.Method).Str("path", r.URL.Path).Msg("Received ping.")
49-
5051
w.Header().Set("Content-Type", "text/plain")
5152
w.Header().Set("Content-Length", "6") // "pong!\n" is 6 bytes
52-
5353
if _, err := w.Write([]byte("pong!\n")); err != nil {
54-
return
54+
log.Err(err)
5555
}
56-
5756
})
5857

58+
// Auth routes
5959
api.Router.Route("/auth", func(r chi.Router) {
6060
r.Get("/callback", api.Handlers.Auth.OAuthCallback)
6161

6262
r.Group(func(r chi.Router) {
6363
r.Use(mw.Auth.RequireAuth)
64-
6564
r.Get("/me", api.Handlers.Auth.GetMe)
66-
6765
r.Post("/logout", api.Handlers.Auth.Logout)
6866
})
6967
})
7068

71-
// Just for testing role perms right now
69+
// Event routes
70+
api.Router.Route("/event", func(r chi.Router) {
71+
r.Post("/{eventId}/interest", api.Handlers.EventInterest.AddEmailToEvent)
72+
})
73+
74+
// Protected test routes
7275
api.Router.Route("/protected", func(r chi.Router) {
7376
r.Use(mw.Auth.RequireAuth)
77+
7478
r.Get("/basic", func(w http.ResponseWriter, r *http.Request) {
75-
if _, err := w.Write([]byte("Welcome, arbitrarily roled user that I don't know the role of yet!!\n")); err != nil {
76-
return
79+
if _, err := w.Write([]byte("Welcome, arbitrarily roled user!\n")); err != nil {
80+
log.Err(err)
7781
}
7882
})
7983

8084
r.Group(func(r chi.Router) {
8185
r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleUser))
82-
8386
r.Get("/user", func(w http.ResponseWriter, r *http.Request) {
8487
if _, err := w.Write([]byte("Welcome, user!\n")); err != nil {
85-
return
88+
log.Err(err)
8689
}
8790
})
8891
})
8992

9093
r.Group(func(r chi.Router) {
9194
r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleSuperuser))
92-
9395
r.Get("/superuser", func(w http.ResponseWriter, r *http.Request) {
9496
if _, err := w.Write([]byte("Welcome, superuser!\n")); err != nil {
95-
return
97+
log.Err(err)
9698
}
9799
})
98100
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
7+
"github.com/go-chi/chi/v5"
8+
"github.com/google/uuid"
9+
"github.com/rs/zerolog"
10+
res "github.com/swamphacks/core/apps/api/internal/api/response"
11+
"github.com/swamphacks/core/apps/api/internal/config"
12+
"github.com/swamphacks/core/apps/api/internal/email"
13+
"github.com/swamphacks/core/apps/api/internal/services"
14+
)
15+
16+
type EventInterestHandler struct {
17+
eventInterestService *services.EventInterestService
18+
cfg *config.Config
19+
logger zerolog.Logger
20+
}
21+
22+
func NewEventInterestHandler(eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *EventInterestHandler {
23+
return &EventInterestHandler{
24+
eventInterestService: eventInterestService,
25+
cfg: cfg,
26+
logger: logger.With().Str("handler", "EventInterestHandler").Str("component", "event_interest").Logger(),
27+
}
28+
}
29+
30+
// AddEmailRequest is the expected payload for adding an email
31+
type AddEmailRequest struct {
32+
Email string `json:"email"`
33+
Source *string `json:"source"`
34+
}
35+
36+
func (h *EventInterestHandler) AddEmailToEvent(w http.ResponseWriter, r *http.Request) {
37+
eventIdStr := chi.URLParam(r, "eventId")
38+
if eventIdStr == "" {
39+
res.SendError(w, http.StatusBadRequest, res.NewError("missing_event", "The event ID is missing from the URL!"))
40+
return
41+
}
42+
eventId, err := uuid.Parse(eventIdStr)
43+
if err != nil {
44+
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID"))
45+
return
46+
}
47+
48+
// Parse JSON body
49+
var req AddEmailRequest
50+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
51+
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body"))
52+
return
53+
}
54+
55+
if !email.IsValidEmail(req.Email) {
56+
res.SendError(w, http.StatusBadRequest, res.NewError("missing_email", "Email is required"))
57+
return
58+
}
59+
60+
_, err = h.eventInterestService.CreateInterestSubmission(r.Context(), eventId, req.Email, req.Source)
61+
if err != nil {
62+
switch err {
63+
case services.ErrEmailConflict:
64+
res.SendError(w, http.StatusConflict, res.NewError("duplicate_email", "Email is already registered for this event"))
65+
case services.ErrFailedToCreateSubmission:
66+
res.SendError(w, http.StatusInternalServerError, res.NewError("submission_error", "Failed to create event interest submission"))
67+
default:
68+
res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong"))
69+
}
70+
return
71+
}
72+
73+
w.WriteHeader(http.StatusCreated)
74+
}

apps/api/internal/api/handlers/handlers.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import (
77
)
88

99
type Handlers struct {
10-
Auth *AuthHandler
10+
Auth *AuthHandler
11+
EventInterest *EventInterestHandler
1112
}
1213

13-
func NewHandlers(authService *services.AuthService, cfg *config.Config, logger zerolog.Logger) *Handlers {
14+
func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *Handlers {
1415
return &Handlers{
15-
Auth: NewAuthHandler(authService, cfg, logger),
16+
Auth: NewAuthHandler(authService, cfg, logger),
17+
EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger),
1618
}
1719
}

apps/api/internal/api/response/response.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ func SendError(w http.ResponseWriter, status int, errorResponse ErrorResponse) {
2828
http.Error(w, "Internal server error", http.StatusInternalServerError)
2929
}
3030
}
31+
32+
// Send marshals any successful payload struct to JSON, sets the status code,
33+
// and writes the response.
34+
func Send(w http.ResponseWriter, status int, payload interface{}) {
35+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
36+
w.WriteHeader(status)
37+
38+
if payload != nil {
39+
if err := json.NewEncoder(w).Encode(payload); err != nil {
40+
// If encoding fails, log the error and fall back to a plain text error.
41+
// This is crucial because the header has already been written.
42+
log.Err(err).Str("function", "Send").Msg("Failed to encode and send JSON success object")
43+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
44+
}
45+
}
46+
}

apps/api/internal/db/errors.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package db
2+
3+
import (
4+
"errors"
5+
6+
"github.com/jackc/pgx/v5/pgconn"
7+
)
8+
9+
func IsUniqueViolation(err error) bool {
10+
var pgErr *pgconn.PgError
11+
if errors.As(err, &pgErr) {
12+
return pgErr.Code == "23505"
13+
}
14+
return false
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- +goose Up
2+
CREATE TABLE event_interest_submissions (
3+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4+
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
5+
email TEXT NOT NULL,
6+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7+
source TEXT
8+
);
9+
10+
CREATE INDEX idx_event_interest_event_id ON event_interest_submissions (event_id);
11+
CREATE UNIQUE INDEX uniq_event_email ON event_interest_submissions (event_id, email);
12+
13+
-- +goose Down
14+
DROP INDEX IF EXISTS uniq_event_email;
15+
DROP INDEX IF EXISTS idx_event_interest_event_id;
16+
DROP TABLE IF EXISTS event_interest_submissions;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- name: AddEmail :one
2+
-- Adds a new email to the mailing list for a specific user and event.
3+
-- The unique constraint on (event_id, user_id) will prevent duplicates.
4+
-- Returns the newly created email record.
5+
INSERT INTO event_interest_submissions (
6+
event_id,
7+
email,
8+
source
9+
) VALUES (
10+
$1, $2, $3
11+
)
12+
RETURNING *;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"github.com/swamphacks/core/apps/api/internal/db"
8+
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
9+
)
10+
11+
var (
12+
ErrDuplicateEmails = errors.New("email already exists in the database")
13+
)
14+
15+
type EventInterestRepository struct {
16+
db *db.DB
17+
}
18+
19+
func NewEventInterestRepository(db *db.DB) *EventInterestRepository {
20+
return &EventInterestRepository{
21+
db: db,
22+
}
23+
}
24+
25+
func (r *EventInterestRepository) AddEmail(ctx context.Context, params sqlc.AddEmailParams) (*sqlc.EventInterestSubmission, error) {
26+
interestSubmission, err := r.db.Query.AddEmail(ctx, params)
27+
if err != nil {
28+
if db.IsUniqueViolation(err) {
29+
return nil, ErrDuplicateEmails
30+
}
31+
32+
return nil, err
33+
}
34+
35+
return &interestSubmission, nil
36+
}

0 commit comments

Comments
 (0)