From 932e47b959040a424d5dd0d42dc85d1fffabb994 Mon Sep 17 00:00:00 2001 From: William Chi Date: Sat, 4 Apr 2026 17:00:28 -0400 Subject: [PATCH 01/13] feat: add the existing email routes and handler to the API --- apps/api/internal/api/api.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index ac01b6b6..cfd3d6a7 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -133,6 +133,9 @@ func Run() { hackathon.RegisterRoutes(hackathonHandler, huma.NewGroup(api, "/hackathon"), mw) emailService := email.NewEmailService(hackathonRepo, userRepo, taskQueueClient, sesClient, r2Client, logger, config) + emailHandler := email.NewHandler(emailService, logger) + email.RegisterRoutes(emailHandler, huma.NewGroup(api, "/email"), mw) + batService := bat.NewBatService(applicationRepo, hackathonRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, nil, config, logger) applicationService := application.NewService(applicationRepo, userRepo, hackathonRepo, txm, r2Client, &config.CoreBuckets, nil, emailService, batService, config, logger) applicationHandler := application.NewHandler(applicationService, batService, config, logger) From 4137e9d0c28ab8c5b8565018d563a66635513d09 Mon Sep 17 00:00:00 2001 From: William Chi Date: Fri, 10 Apr 2026 15:43:15 -0400 Subject: [PATCH 02/13] feat: add email campaigns migration with status, format, and recipient types --- .../20260407214110_email_campaigns.sql | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 apps/api/internal/database/migrations/20260407214110_email_campaigns.sql diff --git a/apps/api/internal/database/migrations/20260407214110_email_campaigns.sql b/apps/api/internal/database/migrations/20260407214110_email_campaigns.sql new file mode 100644 index 00000000..0ba58a62 --- /dev/null +++ b/apps/api/internal/database/migrations/20260407214110_email_campaigns.sql @@ -0,0 +1,94 @@ +-- +goose Up +-- +goose StatementBegin + +create type email_campaign_status as enum ( + 'draft', + 'scheduled', + 'sending', + 'sent', + 'failed' +); + +create type email_campaign_format as enum ( + 'text', + 'html' +); + +create type email_recipient_type as enum ( + 'admins', + 'staff', + 'accepted_applicants', + 'rejected_applicants', + 'waitlisted_applicants', + 'visitors', + 'interest_subscribers' +); + +create table email_campaigns ( + id uuid default gen_random_uuid() not null primary key, + hackathon_id text not null references hackathons(id) on delete cascade, + + title text not null, + description text, + subject text not null, + body text not null, + format email_campaign_format default 'text'::email_campaign_format not null, + + recipient_types email_recipient_type[] not null, + status email_campaign_status default 'draft'::email_campaign_status not null, + + scheduled_at timestamptz, + sent_at timestamptz, + last_error text, + + created_by_user_id uuid references users(id) on delete set null, + updated_by_user_id uuid references users(id) on delete set null, + created_at timestamptz default now() not null, + updated_at timestamptz default now() not null, + + constraint email_campaigns_recipient_types_nonempty + check (cardinality(recipient_types) > 0), + + constraint email_campaigns_scheduled_at_required + check ( + status != 'scheduled'::email_campaign_status + or scheduled_at is not null + ), + + constraint email_campaigns_sent_at_required + check ( + status != 'sent'::email_campaign_status + or sent_at is not null + ) +); + +create index idx_email_campaigns_hackathon_id + on email_campaigns (hackathon_id); + +create index idx_email_campaigns_status + on email_campaigns (status); + +create index idx_email_campaigns_created_at + on email_campaigns (created_at desc); + +create index idx_email_campaigns_scheduled_at + on email_campaigns (scheduled_at) + where status = 'scheduled'::email_campaign_status; + +create trigger set_updated_at_email_campaigns + before update on email_campaigns + for each row + execute procedure update_modified_column(); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +drop trigger if exists set_updated_at_email_campaigns on email_campaigns; +drop table if exists email_campaigns; +drop type if exists email_recipient_type; +drop type if exists email_campaign_format; +drop type if exists email_campaign_status; + +-- +goose StatementEnd From 850258115af1f01bba3132248e9759ec66c02256 Mon Sep 17 00:00:00 2001 From: William Chi Date: Fri, 10 Apr 2026 15:50:26 -0400 Subject: [PATCH 03/13] feat: add EmailCampaign model with status, format, and recipient types --- apps/api/internal/database/sqlc/models.go | 153 ++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/apps/api/internal/database/sqlc/models.go b/apps/api/internal/database/sqlc/models.go index 0d467cab..b3e045d6 100644 --- a/apps/api/internal/database/sqlc/models.go +++ b/apps/api/internal/database/sqlc/models.go @@ -102,6 +102,140 @@ func (ns NullBatRunStatus) Value() (driver.Value, error) { return string(ns.BatRunStatus), nil } +type EmailCampaignFormat string + +const ( + EmailCampaignFormatText EmailCampaignFormat = "text" + EmailCampaignFormatHtml EmailCampaignFormat = "html" +) + +func (e *EmailCampaignFormat) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EmailCampaignFormat(s) + case string: + *e = EmailCampaignFormat(s) + default: + return fmt.Errorf("unsupported scan type for EmailCampaignFormat: %T", src) + } + return nil +} + +type NullEmailCampaignFormat struct { + EmailCampaignFormat EmailCampaignFormat `json:"email_campaign_format"` + Valid bool `json:"valid"` // Valid is true if EmailCampaignFormat is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEmailCampaignFormat) Scan(value interface{}) error { + if value == nil { + ns.EmailCampaignFormat, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EmailCampaignFormat.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEmailCampaignFormat) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EmailCampaignFormat), nil +} + +type EmailCampaignStatus string + +const ( + EmailCampaignStatusDraft EmailCampaignStatus = "draft" + EmailCampaignStatusScheduled EmailCampaignStatus = "scheduled" + EmailCampaignStatusSending EmailCampaignStatus = "sending" + EmailCampaignStatusSent EmailCampaignStatus = "sent" + EmailCampaignStatusFailed EmailCampaignStatus = "failed" +) + +func (e *EmailCampaignStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EmailCampaignStatus(s) + case string: + *e = EmailCampaignStatus(s) + default: + return fmt.Errorf("unsupported scan type for EmailCampaignStatus: %T", src) + } + return nil +} + +type NullEmailCampaignStatus struct { + EmailCampaignStatus EmailCampaignStatus `json:"email_campaign_status"` + Valid bool `json:"valid"` // Valid is true if EmailCampaignStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEmailCampaignStatus) Scan(value interface{}) error { + if value == nil { + ns.EmailCampaignStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EmailCampaignStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEmailCampaignStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EmailCampaignStatus), nil +} + +type EmailRecipientType string + +const ( + EmailRecipientTypeAdmins EmailRecipientType = "admins" + EmailRecipientTypeStaff EmailRecipientType = "staff" + EmailRecipientTypeAcceptedApplicants EmailRecipientType = "accepted_applicants" + EmailRecipientTypeRejectedApplicants EmailRecipientType = "rejected_applicants" + EmailRecipientTypeWaitlistedApplicants EmailRecipientType = "waitlisted_applicants" + EmailRecipientTypeVisitors EmailRecipientType = "visitors" + EmailRecipientTypeInterestSubscribers EmailRecipientType = "interest_subscribers" +) + +func (e *EmailRecipientType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = EmailRecipientType(s) + case string: + *e = EmailRecipientType(s) + default: + return fmt.Errorf("unsupported scan type for EmailRecipientType: %T", src) + } + return nil +} + +type NullEmailRecipientType struct { + EmailRecipientType EmailRecipientType `json:"email_recipient_type"` + Valid bool `json:"valid"` // Valid is true if EmailRecipientType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullEmailRecipientType) Scan(value interface{}) error { + if value == nil { + ns.EmailRecipientType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.EmailRecipientType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullEmailRecipientType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.EmailRecipientType), nil +} + type TeamInvitationStatus string const ( @@ -275,6 +409,25 @@ type BatRun struct { HackathonID string `json:"hackathon_id"` } +type EmailCampaign struct { + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` + Title string `json:"title"` + Description *string `json:"description"` + Subject string `json:"subject"` + Body string `json:"body"` + Format EmailCampaignFormat `json:"format"` + RecipientTypes []EmailRecipientType `json:"recipient_types"` + Status EmailCampaignStatus `json:"status"` + ScheduledAt *time.Time `json:"scheduled_at"` + SentAt *time.Time `json:"sent_at"` + LastError *string `json:"last_error"` + CreatedByUserID *uuid.UUID `json:"created_by_user_id"` + UpdatedByUserID *uuid.UUID `json:"updated_by_user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Hackathon struct { ID string `json:"id"` Name string `json:"name"` From 1b7e5de8a965417104d8907b577fa82eaaf8b99d Mon Sep 17 00:00:00 2001 From: William Chi Date: Sun, 3 May 2026 16:08:55 -0400 Subject: [PATCH 04/13] feat: update SQLC version to v1.30.0 --- .../internal/database/sqlc/accounts.sql.go | 2 +- .../database/sqlc/applications.sql.go | 2 +- .../internal/database/sqlc/bat_runs.sql.go | 2 +- apps/api/internal/database/sqlc/db.go | 2 +- .../database/sqlc/email_campaigns.sql.go | 401 ++++++++++++++++++ .../internal/database/sqlc/hackathons.sql.go | 2 +- .../database/sqlc/interest_submissions.sql.go | 2 +- apps/api/internal/database/sqlc/models.go | 2 +- .../internal/database/sqlc/redeemables.sql.go | 2 +- .../internal/database/sqlc/sessions.sql.go | 2 +- apps/api/internal/database/sqlc/stats.sql.go | 2 +- .../database/sqlc/team_invitations.sql.go | 2 +- .../database/sqlc/team_join_requests.sql.go | 2 +- .../database/sqlc/team_members.sql.go | 2 +- apps/api/internal/database/sqlc/teams.sql.go | 2 +- apps/api/internal/database/sqlc/users.sql.go | 2 +- .../internal/database/sqlc/workshops.sql.go | 2 +- 17 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 apps/api/internal/database/sqlc/email_campaigns.sql.go diff --git a/apps/api/internal/database/sqlc/accounts.sql.go b/apps/api/internal/database/sqlc/accounts.sql.go index 34b3ea3d..9aaf5158 100644 --- a/apps/api/internal/database/sqlc/accounts.sql.go +++ b/apps/api/internal/database/sqlc/accounts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: accounts.sql package sqlc diff --git a/apps/api/internal/database/sqlc/applications.sql.go b/apps/api/internal/database/sqlc/applications.sql.go index ddbb2e0f..795268a1 100644 --- a/apps/api/internal/database/sqlc/applications.sql.go +++ b/apps/api/internal/database/sqlc/applications.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: applications.sql package sqlc diff --git a/apps/api/internal/database/sqlc/bat_runs.sql.go b/apps/api/internal/database/sqlc/bat_runs.sql.go index e86eeb2c..3829be09 100644 --- a/apps/api/internal/database/sqlc/bat_runs.sql.go +++ b/apps/api/internal/database/sqlc/bat_runs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: bat_runs.sql package sqlc diff --git a/apps/api/internal/database/sqlc/db.go b/apps/api/internal/database/sqlc/db.go index a28f6fc5..7a565074 100644 --- a/apps/api/internal/database/sqlc/db.go +++ b/apps/api/internal/database/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package sqlc diff --git a/apps/api/internal/database/sqlc/email_campaigns.sql.go b/apps/api/internal/database/sqlc/email_campaigns.sql.go new file mode 100644 index 00000000..0d16caee --- /dev/null +++ b/apps/api/internal/database/sqlc/email_campaigns.sql.go @@ -0,0 +1,401 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: email_campaigns.sql + +package sqlc + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createEmailCampaign = `-- name: CreateEmailCampaign :one +INSERT INTO email_campaigns ( + hackathon_id, + title, + description, + subject, + body, + format, + recipient_types, + status, + scheduled_at, + sent_at, + last_error, + created_by_user_id, + updated_by_user_id +) VALUES ( + $1, + $2, + $3, + $4, + $5, + COALESCE($6, 'text'::email_campaign_format), + $7::email_recipient_type[], + COALESCE($8, 'draft'::email_campaign_status), + $9, + $10, + $11, + $12, + $13 +) +RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +` + +type CreateEmailCampaignParams struct { + HackathonID string `json:"hackathon_id"` + Title string `json:"title"` + Description *string `json:"description"` + Subject string `json:"subject"` + Body string `json:"body"` + Format interface{} `json:"format"` + RecipientTypes []EmailRecipientType `json:"recipient_types"` + Status interface{} `json:"status"` + ScheduledAt *time.Time `json:"scheduled_at"` + SentAt *time.Time `json:"sent_at"` + LastError *string `json:"last_error"` + CreatedByUserID *uuid.UUID `json:"created_by_user_id"` + UpdatedByUserID *uuid.UUID `json:"updated_by_user_id"` +} + +func (q *Queries) CreateEmailCampaign(ctx context.Context, arg CreateEmailCampaignParams) (EmailCampaign, error) { + row := q.db.QueryRow(ctx, createEmailCampaign, + arg.HackathonID, + arg.Title, + arg.Description, + arg.Subject, + arg.Body, + arg.Format, + arg.RecipientTypes, + arg.Status, + arg.ScheduledAt, + arg.SentAt, + arg.LastError, + arg.CreatedByUserID, + arg.UpdatedByUserID, + ) + var i EmailCampaign + err := row.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteEmailCampaign = `-- name: DeleteEmailCampaign :exec +DELETE FROM email_campaigns +WHERE id = $1::uuid + AND hackathon_id = $2 +` + +type DeleteEmailCampaignParams struct { + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` +} + +func (q *Queries) DeleteEmailCampaign(ctx context.Context, arg DeleteEmailCampaignParams) error { + _, err := q.db.Exec(ctx, deleteEmailCampaign, arg.ID, arg.HackathonID) + return err +} + +const getEmailCampaignByID = `-- name: GetEmailCampaignByID :one +SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +FROM email_campaigns +WHERE id = $1::uuid + AND hackathon_id = $2 +` + +type GetEmailCampaignByIDParams struct { + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` +} + +func (q *Queries) GetEmailCampaignByID(ctx context.Context, arg GetEmailCampaignByIDParams) (EmailCampaign, error) { + row := q.db.QueryRow(ctx, getEmailCampaignByID, arg.ID, arg.HackathonID) + var i EmailCampaign + err := row.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDueScheduledEmailCampaigns = `-- name: ListDueScheduledEmailCampaigns :many +SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +FROM email_campaigns +WHERE hackathon_id = $1 + AND status = 'scheduled'::email_campaign_status + AND scheduled_at IS NOT NULL + AND scheduled_at <= NOW() +ORDER BY scheduled_at ASC +` + +func (q *Queries) ListDueScheduledEmailCampaigns(ctx context.Context, hackathonID string) ([]EmailCampaign, error) { + rows, err := q.db.Query(ctx, listDueScheduledEmailCampaigns, hackathonID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EmailCampaign{} + for rows.Next() { + var i EmailCampaign + if err := rows.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEmailCampaignsByHackathon = `-- name: ListEmailCampaignsByHackathon :many +SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +FROM email_campaigns +WHERE hackathon_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListEmailCampaignsByHackathon(ctx context.Context, hackathonID string) ([]EmailCampaign, error) { + rows, err := q.db.Query(ctx, listEmailCampaignsByHackathon, hackathonID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EmailCampaign{} + for rows.Next() { + var i EmailCampaign + if err := rows.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateEmailCampaign = `-- name: UpdateEmailCampaign :one +UPDATE email_campaigns +SET + title = CASE WHEN $1::boolean THEN $2 ELSE title END, + description = CASE WHEN $3::boolean THEN $4 ELSE description END, + subject = CASE WHEN $5::boolean THEN $6 ELSE subject END, + body = CASE WHEN $7::boolean THEN $8 ELSE body END, + format = CASE WHEN $9::boolean THEN $10::email_campaign_format ELSE format END, + recipient_types = CASE WHEN $11::boolean THEN $12::email_recipient_type[] ELSE recipient_types END, + status = CASE WHEN $13::boolean THEN $14::email_campaign_status ELSE status END, + scheduled_at = CASE WHEN $15::boolean THEN $16::timestamptz ELSE scheduled_at END, + sent_at = CASE WHEN $17::boolean THEN $18::timestamptz ELSE sent_at END, + last_error = CASE WHEN $19::boolean THEN $20 ELSE last_error END, + updated_by_user_id = CASE WHEN $21::boolean THEN $22::uuid ELSE updated_by_user_id END +WHERE id = $23::uuid + AND hackathon_id = $24 +RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +` + +type UpdateEmailCampaignParams struct { + TitleDoUpdate bool `json:"title_do_update"` + Title string `json:"title"` + DescriptionDoUpdate bool `json:"description_do_update"` + Description *string `json:"description"` + SubjectDoUpdate bool `json:"subject_do_update"` + Subject string `json:"subject"` + BodyDoUpdate bool `json:"body_do_update"` + Body string `json:"body"` + FormatDoUpdate bool `json:"format_do_update"` + Format EmailCampaignFormat `json:"format"` + RecipientTypesDoUpdate bool `json:"recipient_types_do_update"` + RecipientTypes []EmailRecipientType `json:"recipient_types"` + StatusDoUpdate bool `json:"status_do_update"` + Status EmailCampaignStatus `json:"status"` + ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` + ScheduledAt time.Time `json:"scheduled_at"` + SentAtDoUpdate bool `json:"sent_at_do_update"` + SentAt time.Time `json:"sent_at"` + LastErrorDoUpdate bool `json:"last_error_do_update"` + LastError *string `json:"last_error"` + UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` + UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` +} + +func (q *Queries) UpdateEmailCampaign(ctx context.Context, arg UpdateEmailCampaignParams) (EmailCampaign, error) { + row := q.db.QueryRow(ctx, updateEmailCampaign, + arg.TitleDoUpdate, + arg.Title, + arg.DescriptionDoUpdate, + arg.Description, + arg.SubjectDoUpdate, + arg.Subject, + arg.BodyDoUpdate, + arg.Body, + arg.FormatDoUpdate, + arg.Format, + arg.RecipientTypesDoUpdate, + arg.RecipientTypes, + arg.StatusDoUpdate, + arg.Status, + arg.ScheduledAtDoUpdate, + arg.ScheduledAt, + arg.SentAtDoUpdate, + arg.SentAt, + arg.LastErrorDoUpdate, + arg.LastError, + arg.UpdatedByUserIDDoUpdate, + arg.UpdatedByUserID, + arg.ID, + arg.HackathonID, + ) + var i EmailCampaign + err := row.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateEmailCampaignStatus = `-- name: UpdateEmailCampaignStatus :one +UPDATE email_campaigns +SET + status = $1::email_campaign_status, + scheduled_at = CASE WHEN $2::boolean THEN $3::timestamptz ELSE scheduled_at END, + sent_at = CASE WHEN $4::boolean THEN $5::timestamptz ELSE sent_at END, + last_error = CASE WHEN $6::boolean THEN $7 ELSE last_error END, + updated_by_user_id = CASE WHEN $8::boolean THEN $9::uuid ELSE updated_by_user_id END +WHERE id = $10::uuid + AND hackathon_id = $11 +RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at +` + +type UpdateEmailCampaignStatusParams struct { + Status EmailCampaignStatus `json:"status"` + ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` + ScheduledAt time.Time `json:"scheduled_at"` + SentAtDoUpdate bool `json:"sent_at_do_update"` + SentAt time.Time `json:"sent_at"` + LastErrorDoUpdate bool `json:"last_error_do_update"` + LastError *string `json:"last_error"` + UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` + UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` +} + +func (q *Queries) UpdateEmailCampaignStatus(ctx context.Context, arg UpdateEmailCampaignStatusParams) (EmailCampaign, error) { + row := q.db.QueryRow(ctx, updateEmailCampaignStatus, + arg.Status, + arg.ScheduledAtDoUpdate, + arg.ScheduledAt, + arg.SentAtDoUpdate, + arg.SentAt, + arg.LastErrorDoUpdate, + arg.LastError, + arg.UpdatedByUserIDDoUpdate, + arg.UpdatedByUserID, + arg.ID, + arg.HackathonID, + ) + var i EmailCampaign + err := row.Scan( + &i.ID, + &i.HackathonID, + &i.Title, + &i.Description, + &i.Subject, + &i.Body, + &i.Format, + &i.RecipientTypes, + &i.Status, + &i.ScheduledAt, + &i.SentAt, + &i.LastError, + &i.CreatedByUserID, + &i.UpdatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/api/internal/database/sqlc/hackathons.sql.go b/apps/api/internal/database/sqlc/hackathons.sql.go index e04fd8d8..3a84b299 100644 --- a/apps/api/internal/database/sqlc/hackathons.sql.go +++ b/apps/api/internal/database/sqlc/hackathons.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: hackathons.sql package sqlc diff --git a/apps/api/internal/database/sqlc/interest_submissions.sql.go b/apps/api/internal/database/sqlc/interest_submissions.sql.go index f4e2a586..1f40992b 100644 --- a/apps/api/internal/database/sqlc/interest_submissions.sql.go +++ b/apps/api/internal/database/sqlc/interest_submissions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: interest_submissions.sql package sqlc diff --git a/apps/api/internal/database/sqlc/models.go b/apps/api/internal/database/sqlc/models.go index 800bb856..58fa9d52 100644 --- a/apps/api/internal/database/sqlc/models.go +++ b/apps/api/internal/database/sqlc/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package sqlc diff --git a/apps/api/internal/database/sqlc/redeemables.sql.go b/apps/api/internal/database/sqlc/redeemables.sql.go index 35917051..26e52067 100644 --- a/apps/api/internal/database/sqlc/redeemables.sql.go +++ b/apps/api/internal/database/sqlc/redeemables.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: redeemables.sql package sqlc diff --git a/apps/api/internal/database/sqlc/sessions.sql.go b/apps/api/internal/database/sqlc/sessions.sql.go index 2667d596..ea2f8f63 100644 --- a/apps/api/internal/database/sqlc/sessions.sql.go +++ b/apps/api/internal/database/sqlc/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: sessions.sql package sqlc diff --git a/apps/api/internal/database/sqlc/stats.sql.go b/apps/api/internal/database/sqlc/stats.sql.go index c97b484b..2703cbfb 100644 --- a/apps/api/internal/database/sqlc/stats.sql.go +++ b/apps/api/internal/database/sqlc/stats.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: stats.sql package sqlc diff --git a/apps/api/internal/database/sqlc/team_invitations.sql.go b/apps/api/internal/database/sqlc/team_invitations.sql.go index 50be8900..be0ae076 100644 --- a/apps/api/internal/database/sqlc/team_invitations.sql.go +++ b/apps/api/internal/database/sqlc/team_invitations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: team_invitations.sql package sqlc diff --git a/apps/api/internal/database/sqlc/team_join_requests.sql.go b/apps/api/internal/database/sqlc/team_join_requests.sql.go index 83900f1f..f11b8391 100644 --- a/apps/api/internal/database/sqlc/team_join_requests.sql.go +++ b/apps/api/internal/database/sqlc/team_join_requests.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: team_join_requests.sql package sqlc diff --git a/apps/api/internal/database/sqlc/team_members.sql.go b/apps/api/internal/database/sqlc/team_members.sql.go index 6e049bdc..c9be1e57 100644 --- a/apps/api/internal/database/sqlc/team_members.sql.go +++ b/apps/api/internal/database/sqlc/team_members.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: team_members.sql package sqlc diff --git a/apps/api/internal/database/sqlc/teams.sql.go b/apps/api/internal/database/sqlc/teams.sql.go index b74dd72b..911db0ff 100644 --- a/apps/api/internal/database/sqlc/teams.sql.go +++ b/apps/api/internal/database/sqlc/teams.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: teams.sql package sqlc diff --git a/apps/api/internal/database/sqlc/users.sql.go b/apps/api/internal/database/sqlc/users.sql.go index 81fe448d..489ed1d1 100644 --- a/apps/api/internal/database/sqlc/users.sql.go +++ b/apps/api/internal/database/sqlc/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: users.sql package sqlc diff --git a/apps/api/internal/database/sqlc/workshops.sql.go b/apps/api/internal/database/sqlc/workshops.sql.go index 2cd96322..5d2fe24c 100644 --- a/apps/api/internal/database/sqlc/workshops.sql.go +++ b/apps/api/internal/database/sqlc/workshops.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: workshops.sql package sqlc From d449c4af926c6da8e69ab18299086e8686438ae0 Mon Sep 17 00:00:00 2001 From: William Chi Date: Sun, 3 May 2026 16:12:43 -0400 Subject: [PATCH 05/13] feat: add SQL queries for email campaigns management --- .../database/queries/email_campaigns.sql | 105 +++++++++++ .../database/sqlc/email_campaigns.sql.go | 177 +++++++----------- 2 files changed, 169 insertions(+), 113 deletions(-) create mode 100644 apps/api/internal/database/queries/email_campaigns.sql diff --git a/apps/api/internal/database/queries/email_campaigns.sql b/apps/api/internal/database/queries/email_campaigns.sql new file mode 100644 index 00000000..279cde08 --- /dev/null +++ b/apps/api/internal/database/queries/email_campaigns.sql @@ -0,0 +1,105 @@ +-- name: CreateEmailCampaign :one +-- creates a draft campaign. It stores the title, subject, body, format, recipient groups, and optional schedule time. +INSERT INTO email_campaigns ( + hackathon_id, + title, + description, + subject, + body, + format, + recipient_types, + scheduled_at, + created_by_user_id, + updated_by_user_id +) VALUES ( + @hackathon_id, + @title, + sqlc.narg(description), + @subject, + @body, + @format::email_campaign_format, + @recipient_types::email_recipient_type[], + sqlc.narg(scheduled_at), + sqlc.narg(created_by_user_id), + sqlc.narg(updated_by_user_id) +) +RETURNING *; + +-- name: GetEmailCampaignByID :one +-- fetches one campaign by ID, scoped to a hackathon so one event cannot accidentally read another event’s campaign. +SELECT * +FROM email_campaigns +WHERE id = @id::uuid + AND hackathon_id = @hackathon_id; + +-- name: ListEmailCampaigns :many +-- returns all campaigns for one hackathon, newest first. +SELECT * +FROM email_campaigns +WHERE hackathon_id = @hackathon_id +ORDER BY created_at DESC; + +-- name: UpdateEmailCampaign :one +--edits draft-like campaign fields: title, description, subject, body, format, recipients, and scheduled time. +UPDATE email_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, + subject = + CASE WHEN @subject_do_update::boolean + THEN @subject + ELSE subject END, + body = + CASE WHEN @body_do_update::boolean + THEN @body + ELSE body END, + format = + CASE WHEN @format_do_update::boolean + THEN @format::email_campaign_format + ELSE format END, + recipient_types = + CASE WHEN @recipient_types_do_update::boolean + THEN @recipient_types::email_recipient_type[] + ELSE recipient_types END, + scheduled_at = + CASE WHEN @scheduled_at_do_update::boolean + THEN @scheduled_at::timestamptz + ELSE scheduled_at END, + updated_by_user_id = + CASE WHEN @updated_by_user_id_do_update::boolean + THEN @updated_by_user_id::uuid + ELSE updated_by_user_id END +WHERE id = @id::uuid + AND hackathon_id = @hackathon_id +RETURNING *; + +-- name: UpdateEmailCampaignStatus :one +-- changes lifecycle fields like draft -> scheduled, scheduled -> sending, sending -> sent, or sending -> failed. +UPDATE email_campaigns +SET + status = @status::email_campaign_status, + scheduled_at = + CASE WHEN @scheduled_at_do_update::boolean + THEN @scheduled_at::timestamptz + ELSE scheduled_at END, + sent_at = + CASE WHEN @sent_at_do_update::boolean + THEN @sent_at::timestamptz + ELSE sent_at END, + last_error = + CASE WHEN @last_error_do_update::boolean + THEN @last_error + ELSE last_error END, + updated_by_user_id = + CASE WHEN @updated_by_user_id_do_update::boolean + THEN @updated_by_user_id::uuid + ELSE updated_by_user_id END +WHERE id = @id::uuid + AND hackathon_id = @hackathon_id +RETURNING *; \ No newline at end of file diff --git a/apps/api/internal/database/sqlc/email_campaigns.sql.go b/apps/api/internal/database/sqlc/email_campaigns.sql.go index 0d16caee..95e497c4 100644 --- a/apps/api/internal/database/sqlc/email_campaigns.sql.go +++ b/apps/api/internal/database/sqlc/email_campaigns.sql.go @@ -21,10 +21,7 @@ INSERT INTO email_campaigns ( body, format, recipient_types, - status, scheduled_at, - sent_at, - last_error, created_by_user_id, updated_by_user_id ) VALUES ( @@ -33,14 +30,11 @@ INSERT INTO email_campaigns ( $3, $4, $5, - COALESCE($6, 'text'::email_campaign_format), + $6::email_campaign_format, $7::email_recipient_type[], - COALESCE($8, 'draft'::email_campaign_status), + $8, $9, - $10, - $11, - $12, - $13 + $10 ) RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at ` @@ -51,16 +45,14 @@ type CreateEmailCampaignParams struct { Description *string `json:"description"` Subject string `json:"subject"` Body string `json:"body"` - Format interface{} `json:"format"` + Format EmailCampaignFormat `json:"format"` RecipientTypes []EmailRecipientType `json:"recipient_types"` - Status interface{} `json:"status"` ScheduledAt *time.Time `json:"scheduled_at"` - SentAt *time.Time `json:"sent_at"` - LastError *string `json:"last_error"` CreatedByUserID *uuid.UUID `json:"created_by_user_id"` UpdatedByUserID *uuid.UUID `json:"updated_by_user_id"` } +// creates a draft campaign. It stores the title, subject, body, format, recipient groups, and optional schedule time. func (q *Queries) CreateEmailCampaign(ctx context.Context, arg CreateEmailCampaignParams) (EmailCampaign, error) { row := q.db.QueryRow(ctx, createEmailCampaign, arg.HackathonID, @@ -70,10 +62,7 @@ func (q *Queries) CreateEmailCampaign(ctx context.Context, arg CreateEmailCampai arg.Body, arg.Format, arg.RecipientTypes, - arg.Status, arg.ScheduledAt, - arg.SentAt, - arg.LastError, arg.CreatedByUserID, arg.UpdatedByUserID, ) @@ -99,27 +88,11 @@ func (q *Queries) CreateEmailCampaign(ctx context.Context, arg CreateEmailCampai return i, err } -const deleteEmailCampaign = `-- name: DeleteEmailCampaign :exec -DELETE FROM email_campaigns -WHERE id = $1::uuid - AND hackathon_id = $2 -` - -type DeleteEmailCampaignParams struct { - ID uuid.UUID `json:"id"` - HackathonID string `json:"hackathon_id"` -} - -func (q *Queries) DeleteEmailCampaign(ctx context.Context, arg DeleteEmailCampaignParams) error { - _, err := q.db.Exec(ctx, deleteEmailCampaign, arg.ID, arg.HackathonID) - return err -} - const getEmailCampaignByID = `-- name: GetEmailCampaignByID :one SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at FROM email_campaigns WHERE id = $1::uuid - AND hackathon_id = $2 + AND hackathon_id = $2 ` type GetEmailCampaignByIDParams struct { @@ -127,6 +100,7 @@ type GetEmailCampaignByIDParams struct { HackathonID string `json:"hackathon_id"` } +// fetches one campaign by ID, scoped to a hackathon so one event cannot accidentally read another event’s campaign. func (q *Queries) GetEmailCampaignByID(ctx context.Context, arg GetEmailCampaignByIDParams) (EmailCampaign, error) { row := q.db.QueryRow(ctx, getEmailCampaignByID, arg.ID, arg.HackathonID) var i EmailCampaign @@ -151,62 +125,16 @@ func (q *Queries) GetEmailCampaignByID(ctx context.Context, arg GetEmailCampaign return i, err } -const listDueScheduledEmailCampaigns = `-- name: ListDueScheduledEmailCampaigns :many -SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at -FROM email_campaigns -WHERE hackathon_id = $1 - AND status = 'scheduled'::email_campaign_status - AND scheduled_at IS NOT NULL - AND scheduled_at <= NOW() -ORDER BY scheduled_at ASC -` - -func (q *Queries) ListDueScheduledEmailCampaigns(ctx context.Context, hackathonID string) ([]EmailCampaign, error) { - rows, err := q.db.Query(ctx, listDueScheduledEmailCampaigns, hackathonID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []EmailCampaign{} - for rows.Next() { - var i EmailCampaign - if err := rows.Scan( - &i.ID, - &i.HackathonID, - &i.Title, - &i.Description, - &i.Subject, - &i.Body, - &i.Format, - &i.RecipientTypes, - &i.Status, - &i.ScheduledAt, - &i.SentAt, - &i.LastError, - &i.CreatedByUserID, - &i.UpdatedByUserID, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listEmailCampaignsByHackathon = `-- name: ListEmailCampaignsByHackathon :many +const listEmailCampaigns = `-- name: ListEmailCampaigns :many SELECT id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at FROM email_campaigns WHERE hackathon_id = $1 ORDER BY created_at DESC ` -func (q *Queries) ListEmailCampaignsByHackathon(ctx context.Context, hackathonID string) ([]EmailCampaign, error) { - rows, err := q.db.Query(ctx, listEmailCampaignsByHackathon, hackathonID) +// returns all campaigns for one hackathon, newest first. +func (q *Queries) ListEmailCampaigns(ctx context.Context, hackathonID string) ([]EmailCampaign, error) { + rows, err := q.db.Query(ctx, listEmailCampaigns, hackathonID) if err != nil { return nil, err } @@ -245,19 +173,40 @@ func (q *Queries) ListEmailCampaignsByHackathon(ctx context.Context, hackathonID const updateEmailCampaign = `-- name: UpdateEmailCampaign :one UPDATE email_campaigns SET - title = CASE WHEN $1::boolean THEN $2 ELSE title END, - description = CASE WHEN $3::boolean THEN $4 ELSE description END, - subject = CASE WHEN $5::boolean THEN $6 ELSE subject END, - body = CASE WHEN $7::boolean THEN $8 ELSE body END, - format = CASE WHEN $9::boolean THEN $10::email_campaign_format ELSE format END, - recipient_types = CASE WHEN $11::boolean THEN $12::email_recipient_type[] ELSE recipient_types END, - status = CASE WHEN $13::boolean THEN $14::email_campaign_status ELSE status END, - scheduled_at = CASE WHEN $15::boolean THEN $16::timestamptz ELSE scheduled_at END, - sent_at = CASE WHEN $17::boolean THEN $18::timestamptz ELSE sent_at END, - last_error = CASE WHEN $19::boolean THEN $20 ELSE last_error END, - updated_by_user_id = CASE WHEN $21::boolean THEN $22::uuid ELSE updated_by_user_id END -WHERE id = $23::uuid - AND hackathon_id = $24 + title = + CASE WHEN $1::boolean + THEN $2 + ELSE title END, + description = + CASE WHEN $3::boolean + THEN $4 + ELSE description END, + subject = + CASE WHEN $5::boolean + THEN $6 + ELSE subject END, + body = + CASE WHEN $7::boolean + THEN $8 + ELSE body END, + format = + CASE WHEN $9::boolean + THEN $10::email_campaign_format + ELSE format END, + recipient_types = + CASE WHEN $11::boolean + THEN $12::email_recipient_type[] + ELSE recipient_types END, + scheduled_at = + CASE WHEN $13::boolean + THEN $14::timestamptz + ELSE scheduled_at END, + updated_by_user_id = + CASE WHEN $15::boolean + THEN $16::uuid + ELSE updated_by_user_id END +WHERE id = $17::uuid + AND hackathon_id = $18 RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at ` @@ -274,20 +223,15 @@ type UpdateEmailCampaignParams struct { Format EmailCampaignFormat `json:"format"` RecipientTypesDoUpdate bool `json:"recipient_types_do_update"` RecipientTypes []EmailRecipientType `json:"recipient_types"` - StatusDoUpdate bool `json:"status_do_update"` - Status EmailCampaignStatus `json:"status"` ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` ScheduledAt time.Time `json:"scheduled_at"` - SentAtDoUpdate bool `json:"sent_at_do_update"` - SentAt time.Time `json:"sent_at"` - LastErrorDoUpdate bool `json:"last_error_do_update"` - LastError *string `json:"last_error"` UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` ID uuid.UUID `json:"id"` HackathonID string `json:"hackathon_id"` } +// edits draft-like campaign fields: title, description, subject, body, format, recipients, and scheduled time. func (q *Queries) UpdateEmailCampaign(ctx context.Context, arg UpdateEmailCampaignParams) (EmailCampaign, error) { row := q.db.QueryRow(ctx, updateEmailCampaign, arg.TitleDoUpdate, @@ -302,14 +246,8 @@ func (q *Queries) UpdateEmailCampaign(ctx context.Context, arg UpdateEmailCampai arg.Format, arg.RecipientTypesDoUpdate, arg.RecipientTypes, - arg.StatusDoUpdate, - arg.Status, arg.ScheduledAtDoUpdate, arg.ScheduledAt, - arg.SentAtDoUpdate, - arg.SentAt, - arg.LastErrorDoUpdate, - arg.LastError, arg.UpdatedByUserIDDoUpdate, arg.UpdatedByUserID, arg.ID, @@ -341,12 +279,24 @@ const updateEmailCampaignStatus = `-- name: UpdateEmailCampaignStatus :one UPDATE email_campaigns SET status = $1::email_campaign_status, - scheduled_at = CASE WHEN $2::boolean THEN $3::timestamptz ELSE scheduled_at END, - sent_at = CASE WHEN $4::boolean THEN $5::timestamptz ELSE sent_at END, - last_error = CASE WHEN $6::boolean THEN $7 ELSE last_error END, - updated_by_user_id = CASE WHEN $8::boolean THEN $9::uuid ELSE updated_by_user_id END + scheduled_at = + CASE WHEN $2::boolean + THEN $3::timestamptz + ELSE scheduled_at END, + sent_at = + CASE WHEN $4::boolean + THEN $5::timestamptz + ELSE sent_at END, + last_error = + CASE WHEN $6::boolean + THEN $7 + ELSE last_error END, + updated_by_user_id = + CASE WHEN $8::boolean + THEN $9::uuid + ELSE updated_by_user_id END WHERE id = $10::uuid - AND hackathon_id = $11 + AND hackathon_id = $11 RETURNING id, hackathon_id, title, description, subject, body, format, recipient_types, status, scheduled_at, sent_at, last_error, created_by_user_id, updated_by_user_id, created_at, updated_at ` @@ -364,6 +314,7 @@ type UpdateEmailCampaignStatusParams struct { HackathonID string `json:"hackathon_id"` } +// changes lifecycle fields like draft -> scheduled, scheduled -> sending, sending -> sent, or sending -> failed. func (q *Queries) UpdateEmailCampaignStatus(ctx context.Context, arg UpdateEmailCampaignStatusParams) (EmailCampaign, error) { row := q.db.QueryRow(ctx, updateEmailCampaignStatus, arg.Status, From e649338f994e3156ad5807b10acd95559cd7afd3 Mon Sep 17 00:00:00 2001 From: William Chi Date: Sun, 3 May 2026 16:41:39 -0400 Subject: [PATCH 06/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/internal/database/queries/email_campaigns.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/internal/database/queries/email_campaigns.sql b/apps/api/internal/database/queries/email_campaigns.sql index 279cde08..930ea583 100644 --- a/apps/api/internal/database/queries/email_campaigns.sql +++ b/apps/api/internal/database/queries/email_campaigns.sql @@ -40,7 +40,7 @@ WHERE hackathon_id = @hackathon_id ORDER BY created_at DESC; -- name: UpdateEmailCampaign :one ---edits draft-like campaign fields: title, description, subject, body, format, recipients, and scheduled time. +-- edits draft-like campaign fields: title, description, subject, body, format, recipients, and scheduled time. UPDATE email_campaigns SET title = From 7552d39d61688b0a7933cb116170321a5a6d2175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 20:45:23 +0000 Subject: [PATCH 07/13] fix: use sqlc.narg for nullable scheduled_at and sent_at in update queries Agent-Logs-Url: https://github.com/swamphacks/core/sessions/1e2e08e9-0737-47dd-8a01-bddead1ad570 Co-authored-by: williamchiii <188648862+williamchiii@users.noreply.github.com> --- .../internal/database/queries/email_campaigns.sql | 6 +++--- .../internal/database/sqlc/email_campaigns.sql.go | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/api/internal/database/queries/email_campaigns.sql b/apps/api/internal/database/queries/email_campaigns.sql index 930ea583..5619c5cf 100644 --- a/apps/api/internal/database/queries/email_campaigns.sql +++ b/apps/api/internal/database/queries/email_campaigns.sql @@ -69,7 +69,7 @@ SET ELSE recipient_types END, scheduled_at = CASE WHEN @scheduled_at_do_update::boolean - THEN @scheduled_at::timestamptz + THEN sqlc.narg(scheduled_at) ELSE scheduled_at END, updated_by_user_id = CASE WHEN @updated_by_user_id_do_update::boolean @@ -86,11 +86,11 @@ SET status = @status::email_campaign_status, scheduled_at = CASE WHEN @scheduled_at_do_update::boolean - THEN @scheduled_at::timestamptz + THEN sqlc.narg(scheduled_at) ELSE scheduled_at END, sent_at = CASE WHEN @sent_at_do_update::boolean - THEN @sent_at::timestamptz + THEN sqlc.narg(sent_at) ELSE sent_at END, last_error = CASE WHEN @last_error_do_update::boolean diff --git a/apps/api/internal/database/sqlc/email_campaigns.sql.go b/apps/api/internal/database/sqlc/email_campaigns.sql.go index 95e497c4..c18a9414 100644 --- a/apps/api/internal/database/sqlc/email_campaigns.sql.go +++ b/apps/api/internal/database/sqlc/email_campaigns.sql.go @@ -199,7 +199,7 @@ SET ELSE recipient_types END, scheduled_at = CASE WHEN $13::boolean - THEN $14::timestamptz + THEN $14 ELSE scheduled_at END, updated_by_user_id = CASE WHEN $15::boolean @@ -224,7 +224,7 @@ type UpdateEmailCampaignParams struct { RecipientTypesDoUpdate bool `json:"recipient_types_do_update"` RecipientTypes []EmailRecipientType `json:"recipient_types"` ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` - ScheduledAt time.Time `json:"scheduled_at"` + ScheduledAt *time.Time `json:"scheduled_at"` UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` ID uuid.UUID `json:"id"` @@ -281,11 +281,11 @@ SET status = $1::email_campaign_status, scheduled_at = CASE WHEN $2::boolean - THEN $3::timestamptz + THEN $3 ELSE scheduled_at END, sent_at = CASE WHEN $4::boolean - THEN $5::timestamptz + THEN $5 ELSE sent_at END, last_error = CASE WHEN $6::boolean @@ -303,9 +303,9 @@ RETURNING id, hackathon_id, title, description, subject, body, format, recipient type UpdateEmailCampaignStatusParams struct { Status EmailCampaignStatus `json:"status"` ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` - ScheduledAt time.Time `json:"scheduled_at"` + ScheduledAt *time.Time `json:"scheduled_at"` SentAtDoUpdate bool `json:"sent_at_do_update"` - SentAt time.Time `json:"sent_at"` + SentAt *time.Time `json:"sent_at"` LastErrorDoUpdate bool `json:"last_error_do_update"` LastError *string `json:"last_error"` UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` From e5036eac3f103dac84a7b843e1c4e38b9c262fa0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 04:17:14 +0000 Subject: [PATCH 08/13] style: group stdlib and third-party imports in email_campaigns repository Agent-Logs-Url: https://github.com/swamphacks/core/sessions/5c4475ce-1d02-429a-80bf-15b48fefcd07 Co-authored-by: williamchiii <188648862+williamchiii@users.noreply.github.com> --- apps/api/internal/database/repository/email_campaigns.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/internal/database/repository/email_campaigns.go b/apps/api/internal/database/repository/email_campaigns.go index 7db1e52c..055f28c3 100644 --- a/apps/api/internal/database/repository/email_campaigns.go +++ b/apps/api/internal/database/repository/email_campaigns.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "github.com/jackc/pgx/v5" "github.com/swamphacks/core/apps/api/internal/database" "github.com/swamphacks/core/apps/api/internal/database/sqlc" From 19ed0ba9adb6fcf3455177b091dbc8744c478936 Mon Sep 17 00:00:00 2001 From: William Chi Date: Mon, 25 May 2026 09:12:47 -0400 Subject: [PATCH 09/13] feat: implement EmailCampaignService with CRUD operations and validation --- .../domains/email/campaign_service.go | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 apps/api/internal/domains/email/campaign_service.go diff --git a/apps/api/internal/domains/email/campaign_service.go b/apps/api/internal/domains/email/campaign_service.go new file mode 100644 index 00000000..f978d0a9 --- /dev/null +++ b/apps/api/internal/domains/email/campaign_service.go @@ -0,0 +1,150 @@ +package email + +import ( + "context" + "errors" + "strings" + + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/database/repository" + "github.com/swamphacks/core/apps/api/internal/database/sqlc" +) + +var ( + //Reuses repository-level "not found" error + ErrEmailCampaignNotFound = repository.ErrEmailCampaignNotFound + + //validation errors before writing error to db + ErrEmailCampaignTitleRequired = errors.New("email campaign title is required") + ErrEmailCampaignSubjectRequired = errors.New("email campaign subject is required") + ErrEmailCampaignBodyRequired = errors.New("email campaign body is required") + ErrEmailCampaignRecipientsRequired = errors.New("email campaign recipients are required") + + ErrEmailCampaignCannotEdit = errors.New("email campaign cannot be edited") + + //status-specific validation errors + ErrEmailCampaignScheduledAtRequired = errors.New("scheduled_at is required for scheduled campaigns") + ErrEmailCampaignSentAtRequired = errors.New("sent_at is required for sent campaigns") +) + +// EmailCampaignService owns business rules for saved email campaigns. +type EmailCampaignService struct { + emailCampaignRepo *repository.EmailCampaignRepository + logger zerolog.Logger +} + +// NewEmailCampaignService creates the service and stores its dependencies. +// This will eventually be called from api.go when wiring the app together. +func NewEmailCampaignService( + emailCampaignRepo *repository.EmailCampaignRepository, + logger zerolog.Logger, +) *EmailCampaignService { + return &EmailCampaignService{ + emailCampaignRepo: emailCampaignRepo, + logger: logger.With().Str("service", "EmailCampaignService").Str("domain", "email").Logger(), + } +} + +// CreateCampaign validates required campaign fields, then stores a new campaign. +// The actual INSERT is handled by the repository/sqlc layer. +func (s *EmailCampaignService) CreateCampaign( + ctx context.Context, + params sqlc.CreateEmailCampaignParams, +) (*sqlc.EmailCampaign, error) { + if err := validateCampaignContent(params.Title, params.Subject, params.Body, params.RecipientTypes); err != nil { + return nil, err + } + + return s.emailCampaignRepo.CreateEmailCampaign(ctx, params) +} + + +// GetCampaignByID fetches one campaign scoped to a hackathon. +// The hackathon scope prevents one event from reading another event's campaign. +func (s *EmailCampaignService) GetCampaignByID( + ctx context.Context, + params sqlc.GetEmailCampaignByIDParams, +) (*sqlc.EmailCampaign, error) { + return s.emailCampaignRepo.GetEmailCampaignByID(ctx, params) +} + +// ListCampaigns fetches all campaigns for one hackathon. +// Sorting is handled by the SQL query, currently newest first. +func (s *EmailCampaignService) ListCampaigns( + ctx context.Context, + hackathonID string, +) ([]sqlc.EmailCampaign, error) { + return s.emailCampaignRepo.ListEmailCampaigns(ctx, hackathonID) +} + +// UpdateCampaign updates editable campaign fields. +// It first loads the existing campaign so we can enforce status rules before updating. +func (s *EmailCampaignService) UpdateCampaign( + ctx context.Context, + params sqlc.UpdateEmailCampaignParams, +) (*sqlc.EmailCampaign, error) { + existingCampaign, err := s.emailCampaignRepo.GetEmailCampaignByID(ctx, sqlc.GetEmailCampaignByIDParams{ + ID: params.ID, + HackathonID: params.HackathonID, + }) + if err != nil { + return nil, err + } + + if !canEditCampaign(existingCampaign.Status) { + return nil, ErrEmailCampaignCannotEdit + } + + return s.emailCampaignRepo.UpdateEmailCampaign(ctx, params) +} + +// UpdateCampaignStatus changes lifecycle fields such as draft -> scheduled or sending -> sent. +// The database also has constraints, but checking here gives cleaner service-level errors. +func (s *EmailCampaignService) UpdateCampaignStatus( + ctx context.Context, + params sqlc.UpdateEmailCampaignStatusParams, +) (*sqlc.EmailCampaign, error) { + if params.Status == sqlc.EmailCampaignStatusScheduled && params.ScheduledAt == nil { + return nil, ErrEmailCampaignScheduledAtRequired + } + + if params.Status == sqlc.EmailCampaignStatusSent && params.SentAt == nil { + return nil, ErrEmailCampaignSentAtRequired + } + + return s.emailCampaignRepo.UpdateEmailCampaignStatus(ctx, params) +} + +// validateCampaignContent checks fields that every campaign needs before it is saved. +// strings.TrimSpace prevents values like " " from passing validation. +func validateCampaignContent( + title string, + subject string, + body string, + recipientTypes []sqlc.EmailRecipientType, +) error { + if strings.TrimSpace(title) == "" { + return ErrEmailCampaignTitleRequired + } + + if strings.TrimSpace(subject) == "" { + return ErrEmailCampaignSubjectRequired + } + + if strings.TrimSpace(body) == "" { + return ErrEmailCampaignBodyRequired + } + + if len(recipientTypes) == 0 { + return ErrEmailCampaignRecipientsRequired + } + + return nil +} + +// canEditCampaign centralizes edit rules. +// Drafts are editable, and scheduled campaigns can still be adjusted before sending. +func canEditCampaign(status sqlc.EmailCampaignStatus) bool { + return status == sqlc.EmailCampaignStatusDraft || + status == sqlc.EmailCampaignStatusScheduled +} \ No newline at end of file From 686fb358a1390f0db80e88730231726f0fd83003 Mon Sep 17 00:00:00 2001 From: William Chi Date: Wed, 3 Jun 2026 11:58:49 -0700 Subject: [PATCH 10/13] Add email campaign HTTP routes and handler implementations --- .../internal/domains/email/campaign_http.go | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 apps/api/internal/domains/email/campaign_http.go diff --git a/apps/api/internal/domains/email/campaign_http.go b/apps/api/internal/domains/email/campaign_http.go new file mode 100644 index 00000000..4f915496 --- /dev/null +++ b/apps/api/internal/domains/email/campaign_http.go @@ -0,0 +1,301 @@ +package email + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/api/cookie" + "github.com/swamphacks/core/apps/api/internal/api/middleware" + "github.com/swamphacks/core/apps/api/internal/ctxutils" + "github.com/swamphacks/core/apps/api/internal/database/sqlc" +) + +func RegisterCampaignRoutes(emailCampaignHandler *emailCampaignHandler, group huma.API, mw *middleware.Middleware) { + huma.Register(group, huma.Operation{ + OperationID: "create-email-campaign", + Method: http.MethodPost, + Summary: "Create Email Campaign", + Description: "Creates a saved email campaign draft for a hackathon.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusCreated, + }, emailCampaignHandler.handleCreateCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "list-email-campaigns", + Method: http.MethodGet, + Summary: "List Email Campaigns", + Description: "Returns all saved email campaigns for a hackathon.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleListCampaigns) + + huma.Register(group, huma.Operation{ + OperationID: "get-email-campaign", + Method: http.MethodGet, + Summary: "Get Email Campaign", + Description: "Returns one saved email campaign by id.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleGetCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "update-email-campaign", + Method: http.MethodPatch, + Summary: "Update Email Campaign", + Description: "Updates editable fields on a draft or scheduled email campaign.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleUpdateCampaign) + + huma.Register(group, huma.Operation{ + OperationID: "update-email-campaign-status", + Method: http.MethodPatch, + Summary: "Update Email Campaign Status", + Description: "Updates lifecycle fields such as status, scheduled_at, sent_at, and last_error.", + Tags: []string{"Email Campaigns"}, + Path: "/campaigns/{campaignId}/status", + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireAdminHuma}, + Errors: []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, + Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, + DefaultStatus: http.StatusOK, + }, emailCampaignHandler.handleUpdateCampaignStatus) +} + +type emailCampaignHandler struct { + emailCampaignService *EmailCampaignService + logger zerolog.Logger +} + +func NewCampaignHandler(emailCampaignService *EmailCampaignService, logger zerolog.Logger) *emailCampaignHandler { + return &emailCampaignHandler{ + emailCampaignService: emailCampaignService, + logger: logger.With().Str("handler", "EmailCampaignHandler").Str("domain", "email").Logger(), + } +} + +type CreateEmailCampaignRequest struct { + HackathonID string `json:"hackathonId" required:"true"` + Title string `json:"title" minLength:"1"` + Description *string `json:"description,omitempty"` + Subject string `json:"subject" minLength:"1"` + Body string `json:"body" minLength:"1"` + Format sqlc.EmailCampaignFormat `json:"format" required:"true"` + RecipientTypes []sqlc.EmailRecipientType `json:"recipientTypes" minItems:"1"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` +} + +type UpdateEmailCampaignRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Subject *string `json:"subject,omitempty"` + Body *string `json:"body,omitempty"` + Format *sqlc.EmailCampaignFormat `json:"format,omitempty"` + RecipientTypes *[]sqlc.EmailRecipientType `json:"recipientTypes,omitempty"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` +} + +type UpdateEmailCampaignStatusRequest struct { + Status sqlc.EmailCampaignStatus `json:"status" required:"true"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` + SentAt *time.Time `json:"sentAt,omitempty"` + LastError *string `json:"lastError,omitempty"` +} + +type EmailCampaignOutput struct { + Body *sqlc.EmailCampaign +} + +type ListEmailCampaignsOutput struct { + Body []sqlc.EmailCampaign +} + +func (h *emailCampaignHandler) handleCreateCampaign(ctx context.Context, input *struct { + Body CreateEmailCampaignRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaign, err := h.emailCampaignService.CreateCampaign(ctx, sqlc.CreateEmailCampaignParams{ + HackathonID: input.Body.HackathonID, + Title: input.Body.Title, + Description: input.Body.Description, + Subject: input.Body.Subject, + Body: input.Body.Body, + Format: input.Body.Format, + RecipientTypes: input.Body.RecipientTypes, + ScheduledAt: input.Body.ScheduledAt, + CreatedByUserID: &userCtx.UserID, + UpdatedByUserID: &userCtx.UserID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to create email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleListCampaigns(ctx context.Context, input *struct { + HackathonID string `query:"hackathonId" required:"true"` +}) (*ListEmailCampaignsOutput, error) { + campaigns, err := h.emailCampaignService.ListCampaigns(ctx, input.HackathonID) + if err != nil { + return nil, campaignHTTPError(err, "Failed to list email campaigns") + } + + return &ListEmailCampaignsOutput{Body: campaigns}, nil +} + +func (h *emailCampaignHandler) handleGetCampaign(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` +}) (*EmailCampaignOutput, error) { + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + + campaign, err := h.emailCampaignService.GetCampaignByID(ctx, sqlc.GetEmailCampaignByIDParams{ + ID: campaignID, + HackathonID: input.HackathonID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to get email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleUpdateCampaign(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` + Body UpdateEmailCampaignRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + + params := sqlc.UpdateEmailCampaignParams{ + TitleDoUpdate: input.Body.Title != nil, + DescriptionDoUpdate: input.Body.Description != nil, + SubjectDoUpdate: input.Body.Subject != nil, + BodyDoUpdate: input.Body.Body != nil, + FormatDoUpdate: input.Body.Format != nil, + RecipientTypesDoUpdate: input.Body.RecipientTypes != nil, + ScheduledAtDoUpdate: input.Body.ScheduledAt != nil, + UpdatedByUserIDDoUpdate: true, + UpdatedByUserID: userCtx.UserID, + ID: campaignID, + HackathonID: input.HackathonID, + } + + if input.Body.Title != nil { + params.Title = *input.Body.Title + } + if input.Body.Description != nil { + params.Description = input.Body.Description + } + if input.Body.Subject != nil { + params.Subject = *input.Body.Subject + } + if input.Body.Body != nil { + params.Body = *input.Body.Body + } + if input.Body.Format != nil { + params.Format = *input.Body.Format + } + if input.Body.RecipientTypes != nil { + params.RecipientTypes = *input.Body.RecipientTypes + } + if input.Body.ScheduledAt != nil { + params.ScheduledAt = input.Body.ScheduledAt + } + + campaign, err := h.emailCampaignService.UpdateCampaign(ctx, params) + if err != nil { + return nil, campaignHTTPError(err, "Failed to update email campaign") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func (h *emailCampaignHandler) handleUpdateCampaignStatus(ctx context.Context, input *struct { + CampaignID string `path:"campaignId"` + HackathonID string `query:"hackathonId" required:"true"` + Body UpdateEmailCampaignStatusRequest +}) (*EmailCampaignOutput, error) { + userCtx := ctxutils.GetUserFromCtx(ctx) + if userCtx == nil { + return nil, huma.Error400BadRequest("Failed to get current user info") + } + + campaignID, err := uuid.Parse(input.CampaignID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid campaign id") + } + + campaign, err := h.emailCampaignService.UpdateCampaignStatus(ctx, sqlc.UpdateEmailCampaignStatusParams{ + Status: input.Body.Status, + ScheduledAtDoUpdate: input.Body.ScheduledAt != nil, + ScheduledAt: input.Body.ScheduledAt, + SentAtDoUpdate: input.Body.SentAt != nil, + SentAt: input.Body.SentAt, + LastErrorDoUpdate: input.Body.LastError != nil, + LastError: input.Body.LastError, + UpdatedByUserIDDoUpdate: true, + UpdatedByUserID: userCtx.UserID, + ID: campaignID, + HackathonID: input.HackathonID, + }) + if err != nil { + return nil, campaignHTTPError(err, "Failed to update email campaign status") + } + + return &EmailCampaignOutput{Body: campaign}, nil +} + +func campaignHTTPError(err error, fallback string) error { + if errors.Is(err, ErrEmailCampaignNotFound) { + return huma.Error404NotFound("Email campaign not found") + } + + if errors.Is(err, ErrEmailCampaignCannotEdit) || + errors.Is(err, ErrEmailCampaignTitleRequired) || + errors.Is(err, ErrEmailCampaignSubjectRequired) || + errors.Is(err, ErrEmailCampaignBodyRequired) || + errors.Is(err, ErrEmailCampaignRecipientsRequired) || + errors.Is(err, ErrEmailCampaignScheduledAtRequired) || + errors.Is(err, ErrEmailCampaignSentAtRequired) { + return huma.Error400BadRequest(err.Error()) + } + + return huma.Error500InternalServerError(fallback) +} From 3c95e641a284fdfa045134a78ad44d4125b2ef26 Mon Sep 17 00:00:00 2001 From: William Chi Date: Wed, 3 Jun 2026 12:07:28 -0700 Subject: [PATCH 11/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/internal/domains/email/campaign_http.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/api/internal/domains/email/campaign_http.go b/apps/api/internal/domains/email/campaign_http.go index 4f915496..a5cf754b 100644 --- a/apps/api/internal/domains/email/campaign_http.go +++ b/apps/api/internal/domains/email/campaign_http.go @@ -202,6 +202,18 @@ func (h *emailCampaignHandler) handleUpdateCampaign(ctx context.Context, input * if err != nil { return nil, huma.Error400BadRequest("Invalid campaign id") } + if input.Body.Title != nil && *input.Body.Title == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignTitleRequired.Error()) + } + if input.Body.Subject != nil && *input.Body.Subject == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignSubjectRequired.Error()) + } + if input.Body.Body != nil && *input.Body.Body == "" { + return nil, huma.Error400BadRequest(ErrEmailCampaignBodyRequired.Error()) + } + if input.Body.RecipientTypes != nil && len(*input.Body.RecipientTypes) == 0 { + return nil, huma.Error400BadRequest(ErrEmailCampaignRecipientsRequired.Error()) + } params := sqlc.UpdateEmailCampaignParams{ TitleDoUpdate: input.Body.Title != nil, From 65f986b34b5f651b15a653ff8f1dcae86eb75eca Mon Sep 17 00:00:00 2001 From: William Chi Date: Tue, 9 Jun 2026 07:13:29 -0700 Subject: [PATCH 12/13] feat: register/manually tested email campaign CRUD API and fix pgx enum array handling --- apps/api/internal/api/api.go | 5 ++ apps/api/internal/database/database.go | 42 ++++++++++++- .../database/queries/email_campaigns.sql | 8 +-- .../database/sqlc/email_campaigns.sql.go | 60 +++++++++---------- .../internal/domains/email/campaign_http.go | 37 +++++++----- .../domains/email/campaign_service.go | 7 +-- 6 files changed, 104 insertions(+), 55 deletions(-) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index dc88253e..ad68834c 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -119,6 +119,7 @@ func Run() { batRunsRepo := repository.NewBatRunsRepository(db) eventInterestsRepo := repository.NewEventInterestsRepository(db) workshopRepo := repository.NewWorkshopsRepository(db) + emailCampaignRepo := repository.NewEmailCampaignRepository(db) mw := mw.NewMiddleware(userRepo, db, logger, config) @@ -139,6 +140,10 @@ func Run() { emailHandler := email.NewHandler(emailService, logger) email.RegisterRoutes(emailHandler, huma.NewGroup(api, "/email"), mw) + emailCampaignService := email.NewEmailCampaignService(emailCampaignRepo, logger) + emailCampaignHandler := email.NewCampaignHandler(emailCampaignService, logger) + email.RegisterCampaignRoutes(emailCampaignHandler, huma.NewGroup(api, "/email"), mw) + batService := bat.NewBatService(applicationRepo, hackathonRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, nil, config, logger) applicationService := application.NewService(applicationRepo, userRepo, hackathonRepo, txm, r2Client, &config.CoreBuckets, nil, emailService, batService, config, logger) applicationHandler := application.NewHandler(applicationService, batService, config, logger) diff --git a/apps/api/internal/database/database.go b/apps/api/internal/database/database.go index afd8ccaf..aa2e9ac1 100644 --- a/apps/api/internal/database/database.go +++ b/apps/api/internal/database/database.go @@ -2,8 +2,11 @@ package database import ( "context" + "errors" "log" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/swamphacks/core/apps/api/internal/database/sqlc" ) @@ -14,7 +17,16 @@ type DB struct { } func NewDB(connStr string) *DB { - pool, err := pgxpool.New(context.Background(), connStr) + poolConfig, err := pgxpool.ParseConfig(connStr) + if err != nil { + log.Fatal(err) + } + + poolConfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + return registerCustomTypes(ctx, conn) + } + + pool, err := pgxpool.NewWithConfig(context.Background(), poolConfig) if err != nil { log.Fatal(err) } @@ -32,3 +44,31 @@ func NewDB(connStr string) *DB { func (d *DB) Close() { d.Pool.Close() } + +func registerCustomTypes(ctx context.Context, conn *pgx.Conn) error { + typeNames := []string{ + "email_campaign_format", + "email_campaign_status", + "email_recipient_type", + "_email_recipient_type", + } + + for _, typeName := range typeNames { + dataType, err := conn.LoadType(ctx, typeName) + if isUndefinedType(err) { + continue + } + if err != nil { + return err + } + + conn.TypeMap().RegisterType(dataType) + } + + return nil +} + +func isUndefinedType(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "42704" +} diff --git a/apps/api/internal/database/queries/email_campaigns.sql b/apps/api/internal/database/queries/email_campaigns.sql index 5619c5cf..8d2fa02f 100644 --- a/apps/api/internal/database/queries/email_campaigns.sql +++ b/apps/api/internal/database/queries/email_campaigns.sql @@ -18,7 +18,7 @@ INSERT INTO email_campaigns ( @subject, @body, @format::email_campaign_format, - @recipient_types::email_recipient_type[], + sqlc.arg(recipient_types)::text[]::email_recipient_type[], sqlc.narg(scheduled_at), sqlc.narg(created_by_user_id), sqlc.narg(updated_by_user_id) @@ -61,11 +61,11 @@ SET ELSE body END, format = CASE WHEN @format_do_update::boolean - THEN @format::email_campaign_format + THEN sqlc.narg(format)::email_campaign_format ELSE format END, recipient_types = CASE WHEN @recipient_types_do_update::boolean - THEN @recipient_types::email_recipient_type[] + THEN sqlc.arg(recipient_types)::text[]::email_recipient_type[] ELSE recipient_types END, scheduled_at = CASE WHEN @scheduled_at_do_update::boolean @@ -102,4 +102,4 @@ SET ELSE updated_by_user_id END WHERE id = @id::uuid AND hackathon_id = @hackathon_id -RETURNING *; \ No newline at end of file +RETURNING *; diff --git a/apps/api/internal/database/sqlc/email_campaigns.sql.go b/apps/api/internal/database/sqlc/email_campaigns.sql.go index c18a9414..cdf9999d 100644 --- a/apps/api/internal/database/sqlc/email_campaigns.sql.go +++ b/apps/api/internal/database/sqlc/email_campaigns.sql.go @@ -31,7 +31,7 @@ INSERT INTO email_campaigns ( $4, $5, $6::email_campaign_format, - $7::email_recipient_type[], + $7::text[]::email_recipient_type[], $8, $9, $10 @@ -40,16 +40,16 @@ RETURNING id, hackathon_id, title, description, subject, body, format, recipient ` type CreateEmailCampaignParams struct { - HackathonID string `json:"hackathon_id"` - Title string `json:"title"` - Description *string `json:"description"` - Subject string `json:"subject"` - Body string `json:"body"` - Format EmailCampaignFormat `json:"format"` - RecipientTypes []EmailRecipientType `json:"recipient_types"` - ScheduledAt *time.Time `json:"scheduled_at"` - CreatedByUserID *uuid.UUID `json:"created_by_user_id"` - UpdatedByUserID *uuid.UUID `json:"updated_by_user_id"` + HackathonID string `json:"hackathon_id"` + Title string `json:"title"` + Description *string `json:"description"` + Subject string `json:"subject"` + Body string `json:"body"` + Format EmailCampaignFormat `json:"format"` + RecipientTypes []string `json:"recipient_types"` + ScheduledAt *time.Time `json:"scheduled_at"` + CreatedByUserID *uuid.UUID `json:"created_by_user_id"` + UpdatedByUserID *uuid.UUID `json:"updated_by_user_id"` } // creates a draft campaign. It stores the title, subject, body, format, recipient groups, and optional schedule time. @@ -195,7 +195,7 @@ SET ELSE format END, recipient_types = CASE WHEN $11::boolean - THEN $12::email_recipient_type[] + THEN $12::text[]::email_recipient_type[] ELSE recipient_types END, scheduled_at = CASE WHEN $13::boolean @@ -211,24 +211,24 @@ RETURNING id, hackathon_id, title, description, subject, body, format, recipient ` type UpdateEmailCampaignParams struct { - TitleDoUpdate bool `json:"title_do_update"` - Title string `json:"title"` - DescriptionDoUpdate bool `json:"description_do_update"` - Description *string `json:"description"` - SubjectDoUpdate bool `json:"subject_do_update"` - Subject string `json:"subject"` - BodyDoUpdate bool `json:"body_do_update"` - Body string `json:"body"` - FormatDoUpdate bool `json:"format_do_update"` - Format EmailCampaignFormat `json:"format"` - RecipientTypesDoUpdate bool `json:"recipient_types_do_update"` - RecipientTypes []EmailRecipientType `json:"recipient_types"` - ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` - ScheduledAt *time.Time `json:"scheduled_at"` - UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` - UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` - ID uuid.UUID `json:"id"` - HackathonID string `json:"hackathon_id"` + TitleDoUpdate bool `json:"title_do_update"` + Title string `json:"title"` + DescriptionDoUpdate bool `json:"description_do_update"` + Description *string `json:"description"` + SubjectDoUpdate bool `json:"subject_do_update"` + Subject string `json:"subject"` + BodyDoUpdate bool `json:"body_do_update"` + Body string `json:"body"` + FormatDoUpdate bool `json:"format_do_update"` + Format NullEmailCampaignFormat `json:"format"` + RecipientTypesDoUpdate bool `json:"recipient_types_do_update"` + RecipientTypes []string `json:"recipient_types"` + ScheduledAtDoUpdate bool `json:"scheduled_at_do_update"` + ScheduledAt *time.Time `json:"scheduled_at"` + UpdatedByUserIDDoUpdate bool `json:"updated_by_user_id_do_update"` + UpdatedByUserID uuid.UUID `json:"updated_by_user_id"` + ID uuid.UUID `json:"id"` + HackathonID string `json:"hackathon_id"` } // edits draft-like campaign fields: title, description, subject, body, format, recipients, and scheduled time. diff --git a/apps/api/internal/domains/email/campaign_http.go b/apps/api/internal/domains/email/campaign_http.go index a5cf754b..ca9eba7a 100644 --- a/apps/api/internal/domains/email/campaign_http.go +++ b/apps/api/internal/domains/email/campaign_http.go @@ -95,24 +95,24 @@ func NewCampaignHandler(emailCampaignService *EmailCampaignService, logger zerol } type CreateEmailCampaignRequest struct { - HackathonID string `json:"hackathonId" required:"true"` - Title string `json:"title" minLength:"1"` - Description *string `json:"description,omitempty"` - Subject string `json:"subject" minLength:"1"` - Body string `json:"body" minLength:"1"` - Format sqlc.EmailCampaignFormat `json:"format" required:"true"` - RecipientTypes []sqlc.EmailRecipientType `json:"recipientTypes" minItems:"1"` - ScheduledAt *time.Time `json:"scheduledAt,omitempty"` + HackathonID string `json:"hackathonId" required:"true"` + Title string `json:"title" minLength:"1"` + Description *string `json:"description,omitempty"` + Subject string `json:"subject" minLength:"1"` + Body string `json:"body" minLength:"1"` + Format sqlc.EmailCampaignFormat `json:"format" required:"true"` + RecipientTypes []string `json:"recipientTypes" minItems:"1"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` } type UpdateEmailCampaignRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Subject *string `json:"subject,omitempty"` - Body *string `json:"body,omitempty"` - Format *sqlc.EmailCampaignFormat `json:"format,omitempty"` - RecipientTypes *[]sqlc.EmailRecipientType `json:"recipientTypes,omitempty"` - ScheduledAt *time.Time `json:"scheduledAt,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Subject *string `json:"subject,omitempty"` + Body *string `json:"body,omitempty"` + Format *sqlc.EmailCampaignFormat `json:"format,omitempty"` + RecipientTypes *[]string `json:"recipientTypes,omitempty"` + ScheduledAt *time.Time `json:"scheduledAt,omitempty"` } type UpdateEmailCampaignStatusRequest struct { @@ -151,6 +151,7 @@ func (h *emailCampaignHandler) handleCreateCampaign(ctx context.Context, input * UpdatedByUserID: &userCtx.UserID, }) if err != nil { + h.logger.Err(err).Msg("Failed to create email campaign") return nil, campaignHTTPError(err, "Failed to create email campaign") } @@ -242,7 +243,10 @@ func (h *emailCampaignHandler) handleUpdateCampaign(ctx context.Context, input * params.Body = *input.Body.Body } if input.Body.Format != nil { - params.Format = *input.Body.Format + params.Format = sqlc.NullEmailCampaignFormat{ + EmailCampaignFormat: *input.Body.Format, + Valid: true, + } } if input.Body.RecipientTypes != nil { params.RecipientTypes = *input.Body.RecipientTypes @@ -253,6 +257,7 @@ func (h *emailCampaignHandler) handleUpdateCampaign(ctx context.Context, input * campaign, err := h.emailCampaignService.UpdateCampaign(ctx, params) if err != nil { + h.logger.Err(err).Msg("Failed to update email campaign") return nil, campaignHTTPError(err, "Failed to update email campaign") } diff --git a/apps/api/internal/domains/email/campaign_service.go b/apps/api/internal/domains/email/campaign_service.go index f978d0a9..30e628ff 100644 --- a/apps/api/internal/domains/email/campaign_service.go +++ b/apps/api/internal/domains/email/campaign_service.go @@ -30,7 +30,7 @@ var ( // EmailCampaignService owns business rules for saved email campaigns. type EmailCampaignService struct { emailCampaignRepo *repository.EmailCampaignRepository - logger zerolog.Logger + logger zerolog.Logger } // NewEmailCampaignService creates the service and stores its dependencies. @@ -58,7 +58,6 @@ func (s *EmailCampaignService) CreateCampaign( return s.emailCampaignRepo.CreateEmailCampaign(ctx, params) } - // GetCampaignByID fetches one campaign scoped to a hackathon. // The hackathon scope prevents one event from reading another event's campaign. func (s *EmailCampaignService) GetCampaignByID( @@ -121,7 +120,7 @@ func validateCampaignContent( title string, subject string, body string, - recipientTypes []sqlc.EmailRecipientType, + recipientTypes []string, ) error { if strings.TrimSpace(title) == "" { return ErrEmailCampaignTitleRequired @@ -147,4 +146,4 @@ func validateCampaignContent( func canEditCampaign(status sqlc.EmailCampaignStatus) bool { return status == sqlc.EmailCampaignStatusDraft || status == sqlc.EmailCampaignStatusScheduled -} \ No newline at end of file +} From 1db4e58bc9329a9826ed6de2c7d1eff531c46965 Mon Sep 17 00:00:00 2001 From: William Chi Date: Mon, 15 Jun 2026 08:26:38 -0500 Subject: [PATCH 13/13] added unit testing for email campaign api endpoints --- .../domains/email/campaign_service_test.go | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 apps/api/internal/domains/email/campaign_service_test.go diff --git a/apps/api/internal/domains/email/campaign_service_test.go b/apps/api/internal/domains/email/campaign_service_test.go new file mode 100644 index 00000000..d1a309b8 --- /dev/null +++ b/apps/api/internal/domains/email/campaign_service_test.go @@ -0,0 +1,139 @@ +package email + +import ( + "context" + "errors" + "testing" + + "github.com/swamphacks/core/apps/api/internal/database/sqlc" +) + +func TestValidateCampaignContent(t *testing.T) { + tests := []struct { + name string + title string + subject string + body string + recipientTypes []string + expectedError error + }{ + { + name: "valid campaign", + title: "Welcome", + subject: "Welcome to SwampHacks", + body: "Campaign body", + recipientTypes: []string{"admins"}, + expectedError: nil, + }, + { + name: "missing title", + title: " ", + subject: "Subject", + body: "Body", + recipientTypes: []string{"admins"}, + expectedError: ErrEmailCampaignTitleRequired, + }, + { + name: "missing subject", + title: "Title", + subject: "", + body: "Body", + recipientTypes: []string{"admins"}, + expectedError: ErrEmailCampaignSubjectRequired, + }, + { + name: "missing body", + title: "Title", + subject: "Subject", + body: " ", + recipientTypes: []string{"admins"}, + expectedError: ErrEmailCampaignBodyRequired, + }, + { + name: "missing recipients", + title: "Title", + subject: "Subject", + body: "Body", + recipientTypes: nil, + expectedError: ErrEmailCampaignRecipientsRequired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateCampaignContent( + test.title, + test.subject, + test.body, + test.recipientTypes, + ) + + if !errors.Is(err, test.expectedError) { + t.Fatalf("expected error %v, got %v", test.expectedError, err) + } + }) + } +} + +func TestCanEditCampaign(t *testing.T) { + tests := []struct { + status sqlc.EmailCampaignStatus + expected bool + }{ + {status: sqlc.EmailCampaignStatusDraft, expected: true}, + {status: sqlc.EmailCampaignStatusScheduled, expected: true}, + {status: sqlc.EmailCampaignStatusSending, expected: false}, + {status: sqlc.EmailCampaignStatusSent, expected: false}, + {status: sqlc.EmailCampaignStatusFailed, expected: false}, + } + + for _, test := range tests { + t.Run(string(test.status), func(t *testing.T) { + result := canEditCampaign(test.status) + + if result != test.expected { + t.Fatalf("expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestUpdateCampaignStatusRequiresScheduledAt(t *testing.T) { + service := &EmailCampaignService{} + + _, err := service.UpdateCampaignStatus( + context.Background(), + sqlc.UpdateEmailCampaignStatusParams{ + Status: sqlc.EmailCampaignStatusScheduled, + ScheduledAt: nil, + }, + ) + + if !errors.Is(err, ErrEmailCampaignScheduledAtRequired) { + t.Fatalf( + "expected %v, got %v", + ErrEmailCampaignScheduledAtRequired, + err, + ) + } +} + +func TestUpdateCampaignStatusRequiresSentAt(t *testing.T) { + service := &EmailCampaignService{} + + _, err := service.UpdateCampaignStatus( + context.Background(), + sqlc.UpdateEmailCampaignStatusParams{ + Status: sqlc.EmailCampaignStatusSent, + SentAt: nil, + }, + ) + + if !errors.Is(err, ErrEmailCampaignSentAtRequired) { + t.Fatalf( + "expected %v, got %v", + ErrEmailCampaignSentAtRequired, + err, + ) + } +} \ No newline at end of file