Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ActiveCampaign endpoint #30

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions pkg/activecampaign/activecampaign.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Package activecampaign wraps the Active Campaign API.
package activecampaign

import (
"context"
"net/http"

"github.com/carlmjohnson/requests"
)

func New(host, key string, cl *http.Client) Client {
return Client{
requests.
New().
Scheme("https").
Host(host).
Client(cl).
Header("Api-Token", key),
}
}

type Client struct {
rb *requests.Builder
}

type FieldValue struct {
Field string `json:"field"`
Value string `json:"value"`
}

type Contact struct {
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Phone string `json:"phone"`
FieldValues []FieldValue `json:"fieldValues"`
}

func (cl Client) CreateContact(ctx context.Context, contact Contact) error {
type CreateContact struct {
Contact Contact `json:"contact"`
}

return cl.rb.Clone().
Path("/api/3/contacts").
BodyJSON(CreateContact{contact}).
CheckStatus(201, 422).
Fetch(ctx)
}

func (cl Client) FindContactByEmail(ctx context.Context, email string) (FindContactResponse, error) {
var data FindContactResponse
err := cl.rb.Clone().
Path("/api/3/contacts").
Param("email", email).
ToJSON(&data).
Fetch(ctx)
return data, err
}

type FindContactResponse struct {
Contacts []ContactInfo `json:"contacts"`
Meta FindContactMeta `json:"meta"`
}

type ContactInfo struct {
ID int `json:"id,string"`
Email string `json:"email"`
Cdate string `json:"cdate"`
Phone string `json:"phone"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Orgid string `json:"orgid"`
SegmentioID string `json:"segmentio_id"`
BouncedHard string `json:"bounced_hard"`
BouncedSoft string `json:"bounced_soft"`
BouncedDate string `json:"bounced_date"`
IP string `json:"ip"`
Ua string `json:"ua"`
Hash string `json:"hash"`
SocialdataLastcheck string `json:"socialdata_lastcheck"`
EmailLocal string `json:"email_local"`
EmailDomain string `json:"email_domain"`
Sentcnt string `json:"sentcnt"`
RatingTstamp string `json:"rating_tstamp"`
Gravatar string `json:"gravatar"`
Deleted string `json:"deleted"`
Adate string `json:"adate"`
Udate string `json:"udate"`
Edate string `json:"edate"`
ScoreValues []any `json:"scoreValues"`
Organization any `json:"organization"`
AccountContacts []string `json:"accountContacts,omitempty"`
}

type FindContactMeta struct {
Total string `json:"total"`
}

func (cl Client) AddToList(ctx context.Context, listID, contactID int) error {
type ContactList struct {
List int `json:"list"`
Contact int `json:"contact"`
Status int `json:"status"`
}
type AddToList struct {
ContactList ContactList `json:"contactList"`
}
return cl.rb.Clone().
Path("/api/3/contactLists").
BodyJSON(AddToList{ContactList: ContactList{
List: listID,
Contact: contactID,
Status: 1,
}}).
Fetch(ctx)
}
6 changes: 6 additions & 0 deletions pkg/emailalerts/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/carlmjohnson/gateway"
"github.com/carlmjohnson/versioninfo"
"github.com/getsentry/sentry-go"
"github.com/spotlightpa/email-alerts/pkg/activecampaign"
"github.com/spotlightpa/email-alerts/pkg/kickbox"
"github.com/spotlightpa/email-alerts/pkg/mailchimp"
)
Expand Down Expand Up @@ -49,6 +50,9 @@ func (app *appEnv) ParseArgs(args []string) error {
kb := fs.String("kickbox-api-key", "", "API `key` for Kickbox")
sentryDSN := fs.String("sentry-dsn", "", "DSN `pseudo-URL` for Sentry")
getMC := mailchimp.FlagVar(fs)
acHost := fs.String("active-campaign-host", "", "`host` URL for Active Campaign")
acKey := fs.String("active-campaign-api-key", "", "API `key` for Active Campaign")

if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -60,6 +64,7 @@ func (app *appEnv) ParseArgs(args []string) error {
}
app.kb = kickbox.New(*kb, app.l)
app.mc = getMC(&http.Client{Timeout: 5 * time.Second})
app.ac = activecampaign.New(*acHost, *acKey, &http.Client{Timeout: 5 * time.Second})
return nil
}

Expand All @@ -68,6 +73,7 @@ type appEnv struct {
l *log.Logger
mc mailchimp.V3
kb *kickbox.Client
ac activecampaign.Client
}

func (app *appEnv) Exec() (err error) {
Expand Down
1 change: 1 addition & 0 deletions pkg/emailalerts/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (app *appEnv) routes() http.Handler {
srv := http.NewServeMux()
srv.HandleFunc("GET /api/healthcheck", app.ping)
srv.HandleFunc("POST /api/subscribe", app.postSubscribeMailchimp)
srv.HandleFunc("POST /api/subscribe-v2", app.postSubscribeActiveCampaign)
if app.isLambda() {
srv.HandleFunc("/", app.notFound)
} else {
Expand Down
144 changes: 144 additions & 0 deletions pkg/emailalerts/subscribe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package emailalerts

import (
"maps"
"net/http"
"strings"
"time"

"github.com/carlmjohnson/emailx"
"github.com/carlmjohnson/resperr"
"github.com/gorilla/schema"
"github.com/spotlightpa/email-alerts/pkg/activecampaign"
)

func (app *appEnv) postSubscribeActiveCampaign(w http.ResponseWriter, r *http.Request) {
app.Printf("start postSubscribeActiveCampaign")

if err := r.ParseForm(); err != nil {
err = resperr.New(http.StatusBadRequest,
"could not parse request: %w", err)
app.redirectErr(w, r, err)
return
}

decoder := schema.NewDecoder()
decoder.IgnoreUnknownKeys(true)
var req struct {
EmailAddress string `schema:"EMAIL"`
FirstName string `schema:"FNAME"`
LastName string `schema:"LNAME"`
Investigator bool `schema:"investigator"`
PAPost bool `schema:"papost"`
BreakingNews bool `schema:"breaking_news"`
PALocal bool `schema:"palocal"`
BerksCounty bool `schema:"berks_county"`
Berks bool `schema:"berks"` // Alias for BerksCounty
TalkOfTheTown bool `schema:"talkofthetown"` // Alias for StateCollege
StateCollege bool `schema:"state_college"`
WeekInReview bool `schema:"week_in_review"`
PennStateAlerts bool `schema:"pennstatealert"`
CentreCountyDocumenters bool `schema:"centre_county_documenters"` // Alias for CentreDocumenters
CentreDocumenters bool `schema:"centredocumenters"`
HowWeCare bool `schema:"care"`
Honeypot bool `schema:"contact"`
Shibboleth string `schema:"shibboleth"`
Timestamp *time.Time `schema:"shibboleth_timestamp"`
}
if err := decoder.Decode(&req, r.PostForm); err != nil {
app.redirectErr(w, r, err)
return
}
if err := validate(req.EmailAddress, req.FirstName, req.LastName); err != nil {
app.redirectErr(w, r, err)
return
}
if req.Shibboleth != "PA Rocks!" || req.Timestamp == nil {
err := resperr.New(http.StatusBadRequest,
"missing shibboleth: %q", req.EmailAddress)
err = resperr.WithUserMessage(err,
"Too fast! Please leave the page open for 15 seconds before signing up.")
app.redirectErr(w, r, err)
return
}
if time.Since(*req.Timestamp).Abs() > 24*time.Hour {
err := resperr.New(http.StatusBadRequest,
"bad timestamp: %q", req.EmailAddress)
err = resperr.WithUserMessage(err,
"Page too old. Please reload the window and try again.")
app.redirectErr(w, r, err)
return
}
if req.Honeypot {
err := resperr.New(http.StatusBadRequest,
"checked honeypot %q", req.EmailAddress)
err = resperr.WithUserMessage(err,
"There was a problem with your request")
app.redirectErr(w, r, err)
return
}
if !app.kb.Verify(r.Context(), req.EmailAddress) {
err := resperr.New(http.StatusBadRequest,
"Kickbox rejected %q", req.EmailAddress)
err = resperr.WithUserMessage(err,
"There was a problem with your request")
app.redirectErr(w, r, err)
return
}

interests := map[int]bool{
1: true, // Master list
3: req.PALocal,
4: req.PAPost,
5: req.Investigator,
6: req.HowWeCare,
7: req.TalkOfTheTown ||
req.StateCollege,
8: req.PennStateAlerts,
9: req.BerksCounty ||
req.Berks,
// TODO!!
// "6137d9281f": req.BreakingNews,
// "5c3b89e306": req.WeekInReview,
// "650bf212f7": req.CentreCountyDocumenters ||
// req.CentreDocumenters,
}
maps.DeleteFunc(interests, func(k int, v bool) bool {
return !v
})

if err := app.ac.CreateContact(r.Context(), activecampaign.Contact{
Email: emailx.Normalize(req.EmailAddress),
FirstName: strings.TrimSpace(req.FirstName),
LastName: strings.TrimSpace(req.LastName),
}); err != nil {
app.redirectErr(w, r, err)
return
}

app.l.Printf("subscribed: email=%q", req.EmailAddress)
res, err := app.ac.FindContactByEmail(r.Context(), emailx.Normalize(req.EmailAddress))
if err != nil {
app.redirectErr(w, r, err)
return
}
if len(res.Contacts) != 1 {
err := resperr.New(http.StatusBadRequest,
"Could not find user ID %q", req.EmailAddress)
err = resperr.WithUserMessage(err,
"There was a problem while processing your request. Please try again.")
app.redirectErr(w, r, err)
return
}
contactID := res.Contacts[0].ID
app.l.Printf("found user: id=%d", contactID)

for listID := range interests {
if err := app.ac.AddToList(r.Context(), listID, contactID); err != nil {
app.redirectErr(w, r, err)
return
}
}
dest := validateRedirect(r.FormValue("redirect"), "/thanks.html")
http.Redirect(w, r, dest, http.StatusSeeOther)
}
Loading