feat(people): typed enums, custom field support

Amolith created

- 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

Change summary

builders.go | 18 +++++++++++++++-
people.go   | 58 +++++++++++++++++++++++++++++++++++++++++++++---------
types.go    | 14 +++++++++++++
3 files changed, 78 insertions(+), 12 deletions(-)

Detailed changes

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

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.

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"
+)