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}