people.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"context"
  9	"encoding/json"
 10	"fmt"
 11	"maps"
 12	"net/http"
 13	"net/url"
 14	"time"
 15)
 16
 17// Person is a contact in Lunatask's relationship tracker.
 18// FirstName and LastName are encrypted client-side and will be null when read.
 19type Person struct {
 20	ID                   string                `json:"id"`
 21	RelationshipStrength *RelationshipStrength `json:"relationship_strength"`
 22	Sources              []Source              `json:"sources"`
 23	CreatedAt            time.Time             `json:"created_at"`
 24	UpdatedAt            time.Time             `json:"updated_at"`
 25}
 26
 27// CreatePersonRequest defines a new person.
 28// Use [PersonBuilder] for a fluent construction API.
 29//
 30// CustomFields allows setting arbitrary custom fields. Lunatask supports
 31// "email", "birthday", and "phone" out of the box; other fields must first be
 32// defined in the app or [ErrUnprocessableEntity] is returned. These are
 33// flattened to top-level JSON fields when marshaled.
 34type CreatePersonRequest struct {
 35	FirstName            *string               `json:"first_name,omitempty"`
 36	LastName             *string               `json:"last_name,omitempty"`
 37	RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
 38	Source               *string               `json:"source,omitempty"`
 39	SourceID             *string               `json:"source_id,omitempty"`
 40	CustomFields         map[string]any        `json:"-"`
 41}
 42
 43// MarshalJSON flattens CustomFields to top-level JSON fields.
 44func (r CreatePersonRequest) MarshalJSON() ([]byte, error) {
 45	// Alias to avoid infinite recursion
 46	type plain CreatePersonRequest
 47
 48	base, err := json.Marshal(plain(r))
 49	if err != nil {
 50		return nil, fmt.Errorf("marshaling person request: %w", err)
 51	}
 52
 53	if len(r.CustomFields) == 0 {
 54		return base, nil
 55	}
 56
 57	// Merge custom fields into the base object
 58	var merged map[string]any
 59	if err := json.Unmarshal(base, &merged); err != nil {
 60		return nil, fmt.Errorf("unmarshaling person request for merge: %w", err)
 61	}
 62
 63	maps.Copy(merged, r.CustomFields)
 64
 65	data, err := json.Marshal(merged)
 66	if err != nil {
 67		return nil, fmt.Errorf("marshaling merged person request: %w", err)
 68	}
 69
 70	return data, nil
 71}
 72
 73// personResponse wraps a single person from the API.
 74type personResponse struct {
 75	Person Person `json:"person"`
 76}
 77
 78// peopleResponse wraps a list of people from the API.
 79type peopleResponse struct {
 80	People []Person `json:"people"`
 81}
 82
 83// ListPeopleOptions filters people by source integration.
 84type ListPeopleOptions struct {
 85	Source   *string
 86	SourceID *string
 87}
 88
 89// ListPeople returns all people, optionally filtered. Pass nil for all.
 90func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
 91	path := "/people"
 92
 93	if opts != nil {
 94		params := url.Values{}
 95		if opts.Source != nil && *opts.Source != "" {
 96			params.Set("source", *opts.Source)
 97		}
 98
 99		if opts.SourceID != nil && *opts.SourceID != "" {
100			params.Set("source_id", *opts.SourceID)
101		}
102
103		if len(params) > 0 {
104			path = fmt.Sprintf("%s?%s", path, params.Encode())
105		}
106	}
107
108	resp, _, err := doJSON[peopleResponse](ctx, c, http.MethodGet, path, nil)
109	if err != nil {
110		return nil, err
111	}
112
113	return resp.People, nil
114}
115
116// GetPerson fetches a person by ID. Name fields will be null (E2EE).
117func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
118	if personID == "" {
119		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
120	}
121
122	resp, _, err := doJSON[personResponse](ctx, c, http.MethodGet, "/people/"+personID, nil)
123	if err != nil {
124		return nil, err
125	}
126
127	return &resp.Person, nil
128}
129
130// CreatePerson adds a person. Returns (nil, nil) if a duplicate exists
131// with matching source/source_id.
132func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) {
133	resp, noContent, err := doJSON[personResponse](ctx, c, http.MethodPost, "/people", person)
134	if err != nil {
135		return nil, err
136	}
137
138	if noContent {
139		// Intentional: duplicate exists (HTTP 204), not an error
140		return nil, nil //nolint:nilnil
141	}
142
143	return &resp.Person, nil
144}
145
146// DeletePerson removes a person and returns their final state.
147func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
148	if personID == "" {
149		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
150	}
151
152	resp, _, err := doJSON[personResponse](ctx, c, http.MethodDelete, "/people/"+personID, nil)
153	if err != nil {
154		return nil, err
155	}
156
157	return &resp.Person, nil
158}