@@ -67,6 +67,44 @@ func (r createPersonRequest) MarshalJSON() ([]byte, error) {
return data, nil
}
+// updatePersonRequest specifies which fields to change on a person.
+type updatePersonRequest struct {
+ FirstName *string `json:"first_name,omitempty"`
+ LastName *string `json:"last_name,omitempty"`
+ RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
+ CustomFields map[string]any `json:"-"`
+}
+
+// MarshalJSON flattens CustomFields to top-level JSON fields.
+func (r updatePersonRequest) MarshalJSON() ([]byte, error) {
+ // Alias to avoid infinite recursion
+ type plain updatePersonRequest
+
+ base, err := json.Marshal(plain(r))
+ if err != nil {
+ return nil, fmt.Errorf("marshaling person update 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 update 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 update request: %w", err)
+ }
+
+ return data, nil
+}
+
// personResponse wraps a single person from the API.
type personResponse struct {
Person Person `json:"person"`
@@ -164,3 +202,60 @@ func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) {
return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person })
}
+
+// PersonUpdateBuilder constructs and updates a person via method chaining.
+// Only fields you set will be modified; others remain unchanged.
+//
+// person, err := client.NewPersonUpdate(personID).
+// WithRelationshipStrength(lunatask.RelationshipCloseFriend).
+// Update(ctx)
+type PersonUpdateBuilder struct {
+ client *Client
+ personID string
+ req updatePersonRequest
+}
+
+// NewPersonUpdate starts building a person update for the given person ID.
+func (c *Client) NewPersonUpdate(personID string) *PersonUpdateBuilder {
+ return &PersonUpdateBuilder{client: c, personID: personID}
+}
+
+// FirstName changes the person's first name.
+func (b *PersonUpdateBuilder) FirstName(firstName string) *PersonUpdateBuilder {
+ b.req.FirstName = &firstName
+
+ return b
+}
+
+// LastName changes the person's last name.
+func (b *PersonUpdateBuilder) LastName(lastName string) *PersonUpdateBuilder {
+ b.req.LastName = &lastName
+
+ return b
+}
+
+// WithRelationshipStrength categorizes the closeness of the relationship.
+// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
+func (b *PersonUpdateBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonUpdateBuilder {
+ b.req.RelationshipStrength = &strength
+
+ 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 *PersonUpdateBuilder) WithCustomField(key string, value any) *PersonUpdateBuilder {
+ if b.req.CustomFields == nil {
+ b.req.CustomFields = make(map[string]any)
+ }
+
+ b.req.CustomFields[key] = value
+
+ return b
+}
+
+// Update sends the changes to Lunatask.
+func (b *PersonUpdateBuilder) Update(ctx context.Context) (*Person, error) {
+ return update(ctx, b.client, "/people", b.personID, "person", b.req, func(r personResponse) Person { return r.Person })
+}
@@ -213,6 +213,76 @@ func TestCreatePerson_Errors(t *testing.T) {
})
}
+// --- UpdatePerson ---
+
+func TestUpdatePerson_Success(t *testing.T) {
+ t.Parallel()
+
+ server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody)
+ defer server.Close()
+
+ client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+ person, err := client.NewPersonUpdate(personID).
+ FirstName("Jane").
+ LastName("Smith").
+ WithRelationshipStrength(lunatask.RelationshipCloseFriend).
+ Update(ctx())
+ if err != nil {
+ t.Fatalf("error = %v", err)
+ }
+
+ if person == nil {
+ t.Fatal("returned nil")
+ }
+
+ if person.ID != personID {
+ t.Errorf("ID = %q, want %q", person.ID, personID)
+ }
+
+ assertBodyField(t, capture.Body, "first_name", "Jane")
+ assertBodyField(t, capture.Body, "last_name", "Smith")
+ assertBodyField(t, capture.Body, "relationship_strength", "close-friends")
+}
+
+func TestUpdatePerson_AllFields(t *testing.T) {
+ t.Parallel()
+
+ server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody)
+ defer server.Close()
+
+ client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+ _, err := client.NewPersonUpdate(personID).
+ FirstName("Ada").
+ LastName("Lovelace").
+ WithRelationshipStrength(lunatask.RelationshipFamily).
+ WithCustomField("email", "ada@example.com").
+ WithCustomField("phone", "+1234567890").
+ WithCustomField("birthday", "1815-12-10").
+ Update(ctx())
+ if err != nil {
+ t.Fatalf("error = %v", err)
+ }
+
+ assertBodyField(t, capture.Body, "first_name", "Ada")
+ assertBodyField(t, capture.Body, "last_name", "Lovelace")
+ assertBodyField(t, capture.Body, "relationship_strength", "family")
+ assertBodyField(t, capture.Body, "email", "ada@example.com")
+ assertBodyField(t, capture.Body, "phone", "+1234567890")
+ assertBodyField(t, capture.Body, "birthday", "1815-12-10")
+}
+
+func TestUpdatePerson_Errors(t *testing.T) {
+ t.Parallel()
+
+ testErrorCases(t, func(c *lunatask.Client) error {
+ _, err := c.NewPersonUpdate(personID).FirstName("x").Update(ctx())
+
+ return err //nolint:wrapcheck // test helper
+ })
+}
+
// --- DeletePerson ---
func TestDeletePerson_Success(t *testing.T) {