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}