Skip to content
Closed
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 @@ -63,17 +63,19 @@ func main() {
eventInterestRepo := repository.NewEventInterestRepository(database)
eventRepo := repository.NewEventRespository(database)
applicationRepo := repository.NewApplicationRepository(database)
campaignRepo := repository.NewCampaignRepository(database)

// Injections into services
authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth)
userService := services.NewUserService(userRepo, logger)
eventInterestService := services.NewEventInterestService(eventInterestRepo, logger)
eventService := services.NewEventService(eventRepo, userRepo, r2Client, &cfg.CoreBuckets, logger)
emailService := services.NewEmailService(taskQueueClient, logger)
campaignService := services.NewCampaignService(campaignRepo, logger)
applicationService := services.NewApplicationService(applicationRepo, eventService, txm, r2Client, &cfg.CoreBuckets, logger)

// Injections into handlers
apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, cfg, logger)
apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, campaignService, cfg, logger)

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

Expand Down
11 changes: 11 additions & 0 deletions apps/api/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
api.Router.Route("/email", func(r chi.Router) {
r.Post("/queue", api.Handlers.Email.QueueEmail)
})

// Campaign routes
api.Router.Route("/campaigns", func(r chi.Router) {
r.Use(ensureEventAdmin)

r.Post("/", api.Handlers.Campaign.CreateCampaign)

r.Route("/{campaignId}", func(r chi.Router) {

})
})

// Protected test routes
api.Router.Route("/protected", func(r chi.Router) {
Expand Down
72 changes: 72 additions & 0 deletions apps/api/internal/api/handlers/campaigns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package handlers

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

"github.com/go-playground/validator/v10"
"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/db/sqlc"
"github.com/swamphacks/core/apps/api/internal/services"
)

type CampaignHandler struct {
campaignService *services.CampaignService
cfg *config.Config
logger zerolog.Logger
}

func NewCampaignHandler(campaignService *services.CampaignService, logger zerolog.Logger) *CampaignHandler {
return &CampaignHandler{
campaignService: campaignService,
logger: logger.With().Str("handler", "CampaignHandler").Str("component", "campaigns").Logger(),
}
}

type CreateCampaignFields struct {
EventID uuid.UUID `json:"event_id" validate:"required"`
Title string `json:"title" validate:"required"`
Description *string `json:"description"`
RecipientRoles *[]string `json:"recipient_roles"`
CreatedBy uuid.UUID `json:"created_by" validate:"required"`
}

func (h *CampaignHandler) CreateCampaign(w http.ResponseWriter, r *http.Request) {

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

validate := validator.New()
if err := validate.Struct(req); err != nil {
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", err.Error()))
}

params := sqlc.CreateCampaignParams{
EventID: req.EventID,
Title: req.Title,
Description: req.Description,
RecipientRoles: req.RecipientRoles,
CreatedBy: req.CreatedBy,
}

campaign, err := h.campaignService.CreateCampaign(r.Context(), params)
if err != nil {
if errors.Is(err, services.ErrFailedToCreateCampaign) {
res.SendError(w, http.StatusInternalServerError, res.NewError("creation_error", "Failed to create campaign"))
} else {
res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong"))
}
}

res.Send(w, http.StatusCreated, campaign)
}
3 changes: 3 additions & 0 deletions apps/api/internal/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Handlers struct {
Event *EventHandler
Email *EmailHandler
Application *ApplicationHandler
Campaign *CampaignHandler
}

func NewHandlers(
Expand All @@ -22,6 +23,7 @@ func NewHandlers(
eventService *services.EventService,
emailService *services.EmailService,
appService *services.ApplicationService,
campaignService *services.CampaignService,
cfg *config.Config,
logger zerolog.Logger,
) *Handlers {
Expand All @@ -31,6 +33,7 @@ func NewHandlers(
EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger),
Event: NewEventHandler(eventService, cfg, logger),
Email: NewEmailHandler(emailService, logger),
Campaign: NewCampaignHandler(campaignService, logger),
Application: NewApplicationHandler(appService),
}
}
94 changes: 94 additions & 0 deletions apps/api/internal/db/migrations/20250923161349_campaign_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- +goose Up
-- +goose StatementBegin

CREATE TABLE campaign_email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT CONSTRAINT title_charlim CHECK (char_length(title) <= 200),
html TEXT,

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE campaign_email_links (
-- May be able to simplify the primary key being used here
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role event_role_type NOT NULL,
hit_count INT DEFAULT 0,
unsubscribed_count INT DEFAULT 0,

-- Example endpoint: GET localhost/<endpoint>?redirect=<redirect_to>&role=<role>
api_endpoint TEXT NOT NULL,
redirect_to TEXT NOT NULL,

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE campaign_emails (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_addresses TEXT[],
recipient_roles TEXT[] NOT NULL,
send_from TEXT NOT NULL,
subject TEXT CONSTRAINT subject_charlim CHECK (char_length(subject) < 78), -- From SMTP spec.
body TEXT, -- Char limit determined by mail clients.

-- Metadata
template UUID NOT NULL REFERENCES campaign_email_templates(id),
send_on TIMESTAMPTZ NOT NULL,
links UUID[],
created_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
title TEXT CONSTRAINT title_charlim CHECK (char_length(title) <= 200) NOT NULL,
description TEXT CONSTRAINT description_charlim CHECK (char_length(description) <= 1000),
recipient_roles event_role_type[] NOT NULL,
recipient_addresses TEXT[],
emails UUID[],

created_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TRIGGER set_updated_at_campaign_email_templates
BEFORE UPDATE ON campaign_email_templates
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

CREATE TRIGGER set_updated_at_campaign_email_links
BEFORE UPDATE ON campaign_email_links
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

CREATE TRIGGER set_updated_at_campaign_emails
BEFORE UPDATE ON campaign_emails
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

CREATE TRIGGER set_updated_at_campaigns
BEFORE UPDATE ON campaigns
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin

DROP TRIGGER IF EXISTS set_updated_at_campaigns ON campaigns;
DROP TRIGGER IF EXISTS set_updated_at_campaign_emails ON campaign_emails;
DROP TRIGGER IF EXISTS set_updated_at_campaign_email_links ON campaign_email_links;
DROP TRIGGER IF EXISTS set_updated_at_campaign_email_templates ON campaign_email_templates;

DROP TABLE IF EXISTS campaigns;
DROP TABLE IF EXISTS campaign_emails;
DROP TABLE IF EXISTS campaign_email_links;
DROP TABLE IF EXISTS campaign_email_templates;

-- +goose StatementEnd
33 changes: 33 additions & 0 deletions apps/api/internal/db/queries/campaigns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- name: CreateCampaign :one
INSERT INTO campaigns (
event_id,
title, description,
recipient_roles,
created_by
) VALUES (
@event_id, @title,
coalesce(sqlc.narg(description), null),
coalesce(sqlc.narg(recipient_roles), null),
@created_by
)
RETURNING *;

-- name: GetCampaignById :one
SELECT * FROM campaigns
WHERE id = $1;

-- name: UpdateCampaignById :exec
UPDATE campaigns
SET
title = CASE WHEN @title_do_update::boolean THEN @title ELSE title END,
description = CASE WHEN @description_do_update::boolean THEN @description ELSE description END,
recipient_roles = CASE WHEN @recipient_roles::boolean THEN @recipient_roles ELSE recipient_roles END,
recipient_addresses = CASE WHEN @recipient_addresses::boolean THEN @recipient_addresses ELSE recipient_addresses END,
emails = CASE WHEN @emails::boolean THEN @emails ELSE emails END
WHERE
id = @id::uuid
returning *;

-- name: DeleteCampaignById :execrows
DELETE FROM campaigns
WHERE id = $1;
68 changes: 68 additions & 0 deletions apps/api/internal/db/repository/campaigns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package repository

import (
"context"
"errors"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/swamphacks/core/apps/api/internal/db"
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
)

var (
ErrCampaignNotFound = errors.New("campaign not found")
ErrNoCampaignsDeleted = errors.New("no campsigns deleted")
ErrMultipleCampaignsDeleted = errors.New("multiple campaigns affected by delete query while only expecting one to delete one")
)

type CampaignRepository struct {
db *db.DB
}

func NewCampaignRepository(db *db.DB) *CampaignRepository {
return &CampaignRepository{
db: db,
}
}

func (r *CampaignRepository) CreateCampaign(ctx context.Context, params sqlc.CreateCampaignParams) (*sqlc.Campaign, error) {
campaign, err := r.db.Query.CreateCampaign(ctx, params)
if err != nil {
return nil, err
}

return &campaign, nil
}

func (r *CampaignRepository) GetCampaignByID(ctx context.Context, id uuid.UUID) (*sqlc.Campaign, error) {
campaign, err := r.db.Query.GetCampaignById(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCampaignNotFound
} else if err != nil {
return nil, err
}

return &campaign, nil
}

func (r *CampaignRepository) UpdateCampaignById(ctx context.Context, params sqlc.UpdateCampaignByIdParams) error {
err := r.db.Query.UpdateCampaignById(ctx, params)
if errors.Is(err, pgx.ErrNoRows) {
return ErrCampaignNotFound
}
return err
}

func (r *CampaignRepository) DeleteCampaignById(ctx context.Context, id uuid.UUID) error {
affectedRows, err := r.db.Query.DeleteCampaignById(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return ErrCampaignNotFound
}
if affectedRows == 0 {
return ErrNoCampaignsDeleted
} else if affectedRows > 1 {
return ErrMultipleCampaignsDeleted
}
return err
}
2 changes: 1 addition & 1 deletion apps/api/internal/db/sqlc/accounts.sql.go

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

2 changes: 1 addition & 1 deletion apps/api/internal/db/sqlc/applications.sql.go

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

Loading
Loading