Skip to content
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
65 changes: 53 additions & 12 deletions cmd/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cmd

import (
"fmt"
"sort"
"strconv"
"strings"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/cmdutil"
Expand Down Expand Up @@ -128,19 +130,28 @@ var contactsFindCmd = &cobra.Command{
return nil
}

c := contacts[0]
w := newTableWriter(cmd.OutOrStdout())
fmt.Fprintln(w, "USER ID\tEMAIL\tFIRST NAME\tLAST NAME\tSUBSCRIBED\tSOURCE\tUSER GROUP\tOPT-IN STATUS")
for _, c := range contacts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
deref(c.UserID),
c.Email,
deref(c.FirstName),
deref(c.LastName),
strconv.FormatBool(c.Subscribed),
c.Source,
c.UserGroup,
deref(c.OptInStatus),
)
fmt.Fprintln(w, "FIELD\tVALUE\tCUSTOM")
row := func(field, value string, custom bool) {
marker := ""
if custom {
marker = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", field, value, marker)
}
row("id", c.ID, false)
row("email", c.Email, false)
row("firstName", deref(c.FirstName), false)
row("lastName", deref(c.LastName), false)
row("subscribed", strconv.FormatBool(c.Subscribed), false)
row("source", c.Source, false)
row("userGroup", c.UserGroup, false)
row("userId", deref(c.UserID), false)
row("optInStatus", deref(c.OptInStatus), false)
row("mailingLists", formatMailingLists(c.MailingLists), false)
for _, k := range sortedKeys(c.Custom) {
row(k, formatCustomValue(c.Custom[k]), true)
}
w.Flush()

Expand Down Expand Up @@ -282,6 +293,36 @@ var contactsDeleteCmd = &cobra.Command{
},
}

func formatMailingLists(m map[string]bool) string {
if len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k, v := range m {
if v {
keys = append(keys, k)
}
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}

func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

func formatCustomValue(v any) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}

func init() {
contactsFindCmd.Flags().StringP("email", "e", "", "Contact email address")
contactsFindCmd.Flags().StringP("user-id", "u", "", "Contact user ID")
Expand Down
49 changes: 48 additions & 1 deletion cmd/contacts_find_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package cmd

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

"github.com/loops-so/cli/internal/api"
)

func TestRunContactsFind(t *testing.T) {
body := `[{"id":"cnt_abc123","email":"bob@example.com","firstName":"Bob","lastName":"Smith","source":"api","subscribed":true,"userGroup":"default","userId":"user_123","mailingLists":{},"optInStatus":"accepted"}]`
body := `[{"id":"cnt_abc123","email":"bob@example.com","firstName":"Bob","lastName":"Smith","source":"api","subscribed":true,"userGroup":"default","userId":"user_123","mailingLists":{},"optInStatus":"accepted","company":"Loops","plan":"pro"}]`

assertContact := func(t *testing.T, got api.Contact) {
t.Helper()
Expand All @@ -23,6 +24,9 @@ func TestRunContactsFind(t *testing.T) {
deref(got.OptInStatus) != "accepted" {
t.Errorf("unexpected contact: %+v", got)
}
if got.Custom["company"] != "Loops" || got.Custom["plan"] != "pro" {
t.Errorf("unexpected custom properties: %v", got.Custom)
}
}

t.Run("finds by email", func(t *testing.T) {
Expand Down Expand Up @@ -67,4 +71,47 @@ func TestRunContactsFind(t *testing.T) {
t.Fatal("expected error, got nil")
}
})

t.Run("marshal preserves custom properties", func(t *testing.T) {
serveJSON(t, http.StatusOK, body)
contacts, err := runContactsFind(cfg(t), "bob@example.com", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := json.Marshal(contacts[0])
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
for _, key := range []string{"company", "plan"} {
if _, ok := raw[key]; !ok {
t.Errorf("expected custom property %q in marshaled JSON", key)
}
}
})
}

func TestFormatMailingLists(t *testing.T) {
tests := []struct {
name string
in map[string]bool
want string
}{
{"nil", nil, ""},
{"empty", map[string]bool{}, ""},
{"one subscribed", map[string]bool{"list_a": true}, "list_a"},
{"skips unsubscribed", map[string]bool{"list_a": true, "list_b": false}, "list_a"},
{"sorted", map[string]bool{"list_c": true, "list_a": true, "list_b": true}, "list_a, list_b, list_c"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatMailingLists(tt.in)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
56 changes: 56 additions & 0 deletions internal/api/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,62 @@ type Contact struct {
UserID *string `json:"userId"`
MailingLists map[string]bool `json:"mailingLists"`
OptInStatus *string `json:"optInStatus"`
Custom map[string]any `json:"-"`
}

var knownContactFields = map[string]bool{
"id": true, "email": true, "firstName": true, "lastName": true,
"source": true, "subscribed": true, "userGroup": true, "userId": true,
"mailingLists": true, "optInStatus": true,
}

func (c *Contact) UnmarshalJSON(data []byte) error {
type Alias Contact
if err := json.Unmarshal(data, (*Alias)(c)); err != nil {
return err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for k, v := range raw {
if knownContactFields[k] {
continue
}
if c.Custom == nil {
c.Custom = make(map[string]any)
}
var val any
if err := json.Unmarshal(v, &val); err != nil {
c.Custom[k] = string(v)
} else {
c.Custom[k] = val
}
}
return nil
}

func (c Contact) MarshalJSON() ([]byte, error) {
type Alias Contact
b, err := json.Marshal(Alias(c))
if err != nil {
return nil, err
}
if len(c.Custom) == 0 {
return b, nil
}
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
for k, v := range c.Custom {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
m[k] = raw
}
return json.Marshal(m)
}

type CreateContactRequest struct {
Expand Down