Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ func main() {
userRepo := repository.NewUserRepository(database)
accountRepo := repository.NewAccountRespository(database)
sessionRepo := repository.NewSessionRepository(database)
eventInterestRepo := repository.NewEventInterestRepository(database)

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

// Injections into handlers
apiHandlers := handlers.NewHandlers(authService, cfg, logger)
apiHandlers := handlers.NewHandlers(authService, eventInterestService, cfg, logger)

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

Expand Down
1 change: 1 addition & 0 deletions apps/api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/rs/zerolog v1.34.0
)

Expand Down
28 changes: 15 additions & 13 deletions apps/api/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/swamphacks/core/apps/api/internal/api/handlers"
mw "github.com/swamphacks/core/apps/api/internal/api/middleware"
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
Expand Down Expand Up @@ -44,55 +45,56 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
MaxAge: 300,
}))

// Health check
api.Router.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
api.Logger.Trace().Str("method", r.Method).Str("path", r.URL.Path).Msg("Received ping.")

w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", "6") // "pong!\n" is 6 bytes

if _, err := w.Write([]byte("pong!\n")); err != nil {
return
log.Err(err)
}

})

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

r.Group(func(r chi.Router) {
r.Use(mw.Auth.RequireAuth)

r.Get("/me", api.Handlers.Auth.GetMe)

r.Post("/logout", api.Handlers.Auth.Logout)
})
})

// Just for testing role perms right now
// Event routes
api.Router.Route("/event", func(r chi.Router) {
r.Post("/{eventId}/interest", api.Handlers.EventInterest.AddEmailToEvent)
})

// Protected test routes
api.Router.Route("/protected", func(r chi.Router) {
r.Use(mw.Auth.RequireAuth)

r.Get("/basic", func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte("Welcome, arbitrarily roled user that I don't know the role of yet!!\n")); err != nil {
return
if _, err := w.Write([]byte("Welcome, arbitrarily roled user!\n")); err != nil {
log.Err(err)
}
})

r.Group(func(r chi.Router) {
r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleUser))

r.Get("/user", func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte("Welcome, user!\n")); err != nil {
return
log.Err(err)
}
})
})

r.Group(func(r chi.Router) {
r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleSuperuser))

r.Get("/superuser", func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte("Welcome, superuser!\n")); err != nil {
return
log.Err(err)
}
})
})
Expand Down
74 changes: 74 additions & 0 deletions apps/api/internal/api/handlers/event_interest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package handlers

import (
"encoding/json"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog"
res "github.com/swamphacks/core/apps/api/internal/api/response"
"github.com/swamphacks/core/apps/api/internal/config"
"github.com/swamphacks/core/apps/api/internal/email"
"github.com/swamphacks/core/apps/api/internal/services"
)

type EventInterestHandler struct {
eventInterestService *services.EventInterestService
cfg *config.Config
logger zerolog.Logger
}

func NewEventInterestHandler(eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *EventInterestHandler {
return &EventInterestHandler{
eventInterestService: eventInterestService,
cfg: cfg,
logger: logger.With().Str("handler", "EventInterestHandler").Str("component", "event_interest").Logger(),
}
}

// AddEmailRequest is the expected payload for adding an email
type AddEmailRequest struct {
Email string `json:"email"`
Source *string `json:"source"`
}

func (h *EventInterestHandler) AddEmailToEvent(w http.ResponseWriter, r *http.Request) {
eventIdStr := chi.URLParam(r, "eventId")
if eventIdStr == "" {
res.SendError(w, http.StatusBadRequest, res.NewError("missing_event", "The event ID is missing from the URL!"))
return
}
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID"))
return
}

// Parse JSON body
var req AddEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body"))
return
}

if !email.IsValidEmail(req.Email) {
res.SendError(w, http.StatusBadRequest, res.NewError("missing_email", "Email is required"))
return
}

_, err = h.eventInterestService.CreateInterestSubmission(r.Context(), eventId, req.Email, req.Source)
if err != nil {
switch err {
case services.ErrEmailConflict:
res.SendError(w, http.StatusConflict, res.NewError("duplicate_email", "Email is already registered for this event"))
case services.ErrFailedToCreateSubmission:
res.SendError(w, http.StatusInternalServerError, res.NewError("submission_error", "Failed to create event interest submission"))
default:
res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong"))
}
return
}

w.WriteHeader(http.StatusCreated)
}
8 changes: 5 additions & 3 deletions apps/api/internal/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
)

type Handlers struct {
Auth *AuthHandler
Auth *AuthHandler
EventInterest *EventInterestHandler
}

func NewHandlers(authService *services.AuthService, cfg *config.Config, logger zerolog.Logger) *Handlers {
func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *Handlers {
return &Handlers{
Auth: NewAuthHandler(authService, cfg, logger),
Auth: NewAuthHandler(authService, cfg, logger),
EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger),
}
}
16 changes: 16 additions & 0 deletions apps/api/internal/api/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ func SendError(w http.ResponseWriter, status int, errorResponse ErrorResponse) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

// Send marshals any successful payload struct to JSON, sets the status code,
// and writes the response.
func Send(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)

if payload != nil {
if err := json.NewEncoder(w).Encode(payload); err != nil {
// If encoding fails, log the error and fall back to a plain text error.
// This is crucial because the header has already been written.
log.Err(err).Str("function", "Send").Msg("Failed to encode and send JSON success object")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
15 changes: 15 additions & 0 deletions apps/api/internal/db/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package db

import (
"errors"

"github.com/jackc/pgx/v5/pgconn"
)

func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE event_interest_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT
);

CREATE INDEX idx_event_interest_event_id ON event_interest_submissions (event_id);
CREATE UNIQUE INDEX uniq_event_email ON event_interest_submissions (event_id, email);

-- +goose Down
DROP INDEX IF EXISTS uniq_event_email;
DROP INDEX IF EXISTS idx_event_interest_event_id;
DROP TABLE IF EXISTS event_interest_submissions;
12 changes: 12 additions & 0 deletions apps/api/internal/db/queries/event_interest_submissions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- name: AddEmail :one
-- Adds a new email to the mailing list for a specific user and event.
-- The unique constraint on (event_id, user_id) will prevent duplicates.
-- Returns the newly created email record.
INSERT INTO event_interest_submissions (
event_id,
email,
source
) VALUES (
$1, $2, $3
)
RETURNING *;
36 changes: 36 additions & 0 deletions apps/api/internal/db/repository/event_interest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package repository

import (
"context"
"errors"

"github.com/swamphacks/core/apps/api/internal/db"
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
)

var (
ErrDuplicateEmails = errors.New("email already exists in the database")
)

type EventInterestRepository struct {
db *db.DB
}

func NewEventInterestRepository(db *db.DB) *EventInterestRepository {
return &EventInterestRepository{
db: db,
}
}

func (r *EventInterestRepository) AddEmail(ctx context.Context, params sqlc.AddEmailParams) (*sqlc.EventInterestSubmission, error) {
interestSubmission, err := r.db.Query.AddEmail(ctx, params)
if err != nil {
if db.IsUniqueViolation(err) {
return nil, ErrDuplicateEmails
}

return nil, err
}

return &interestSubmission, nil
}
45 changes: 45 additions & 0 deletions apps/api/internal/db/sqlc/event_interest_submissions.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions apps/api/internal/db/sqlc/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/api/internal/db/sqlc/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions apps/api/internal/email/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package email

import "net/mail"

func IsValidEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
Loading