From 932e47b959040a424d5dd0d42dc85d1fffabb994 Mon Sep 17 00:00:00 2001 From: William Chi Date: Sat, 4 Apr 2026 17:00:28 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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"`