diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 18039efb..0d122dfc 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -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) diff --git a/apps/api/go.mod b/apps/api/go.mod index ff1187dd..73abb4b0 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -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 ) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 13f5ee24..b846840f 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -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" @@ -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) } }) }) diff --git a/apps/api/internal/api/handlers/event_interest.go b/apps/api/internal/api/handlers/event_interest.go new file mode 100644 index 00000000..de57af66 --- /dev/null +++ b/apps/api/internal/api/handlers/event_interest.go @@ -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) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index cd1dd822..52d0d1ef 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -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), } } diff --git a/apps/api/internal/api/response/response.go b/apps/api/internal/api/response/response.go index f4095478..39089c67 100644 --- a/apps/api/internal/api/response/response.go +++ b/apps/api/internal/api/response/response.go @@ -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) + } + } +} diff --git a/apps/api/internal/db/errors.go b/apps/api/internal/db/errors.go new file mode 100644 index 00000000..28dceda4 --- /dev/null +++ b/apps/api/internal/db/errors.go @@ -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 +} diff --git a/apps/api/internal/db/migrations/20250627064521_create_mailing_list.sql b/apps/api/internal/db/migrations/20250627064521_create_mailing_list.sql new file mode 100644 index 00000000..ad958756 --- /dev/null +++ b/apps/api/internal/db/migrations/20250627064521_create_mailing_list.sql @@ -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; diff --git a/apps/api/internal/db/queries/event_interest_submissions.sql b/apps/api/internal/db/queries/event_interest_submissions.sql new file mode 100644 index 00000000..475bf777 --- /dev/null +++ b/apps/api/internal/db/queries/event_interest_submissions.sql @@ -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 *; \ No newline at end of file diff --git a/apps/api/internal/db/repository/event_interest.go b/apps/api/internal/db/repository/event_interest.go new file mode 100644 index 00000000..886ae9fc --- /dev/null +++ b/apps/api/internal/db/repository/event_interest.go @@ -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 +} diff --git a/apps/api/internal/db/sqlc/event_interest_submissions.sql.go b/apps/api/internal/db/sqlc/event_interest_submissions.sql.go new file mode 100644 index 00000000..27239b85 --- /dev/null +++ b/apps/api/internal/db/sqlc/event_interest_submissions.sql.go @@ -0,0 +1,45 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: event_interest_submissions.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" +) + +const addEmail = `-- name: AddEmail :one +INSERT INTO event_interest_submissions ( + event_id, + email, + source +) VALUES ( + $1, $2, $3 +) +RETURNING id, event_id, email, created_at, source +` + +type AddEmailParams struct { + EventID uuid.UUID `json:"event_id"` + Email string `json:"email"` + Source *string `json:"source"` +} + +// 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. +func (q *Queries) AddEmail(ctx context.Context, arg AddEmailParams) (EventInterestSubmission, error) { + row := q.db.QueryRow(ctx, addEmail, arg.EventID, arg.Email, arg.Source) + var i EventInterestSubmission + err := row.Scan( + &i.ID, + &i.EventID, + &i.Email, + &i.CreatedAt, + &i.Source, + ) + return i, err +} diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index 148cb354..611a8438 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -156,6 +156,14 @@ type Event struct { UpdatedAt *time.Time `json:"updated_at"` } +type EventInterestSubmission struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + Source *string `json:"source"` +} + type EventRole struct { UserID uuid.UUID `json:"user_id"` EventID uuid.UUID `json:"event_id"` diff --git a/apps/api/internal/db/sqlc/querier.go b/apps/api/internal/db/sqlc/querier.go index 324eb406..f07c2601 100644 --- a/apps/api/internal/db/sqlc/querier.go +++ b/apps/api/internal/db/sqlc/querier.go @@ -11,6 +11,10 @@ import ( ) type Querier interface { + // 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. + AddEmail(ctx context.Context, arg AddEmailParams) (EventInterestSubmission, error) CreateAccount(ctx context.Context, arg CreateAccountParams) (AuthAccount, error) CreateSession(ctx context.Context, arg CreateSessionParams) (AuthSession, error) CreateUser(ctx context.Context, arg CreateUserParams) (AuthUser, error) diff --git a/apps/api/internal/email/validation.go b/apps/api/internal/email/validation.go new file mode 100644 index 00000000..c1ce5a8e --- /dev/null +++ b/apps/api/internal/email/validation.go @@ -0,0 +1,8 @@ +package email + +import "net/mail" + +func IsValidEmail(email string) bool { + _, err := mail.ParseAddress(email) + return err == nil +} diff --git a/apps/api/internal/services/event_interest.go b/apps/api/internal/services/event_interest.go new file mode 100644 index 00000000..fadb7929 --- /dev/null +++ b/apps/api/internal/services/event_interest.go @@ -0,0 +1,47 @@ +package services + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrEmailConflict = errors.New("email already exists in this mailing list") + ErrFailedToCreateSubmission = errors.New("failed to create event interest submission") +) + +type EventInterestService struct { + eventInterestRepo *repository.EventInterestRepository + logger zerolog.Logger +} + +func NewEventInterestService(eventInterestRepo *repository.EventInterestRepository, logger zerolog.Logger) *EventInterestService { + return &EventInterestService{ + eventInterestRepo: eventInterestRepo, + logger: logger.With().Str("service", "EventInterestService").Str("component", "event_interest").Logger(), + } +} + +func (s *EventInterestService) CreateInterestSubmission(ctx context.Context, eventID uuid.UUID, email string, source *string) (*sqlc.EventInterestSubmission, error) { + params := sqlc.AddEmailParams{ + EventID: eventID, + Email: email, + Source: source, + } + + result, err := s.eventInterestRepo.AddEmail(ctx, params) + if err != nil && err == repository.ErrDuplicateEmails { + s.logger.Err(err).Msg("Could not insert email due to duplicate existing.") + return nil, ErrEmailConflict + } else if err != nil { + s.logger.Err(err).Msg("An unknown error was caught!") + return nil, ErrFailedToCreateSubmission + } + + return result, nil +}