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}