From 152fc3274be770460e51224a54826c3a9102b7fb Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 19 Dec 2025 08:33:24 -0700 Subject: [PATCH] feat(people): typed enums, custom field support - Add RelationshipStrength type with constants for valid values - Add CustomFields map for arbitrary fields (email, birthday, phone, etc.) - Custom MarshalJSON flattens CustomFields to top-level JSON - Add WithCustomField builder method Assisted-by: Claude Opus 4.5 via Crush --- builders.go | 18 +++++++++++++++-- people.go | 58 ++++++++++++++++++++++++++++++++++++++++++++--------- types.go | 14 +++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/builders.go b/builders.go index e44de25c993a4ee664156eda1d65e0f96b008df8..6b1bc6678e14c9e3eea9f256b36c76f9b263a379 100644 --- a/builders.go +++ b/builders.go @@ -329,8 +329,9 @@ func (b *PersonBuilder) WithLastName(name string) *PersonBuilder { return b } -// WithRelationshipStrength categorizes how close you are: close, regular, distant, or acquaintance. -func (b *PersonBuilder) WithRelationshipStrength(strength string) *PersonBuilder { +// WithRelationshipStrength categorizes the closeness of the relationship. +// Use one of the Relationship* constants (e.g., RelationshipCloseFriend). +func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder { b.req.RelationshipStrength = &strength return b @@ -345,6 +346,19 @@ func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder { return b } +// WithCustomField sets an arbitrary custom field. Lunatask supports "email", +// "birthday", and "phone" out of the box; other fields must first be defined +// in the app or [ErrUnprocessableEntity] is returned. +func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder { + if b.req.CustomFields == nil { + b.req.CustomFields = make(map[string]any) + } + + b.req.CustomFields[key] = value + + return b +} + // Build returns the constructed request. func (b *PersonBuilder) Build() *CreatePersonRequest { return &b.req diff --git a/people.go b/people.go index b10b26926ee6ed340f965576d2170862ea6f53a3..b89265b55045860168bede28999bb090cb4087d3 100644 --- a/people.go +++ b/people.go @@ -6,7 +6,9 @@ package lunatask import ( "context" + "encoding/json" "fmt" + "maps" "net/http" "net/url" "time" @@ -15,21 +17,57 @@ import ( // Person is a contact in Lunatask's relationship tracker. // FirstName and LastName are encrypted client-side and will be null when read. type Person struct { - ID string `json:"id"` - RelationshipStrength *string `json:"relationship_strength"` - Sources []Source `json:"sources"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + RelationshipStrength *RelationshipStrength `json:"relationship_strength"` + Sources []Source `json:"sources"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreatePersonRequest defines a new person. // Use [PersonBuilder] for a fluent construction API. +// +// CustomFields allows setting arbitrary custom fields. Lunatask supports +// "email", "birthday", and "phone" out of the box; other fields must first be +// defined in the app or [ErrUnprocessableEntity] is returned. These are +// flattened to top-level JSON fields when marshaled. type CreatePersonRequest struct { - FirstName *string `json:"first_name,omitempty"` - LastName *string `json:"last_name,omitempty"` - RelationshipStrength *string `json:"relationship_strength,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"` + Source *string `json:"source,omitempty"` + SourceID *string `json:"source_id,omitempty"` + CustomFields map[string]any `json:"-"` +} + +// MarshalJSON flattens CustomFields to top-level JSON fields. +func (r CreatePersonRequest) MarshalJSON() ([]byte, error) { + // Alias to avoid infinite recursion + type plain CreatePersonRequest + + base, err := json.Marshal(plain(r)) + if err != nil { + return nil, fmt.Errorf("marshaling person request: %w", err) + } + + if len(r.CustomFields) == 0 { + return base, nil + } + + // Merge custom fields into the base object + var merged map[string]any + if err := json.Unmarshal(base, &merged); err != nil { + return nil, fmt.Errorf("unmarshaling person request for merge: %w", err) + } + + maps.Copy(merged, r.CustomFields) + + data, err := json.Marshal(merged) + if err != nil { + return nil, fmt.Errorf("marshaling merged person request: %w", err) + } + + return data, nil } // personResponse wraps a single person from the API. diff --git a/types.go b/types.go index 211b5c18951adee3b02b6b8493eff441e920fd9e..5e4817c21765ebde5efd9293e60d68d73bbf1115 100644 --- a/types.go +++ b/types.go @@ -87,3 +87,17 @@ func ParseDate(s string) (Date, error) { func Today() Date { return NewDate(time.Now()) } + +// RelationshipStrength categorizes the closeness of a relationship. +type RelationshipStrength string + +// Valid relationship strength values. +const ( + RelationshipFamily RelationshipStrength = "family" + RelationshipIntimateFriend RelationshipStrength = "intimate-friends" + RelationshipCloseFriend RelationshipStrength = "close-friends" + RelationshipCasualFriend RelationshipStrength = "casual-friends" + RelationshipAcquaintance RelationshipStrength = "acquaintances" + RelationshipBusiness RelationshipStrength = "business-contacts" + RelationshipAlmostStranger RelationshipStrength = "almost-strangers" +)