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 "time"
13)
14
15// Person is a contact in Lunatask's relationship tracker.
16// FirstName and LastName are encrypted client-side and will be null when read.
17type Person struct {
18 ID string `json:"id"`
19 RelationshipStrength *RelationshipStrength `json:"relationship_strength"`
20 Sources []Source `json:"sources"`
21 CreatedAt time.Time `json:"created_at"`
22 UpdatedAt time.Time `json:"updated_at"`
23}
24
25// createPersonRequest defines a new person for JSON serialization.
26//
27// CustomFields allows setting arbitrary custom fields. Lunatask supports
28// "email", "birthday", and "phone" out of the box; other fields must first be
29// defined in the app or [ErrUnprocessableEntity] is returned. These are
30// flattened to top-level JSON fields when marshaled.
31type createPersonRequest struct {
32 FirstName *string `json:"first_name,omitempty"`
33 LastName *string `json:"last_name,omitempty"`
34 RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
35 Source *string `json:"source,omitempty"`
36 SourceID *string `json:"source_id,omitempty"`
37 CustomFields map[string]any `json:"-"`
38}
39
40// MarshalJSON flattens CustomFields to top-level JSON fields.
41func (r createPersonRequest) MarshalJSON() ([]byte, error) {
42 // Alias to avoid infinite recursion
43 type plain createPersonRequest
44
45 base, err := json.Marshal(plain(r))
46 if err != nil {
47 return nil, fmt.Errorf("marshaling person request: %w", err)
48 }
49
50 if len(r.CustomFields) == 0 {
51 return base, nil
52 }
53
54 // Merge custom fields into the base object
55 var merged map[string]any
56 if err := json.Unmarshal(base, &merged); err != nil {
57 return nil, fmt.Errorf("unmarshaling person request for merge: %w", err)
58 }
59
60 maps.Copy(merged, r.CustomFields)
61
62 data, err := json.Marshal(merged)
63 if err != nil {
64 return nil, fmt.Errorf("marshaling merged person request: %w", err)
65 }
66
67 return data, nil
68}
69
70// personResponse wraps a single person from the API.
71type personResponse struct {
72 Person Person `json:"person"`
73}
74
75// peopleResponse wraps a list of people from the API.
76type peopleResponse struct {
77 People []Person `json:"people"`
78}
79
80// ListPeopleOptions filters people by source integration.
81type ListPeopleOptions struct {
82 Source *string
83 SourceID *string
84}
85
86// GetSource implements [SourceFilter].
87func (o *ListPeopleOptions) GetSource() *string { return o.Source }
88
89// GetSourceID implements [SourceFilter].
90func (o *ListPeopleOptions) GetSourceID() *string { return o.SourceID }
91
92// ListPeople returns all people, optionally filtered. Pass nil for all.
93func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
94 var filter SourceFilter
95 if opts != nil {
96 filter = opts
97 }
98
99 return list(ctx, c, "/people", filter, func(r peopleResponse) []Person { return r.People })
100}
101
102// GetPerson fetches a person by ID. Name fields will be null (E2EE).
103func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
104 return get(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
105}
106
107// DeletePerson removes a person and returns their final state.
108func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
109 return del(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
110}
111
112// PersonBuilder constructs and creates a person via method chaining.
113// Name fields are encrypted client-side; the API accepts them on create but
114// returns null on read.
115//
116// person, err := client.NewPerson("Ada", "Lovelace").
117// WithRelationshipStrength(lunatask.RelationshipCloseFriend).
118// Create(ctx)
119type PersonBuilder struct {
120 client *Client
121 req createPersonRequest
122}
123
124// NewPerson starts building a person entry with the given name.
125func (c *Client) NewPerson(firstName, lastName string) *PersonBuilder {
126 return &PersonBuilder{
127 client: c,
128 req: createPersonRequest{FirstName: &firstName, LastName: &lastName},
129 }
130}
131
132// WithRelationshipStrength categorizes the closeness of the relationship.
133// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
134func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
135 b.req.RelationshipStrength = &strength
136
137 return b
138}
139
140// FromSource tags the person with a free-form origin identifier, useful for
141// tracking entries created by scripts or external integrations.
142func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
143 b.req.Source = &source
144 b.req.SourceID = &sourceID
145
146 return b
147}
148
149// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
150// "birthday", and "phone" out of the box; other fields must first be defined
151// in the app or [ErrUnprocessableEntity] is returned.
152func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
153 if b.req.CustomFields == nil {
154 b.req.CustomFields = make(map[string]any)
155 }
156
157 b.req.CustomFields[key] = value
158
159 return b
160}
161
162// Create sends the person to Lunatask.
163// Returns (nil, nil) if a duplicate exists with matching source/source_id.
164func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) {
165 return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person })
166}