1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package goal
  6
  7import (
  8	"context"
  9	"errors"
 10	"strings"
 11	"time"
 12
 13	"git.secluded.site/np/internal/db"
 14	"git.secluded.site/np/internal/timeutil"
 15)
 16
 17// ErrNotFound is returned when no goal exists for the requested session.
 18var ErrNotFound = errors.New("goal: not found")
 19
 20// ErrEmptyTitle indicates that a goal title was not provided.
 21var ErrEmptyTitle = errors.New("goal: title is required")
 22
 23// Document captures the session goal persisted in the database.
 24type Document struct {
 25	Title       string    `json:"title"`
 26	Description string    `json:"description"`
 27	UpdatedAt   time.Time `json:"updated_at"`
 28}
 29
 30// Store provides high-level helpers for working with session goals.
 31type Store struct {
 32	db    *db.Database
 33	clock timeutil.Clock
 34}
 35
 36// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
 37func NewStore(database *db.Database, clock timeutil.Clock) *Store {
 38	if clock == nil {
 39		clock = timeutil.UTCClock{}
 40	}
 41	return &Store{
 42		db:    database,
 43		clock: clock,
 44	}
 45}
 46
 47// WithTxn returns a transactional view of the store for use within db.Update.
 48func (s *Store) WithTxn(txn *db.Txn) TxnStore {
 49	return TxnStore{
 50		txn:   txn,
 51		clock: s.clock,
 52	}
 53}
 54
 55// Get loads the goal for sid using a read-only transaction.
 56func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
 57	var doc Document
 58	err := s.db.View(ctx, func(txn *db.Txn) error {
 59		var err error
 60		doc, err = load(txn, sid)
 61		return err
 62	})
 63	return doc, err
 64}
 65
 66// Exists reports whether a goal has been set for sid.
 67func (s *Store) Exists(ctx context.Context, sid string) (bool, error) {
 68	var exists bool
 69	err := s.db.View(ctx, func(txn *db.Txn) error {
 70		var err error
 71		exists, err = txn.Exists(db.KeySessionGoal(sid))
 72		return err
 73	})
 74	return exists, err
 75}
 76
 77// Set stores a goal for sid using a write transaction, returning the persisted
 78// document.
 79func (s *Store) Set(ctx context.Context, sid, title, description string) (Document, error) {
 80	var doc Document
 81	err := s.db.Update(ctx, func(txn *db.Txn) error {
 82		var err error
 83		doc, err = save(txn, s.clock, sid, title, description)
 84		return err
 85	})
 86	return doc, err
 87}
 88
 89// TxnStore offers goal helpers scoped to an existing transaction.
 90type TxnStore struct {
 91	txn   *db.Txn
 92	clock timeutil.Clock
 93}
 94
 95// Get loads the goal for sid within the wrapped transaction.
 96func (s TxnStore) Get(sid string) (Document, error) {
 97	return load(s.txn, sid)
 98}
 99
100// Exists reports whether a goal has been set for sid within the wrapped txn.
101func (s TxnStore) Exists(sid string) (bool, error) {
102	if s.txn == nil {
103		return false, errors.New("goal: transaction is nil")
104	}
105	return s.txn.Exists(db.KeySessionGoal(sid))
106}
107
108// Key returns the Badger key storing the goal document. This is useful for
109// observers that subscribe to goal updates.
110func Key(sid string) []byte {
111	return db.KeySessionGoal(sid)
112}
113
114// Set writes the goal for sid inside the wrapped transaction.
115func (s TxnStore) Set(sid, title, description string) (Document, error) {
116	return save(s.txn, s.clock, sid, title, description)
117}
118
119func load(txn *db.Txn, sid string) (Document, error) {
120	if txn == nil {
121		return Document{}, errors.New("goal: transaction is nil")
122	}
123	key := db.KeySessionGoal(sid)
124	exists, err := txn.Exists(key)
125	if err != nil {
126		return Document{}, err
127	}
128	if !exists {
129		return Document{}, ErrNotFound
130	}
131
132	var doc Document
133	if err := txn.GetJSON(key, &doc); err != nil {
134		return Document{}, err
135	}
136	return doc, nil
137}
138
139func save(txn *db.Txn, clock timeutil.Clock, sid, title, description string) (Document, error) {
140	if txn == nil {
141		return Document{}, errors.New("goal: transaction is nil")
142	}
143	if clock == nil {
144		clock = timeutil.UTCClock{}
145	}
146
147	cleanTitle := strings.TrimSpace(title)
148	if cleanTitle == "" {
149		return Document{}, ErrEmptyTitle
150	}
151
152	doc := Document{
153		Title:       cleanTitle,
154		Description: description,
155		UpdatedAt:   timeutil.EnsureUTC(clock.Now()),
156	}
157
158	if err := txn.SetJSON(db.KeySessionGoal(sid), doc); err != nil {
159		return Document{}, err
160	}
161	return doc, nil
162}