1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package task
6
7import (
8 "context"
9 "errors"
10 "sort"
11 "strings"
12
13 "git.secluded.site/np/internal/db"
14 "git.secluded.site/np/internal/timeutil"
15)
16
17// CreateParams captures the data required to create a new task.
18type CreateParams struct {
19 ID string
20 Title string
21 Description string
22 Status Status
23 CreatedSeq uint64
24}
25
26// Mutator modifies a task prior to persistence.
27type Mutator func(*Task) error
28
29// Store coordinates task persistence and retrieval.
30type Store struct {
31 db *db.Database
32 clock timeutil.Clock
33}
34
35// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
36func NewStore(database *db.Database, clock timeutil.Clock) *Store {
37 if clock == nil {
38 clock = timeutil.UTCClock{}
39 }
40 return &Store{
41 db: database,
42 clock: clock,
43 }
44}
45
46// WithTxn exposes transactional helpers for use within db.Update.
47func (s *Store) WithTxn(txn *db.Txn) TxnStore {
48 return TxnStore{
49 txn: txn,
50 clock: s.clock,
51 }
52}
53
54// Create inserts a task into sid.
55func (s *Store) Create(ctx context.Context, sid string, params CreateParams) (Task, error) {
56 var out Task
57 err := s.db.Update(ctx, func(txn *db.Txn) error {
58 var err error
59 out, err = TxnStore{txn: txn, clock: s.clock}.Create(sid, params)
60 return err
61 })
62 return out, err
63}
64
65// Get retrieves a task by ID.
66func (s *Store) Get(ctx context.Context, sid, id string) (Task, error) {
67 var out Task
68 err := s.db.View(ctx, func(txn *db.Txn) error {
69 var err error
70 out, err = TxnStore{txn: txn, clock: s.clock}.Get(sid, id)
71 return err
72 })
73 return out, err
74}
75
76// Update applies mutate to the stored task and persists the result.
77func (s *Store) Update(ctx context.Context, sid, id string, mutate Mutator) (Task, error) {
78 var out Task
79 err := s.db.Update(ctx, func(txn *db.Txn) error {
80 var err error
81 out, err = TxnStore{txn: txn, clock: s.clock}.Update(sid, id, mutate)
82 return err
83 })
84 return out, err
85}
86
87// UpdateStatus changes the status of a task.
88func (s *Store) UpdateStatus(ctx context.Context, sid, id string, status Status) (Task, error) {
89 var out Task
90 err := s.db.Update(ctx, func(txn *db.Txn) error {
91 var err error
92 out, err = TxnStore{txn: txn, clock: s.clock}.UpdateStatus(sid, id, status)
93 return err
94 })
95 return out, err
96}
97
98// Delete removes a task from storage.
99func (s *Store) Delete(ctx context.Context, sid, id string) error {
100 return s.db.Update(ctx, func(txn *db.Txn) error {
101 return TxnStore{txn: txn, clock: s.clock}.Delete(sid, id)
102 })
103}
104
105// List returns all tasks for sid sorted by creation order.
106func (s *Store) List(ctx context.Context, sid string) ([]Task, error) {
107 var out []Task
108 err := s.db.View(ctx, func(txn *db.Txn) error {
109 var err error
110 out, err = TxnStore{txn: txn, clock: s.clock}.List(sid)
111 return err
112 })
113 return out, err
114}
115
116// ListByStatus returns tasks matching status for sid sorted by creation order.
117func (s *Store) ListByStatus(ctx context.Context, sid string, status Status) ([]Task, error) {
118 var out []Task
119 err := s.db.View(ctx, func(txn *db.Txn) error {
120 var err error
121 out, err = TxnStore{txn: txn, clock: s.clock}.ListByStatus(sid, status)
122 return err
123 })
124 return out, err
125}
126
127// Exists reports whether a task with id is stored for sid.
128func (s *Store) Exists(ctx context.Context, sid, id string) (bool, error) {
129 var exists bool
130 err := s.db.View(ctx, func(txn *db.Txn) error {
131 var err error
132 exists, err = TxnStore{txn: txn, clock: s.clock}.Exists(sid, id)
133 return err
134 })
135 return exists, err
136}
137
138// TxnStore wraps a db transaction for task operations.
139type TxnStore struct {
140 txn *db.Txn
141 clock timeutil.Clock
142}
143
144// Create inserts a task into sid using params.
145func (s TxnStore) Create(sid string, params CreateParams) (Task, error) {
146 if s.txn == nil {
147 return Task{}, errors.New("task: transaction is nil")
148 }
149
150 title := strings.TrimSpace(params.Title)
151 if title == "" {
152 return Task{}, ErrEmptyTitle
153 }
154
155 status := params.Status
156 if status == "" {
157 status = StatusPending
158 }
159 if !status.Valid() {
160 return Task{}, ErrInvalidStatus
161 }
162
163 id := strings.TrimSpace(params.ID)
164 if id == "" {
165 id = GenerateID(sid, title, params.Description)
166 }
167
168 key := db.KeySessionTask(sid, id)
169 exists, err := s.txn.Exists(key)
170 if err != nil {
171 return Task{}, err
172 }
173 if exists {
174 return Task{}, ErrExists
175 }
176
177 now := timeutil.EnsureUTC(s.clock.Now())
178 task := Task{
179 ID: id,
180 Title: title,
181 Description: params.Description,
182 Status: status,
183 CreatedAt: now,
184 UpdatedAt: now,
185 CreatedSeq: params.CreatedSeq,
186 }
187
188 if err := s.txn.SetJSON(key, task); err != nil {
189 return Task{}, err
190 }
191 if err := addStatusIndex(s.txn, sid, status, id); err != nil {
192 return Task{}, err
193 }
194
195 return task, nil
196}
197
198// Get retrieves a task by ID from sid.
199func (s TxnStore) Get(sid, id string) (Task, error) {
200 if s.txn == nil {
201 return Task{}, errors.New("task: transaction is nil")
202 }
203 return loadTask(s.txn, sid, id)
204}
205
206// Update applies mutate to the task and persists changes.
207func (s TxnStore) Update(sid, id string, mutate Mutator) (Task, error) {
208 if s.txn == nil {
209 return Task{}, errors.New("task: transaction is nil")
210 }
211
212 current, err := loadTask(s.txn, sid, id)
213 if err != nil {
214 return Task{}, err
215 }
216
217 next := current
218 if mutate != nil {
219 if err := mutate(&next); err != nil {
220 return Task{}, err
221 }
222 }
223
224 next.ID = current.ID
225 next.CreatedAt = current.CreatedAt
226 next.CreatedSeq = current.CreatedSeq
227
228 next.Title = strings.TrimSpace(next.Title)
229 if next.Title == "" {
230 return Task{}, ErrEmptyTitle
231 }
232
233 if next.Status == "" {
234 next.Status = current.Status
235 }
236 if !next.Status.Valid() {
237 return Task{}, ErrInvalidStatus
238 }
239
240 next.UpdatedAt = timeutil.EnsureUTC(s.clock.Now())
241
242 key := db.KeySessionTask(sid, id)
243 if err := s.txn.SetJSON(key, next); err != nil {
244 return Task{}, err
245 }
246
247 if next.Status != current.Status {
248 if err := removeStatusIndex(s.txn, sid, current.Status, id); err != nil {
249 return Task{}, err
250 }
251 }
252
253 if err := addStatusIndex(s.txn, sid, next.Status, id); err != nil {
254 return Task{}, err
255 }
256
257 return next, nil
258}
259
260// UpdateStatus changes a task's status.
261func (s TxnStore) UpdateStatus(sid, id string, status Status) (Task, error) {
262 if !status.Valid() {
263 return Task{}, ErrInvalidStatus
264 }
265 return s.Update(sid, id, func(t *Task) error {
266 t.Status = status
267 return nil
268 })
269}
270
271// Delete removes a task by ID.
272func (s TxnStore) Delete(sid, id string) error {
273 if s.txn == nil {
274 return errors.New("task: transaction is nil")
275 }
276
277 task, err := loadTask(s.txn, sid, id)
278 if err != nil {
279 return err
280 }
281
282 if err := s.txn.Delete(db.KeySessionTask(sid, id)); err != nil {
283 return err
284 }
285
286 return removeStatusIndex(s.txn, sid, task.Status, id)
287}
288
289// List returns all tasks sorted by creation sequence.
290func (s TxnStore) List(sid string) ([]Task, error) {
291 if s.txn == nil {
292 return nil, errors.New("task: transaction is nil")
293 }
294
295 var tasks []Task
296 err := s.txn.Iterate(db.IterateOptions{
297 Prefix: db.PrefixSessionTasks(sid),
298 PrefetchValues: true,
299 }, func(item db.Item) error {
300 var entry Task
301 if err := item.ValueJSON(&entry); err != nil {
302 return err
303 }
304 tasks = append(tasks, entry)
305 return nil
306 })
307 if err != nil {
308 return nil, err
309 }
310
311 sortTasks(tasks)
312 return tasks, nil
313}
314
315// Exists reports whether a task with id exists.
316func (s TxnStore) Exists(sid, id string) (bool, error) {
317 if s.txn == nil {
318 return false, errors.New("task: transaction is nil")
319 }
320 return s.txn.Exists(db.KeySessionTask(sid, id))
321}
322
323// ListByStatus returns tasks filtered by status sorted by creation sequence.
324func (s TxnStore) ListByStatus(sid string, status Status) ([]Task, error) {
325 if s.txn == nil {
326 return nil, errors.New("task: transaction is nil")
327 }
328 if !status.Valid() {
329 return nil, ErrInvalidStatus
330 }
331
332 var ids []string
333 err := s.txn.Iterate(db.IterateOptions{
334 Prefix: db.PrefixSessionStatusIndex(sid, status.String()),
335 }, func(item db.Item) error {
336 ids = append(ids, lastKeySegment(item.KeyString()))
337 return nil
338 })
339 if err != nil {
340 return nil, err
341 }
342
343 tasks := make([]Task, 0, len(ids))
344 for _, id := range ids {
345 task, err := loadTask(s.txn, sid, id)
346 if err != nil {
347 return nil, err
348 }
349 tasks = append(tasks, task)
350 }
351
352 sortTasks(tasks)
353 return tasks, nil
354}
355
356func loadTask(txn *db.Txn, sid, id string) (Task, error) {
357 key := db.KeySessionTask(sid, id)
358 exists, err := txn.Exists(key)
359 if err != nil {
360 return Task{}, err
361 }
362 if !exists {
363 return Task{}, ErrNotFound
364 }
365
366 var task Task
367 if err := txn.GetJSON(key, &task); err != nil {
368 return Task{}, err
369 }
370
371 if task.ID == "" {
372 task.ID = id
373 }
374 return task, nil
375}
376
377func addStatusIndex(txn *db.Txn, sid string, status Status, id string) error {
378 key := db.KeySessionTaskStatusIndex(sid, status.String(), id)
379 return txn.Set(key, []byte{})
380}
381
382func removeStatusIndex(txn *db.Txn, sid string, status Status, id string) error {
383 key := db.KeySessionTaskStatusIndex(sid, status.String(), id)
384 exists, err := txn.Exists(key)
385 if err != nil {
386 return err
387 }
388 if !exists {
389 return nil
390 }
391 return txn.Delete(key)
392}
393
394func sortTasks(tasks []Task) {
395 sort.Slice(tasks, func(i, j int) bool {
396 if tasks[i].CreatedSeq != tasks[j].CreatedSeq {
397 return tasks[i].CreatedSeq < tasks[j].CreatedSeq
398 }
399 if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) {
400 return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
401 }
402 return tasks[i].ID < tasks[j].ID
403 })
404}
405
406func lastKeySegment(key string) string {
407 idx := strings.LastIndex(key, "/")
408 if idx == -1 || idx == len(key)-1 {
409 return key
410 }
411 return key[idx+1:]
412}
413
414// Key returns the storage key for a task ID within sid.
415func Key(sid, id string) []byte {
416 return db.KeySessionTask(sid, id)
417}
418
419// Prefix returns the prefix for all task documents in sid.
420func Prefix(sid string) []byte {
421 return db.PrefixSessionTasks(sid)
422}
423
424// StatusPrefix returns the prefix for tasks matching status within sid.
425func StatusPrefix(sid string, status Status) []byte {
426 return db.PrefixSessionStatusIndex(sid, status.String())
427}