Skip to content

Commit 8f9995e

Browse files
authored
chore: show custom props for contacts find (#53)
1 parent 7f304ae commit 8f9995e

3 files changed

Lines changed: 157 additions & 13 deletions

File tree

cmd/contacts.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package cmd
22

33
import (
44
"fmt"
5+
"sort"
56
"strconv"
7+
"strings"
68

79
"github.com/loops-so/cli/internal/api"
810
"github.com/loops-so/cli/internal/cmdutil"
@@ -128,19 +130,28 @@ var contactsFindCmd = &cobra.Command{
128130
return nil
129131
}
130132

133+
c := contacts[0]
131134
w := newTableWriter(cmd.OutOrStdout())
132-
fmt.Fprintln(w, "USER ID\tEMAIL\tFIRST NAME\tLAST NAME\tSUBSCRIBED\tSOURCE\tUSER GROUP\tOPT-IN STATUS")
133-
for _, c := range contacts {
134-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
135-
deref(c.UserID),
136-
c.Email,
137-
deref(c.FirstName),
138-
deref(c.LastName),
139-
strconv.FormatBool(c.Subscribed),
140-
c.Source,
141-
c.UserGroup,
142-
deref(c.OptInStatus),
143-
)
135+
fmt.Fprintln(w, "FIELD\tVALUE\tCUSTOM")
136+
row := func(field, value string, custom bool) {
137+
marker := ""
138+
if custom {
139+
marker = "*"
140+
}
141+
fmt.Fprintf(w, "%s\t%s\t%s\n", field, value, marker)
142+
}
143+
row("id", c.ID, false)
144+
row("email", c.Email, false)
145+
row("firstName", deref(c.FirstName), false)
146+
row("lastName", deref(c.LastName), false)
147+
row("subscribed", strconv.FormatBool(c.Subscribed), false)
148+
row("source", c.Source, false)
149+
row("userGroup", c.UserGroup, false)
150+
row("userId", deref(c.UserID), false)
151+
row("optInStatus", deref(c.OptInStatus), false)
152+
row("mailingLists", formatMailingLists(c.MailingLists), false)
153+
for _, k := range sortedKeys(c.Custom) {
154+
row(k, formatCustomValue(c.Custom[k]), true)
144155
}
145156
w.Flush()
146157

@@ -282,6 +293,36 @@ var contactsDeleteCmd = &cobra.Command{
282293
},
283294
}
284295

296+
func formatMailingLists(m map[string]bool) string {
297+
if len(m) == 0 {
298+
return ""
299+
}
300+
keys := make([]string, 0, len(m))
301+
for k, v := range m {
302+
if v {
303+
keys = append(keys, k)
304+
}
305+
}
306+
sort.Strings(keys)
307+
return strings.Join(keys, ", ")
308+
}
309+
310+
func sortedKeys(m map[string]any) []string {
311+
keys := make([]string, 0, len(m))
312+
for k := range m {
313+
keys = append(keys, k)
314+
}
315+
sort.Strings(keys)
316+
return keys
317+
}
318+
319+
func formatCustomValue(v any) string {
320+
if v == nil {
321+
return ""
322+
}
323+
return fmt.Sprintf("%v", v)
324+
}
325+
285326
func init() {
286327
contactsFindCmd.Flags().StringP("email", "e", "", "Contact email address")
287328
contactsFindCmd.Flags().StringP("user-id", "u", "", "Contact user ID")

cmd/contacts_find_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package cmd
22

33
import (
4+
"encoding/json"
45
"net/http"
56
"testing"
67

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

1011
func TestRunContactsFind(t *testing.T) {
11-
body := `[{"id":"cnt_abc123","email":"bob@example.com","firstName":"Bob","lastName":"Smith","source":"api","subscribed":true,"userGroup":"default","userId":"user_123","mailingLists":{},"optInStatus":"accepted"}]`
12+
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"}]`
1213

1314
assertContact := func(t *testing.T, got api.Contact) {
1415
t.Helper()
@@ -23,6 +24,9 @@ func TestRunContactsFind(t *testing.T) {
2324
deref(got.OptInStatus) != "accepted" {
2425
t.Errorf("unexpected contact: %+v", got)
2526
}
27+
if got.Custom["company"] != "Loops" || got.Custom["plan"] != "pro" {
28+
t.Errorf("unexpected custom properties: %v", got.Custom)
29+
}
2630
}
2731

2832
t.Run("finds by email", func(t *testing.T) {
@@ -67,4 +71,47 @@ func TestRunContactsFind(t *testing.T) {
6771
t.Fatal("expected error, got nil")
6872
}
6973
})
74+
75+
t.Run("marshal preserves custom properties", func(t *testing.T) {
76+
serveJSON(t, http.StatusOK, body)
77+
contacts, err := runContactsFind(cfg(t), "bob@example.com", "")
78+
if err != nil {
79+
t.Fatalf("unexpected error: %v", err)
80+
}
81+
b, err := json.Marshal(contacts[0])
82+
if err != nil {
83+
t.Fatalf("marshal error: %v", err)
84+
}
85+
var raw map[string]json.RawMessage
86+
if err := json.Unmarshal(b, &raw); err != nil {
87+
t.Fatalf("unmarshal error: %v", err)
88+
}
89+
for _, key := range []string{"company", "plan"} {
90+
if _, ok := raw[key]; !ok {
91+
t.Errorf("expected custom property %q in marshaled JSON", key)
92+
}
93+
}
94+
})
95+
}
96+
97+
func TestFormatMailingLists(t *testing.T) {
98+
tests := []struct {
99+
name string
100+
in map[string]bool
101+
want string
102+
}{
103+
{"nil", nil, ""},
104+
{"empty", map[string]bool{}, ""},
105+
{"one subscribed", map[string]bool{"list_a": true}, "list_a"},
106+
{"skips unsubscribed", map[string]bool{"list_a": true, "list_b": false}, "list_a"},
107+
{"sorted", map[string]bool{"list_c": true, "list_a": true, "list_b": true}, "list_a, list_b, list_c"},
108+
}
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
got := formatMailingLists(tt.in)
112+
if got != tt.want {
113+
t.Errorf("got %q, want %q", got, tt.want)
114+
}
115+
})
116+
}
70117
}

internal/api/contacts.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,62 @@ type Contact struct {
1818
UserID *string `json:"userId"`
1919
MailingLists map[string]bool `json:"mailingLists"`
2020
OptInStatus *string `json:"optInStatus"`
21+
Custom map[string]any `json:"-"`
22+
}
23+
24+
var knownContactFields = map[string]bool{
25+
"id": true, "email": true, "firstName": true, "lastName": true,
26+
"source": true, "subscribed": true, "userGroup": true, "userId": true,
27+
"mailingLists": true, "optInStatus": true,
28+
}
29+
30+
func (c *Contact) UnmarshalJSON(data []byte) error {
31+
type Alias Contact
32+
if err := json.Unmarshal(data, (*Alias)(c)); err != nil {
33+
return err
34+
}
35+
var raw map[string]json.RawMessage
36+
if err := json.Unmarshal(data, &raw); err != nil {
37+
return err
38+
}
39+
for k, v := range raw {
40+
if knownContactFields[k] {
41+
continue
42+
}
43+
if c.Custom == nil {
44+
c.Custom = make(map[string]any)
45+
}
46+
var val any
47+
if err := json.Unmarshal(v, &val); err != nil {
48+
c.Custom[k] = string(v)
49+
} else {
50+
c.Custom[k] = val
51+
}
52+
}
53+
return nil
54+
}
55+
56+
func (c Contact) MarshalJSON() ([]byte, error) {
57+
type Alias Contact
58+
b, err := json.Marshal(Alias(c))
59+
if err != nil {
60+
return nil, err
61+
}
62+
if len(c.Custom) == 0 {
63+
return b, nil
64+
}
65+
var m map[string]json.RawMessage
66+
if err := json.Unmarshal(b, &m); err != nil {
67+
return nil, err
68+
}
69+
for k, v := range c.Custom {
70+
raw, err := json.Marshal(v)
71+
if err != nil {
72+
return nil, err
73+
}
74+
m[k] = raw
75+
}
76+
return json.Marshal(m)
2177
}
2278

2379
type CreateContactRequest struct {

0 commit comments

Comments
 (0)