all repos

onasty @ ac9bab3

a one-time notes service

onasty/internal/service/notesrv/notesrv.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
refactor!: rename "burn before expiration" to "keep before expiration" (#199)..., 9 months ago
1
package notesrv
2
3
import (
4
	"context"
5
	"errors"
6
	"log/slog"
7
	"time"
8
9
	"github.com/gofrs/uuid/v5"
10
	"github.com/olexsmir/onasty/internal/dtos"
11
	"github.com/olexsmir/onasty/internal/hasher"
12
	"github.com/olexsmir/onasty/internal/models"
13
	"github.com/olexsmir/onasty/internal/store/psql/noterepo"
14
	"github.com/olexsmir/onasty/internal/store/rdb/notecache"
15
)
16
17
var ErrNotePasswordNotProvided = errors.New("note: password was not provided")
18
19
type NoteServicer interface {
20
	// Create creates note
21
	// if slug is empty it will be generated, otherwise used as is
22
	// if userID is empty it means user isn't authorized so it will be used
23
	Create(ctx context.Context, note dtos.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error)
24
25
	// GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed.
26
	// If note is not found returns [models.ErrNoteNotFound].
27
	GetBySlugAndRemoveIfNeeded(
28
		ctx context.Context,
29
		input GetNoteBySlugInput,
30
	) (dtos.GetNote, error)
31
32
	// GetNoteMetadataBySlug returns note metadata by slug.
33
	// If note is not found returns [models.ErrNoteNotFound].
34
	GetNoteMetadataBySlug(ctx context.Context, slug dtos.NoteSlug) (dtos.NoteMetadata, error)
35
36
	// GetAllByAuthorID returns all notes by author id.
37
	GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error)
38
39
	// GetAllReadByAuthorID returns all notes that ARE READ and authored by author id.
40
	GetAllReadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error)
41
42
	// GetAllUnreadByAuthorID returns all notes that ARE UNREAD and authored by author id.
43
	GetAllUnreadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error)
44
45
	// UpdateExpirationTimeSettings updates expiresAt and keepBeforeExpiration.
46
	// If notes is not found returns [models.ErrNoteNotFound].
47
	UpdateExpirationTimeSettings(
48
		ctx context.Context,
49
		patchData dtos.PatchNote,
50
		slug dtos.NoteSlug,
51
		userID uuid.UUID,
52
	) error
53
54
	// UpdatePassword sets or updates notes password.
55
	// If notes is not found returns [models.ErrNoteNotFound].
56
	UpdatePassword(ctx context.Context, slug dtos.NoteSlug, passwd string, userID uuid.UUID) error
57
58
	// DeleteBySlug deletes note by slug
59
	DeleteBySlug(ctx context.Context, slug dtos.NoteSlug, userID uuid.UUID) error
60
}
61
62
var _ NoteServicer = (*NoteSrv)(nil)
63
64
type NoteSrv struct {
65
	noterepo noterepo.NoteStorer
66
	hasher   hasher.Hasher
67
	cache    notecache.NoteCacher
68
}
69
70
func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher, cache notecache.NoteCacher) *NoteSrv {
71
	return &NoteSrv{
72
		noterepo: noterepo,
73
		hasher:   hasher,
74
		cache:    cache,
75
	}
76
}
77
78
func (n *NoteSrv) Create(
79
	ctx context.Context,
80
	inp dtos.CreateNote,
81
	userID uuid.UUID,
82
) (dtos.NoteSlug, error) {
83
	slog.DebugContext(ctx, "creating", "inp", inp)
84
85
	if inp.Slug == "" {
86
		inp.Slug = uuid.Must(uuid.NewV4()).String()
87
	}
88
89
	if inp.Password != "" {
90
		hashedPassword, err := n.hasher.Hash(inp.Password)
91
		if err != nil {
92
			return "", err
93
		}
94
		inp.Password = hashedPassword
95
	}
96
97
	//nolint:exhaustruct // ID - cannot be predicted, and ReadAt will be set on read
98
	note := models.Note{
99
		Content:              inp.Content,
100
		Slug:                 inp.Slug,
101
		Password:             inp.Password,
102
		KeepBeforeExpiration: inp.KeepBeforeExpiration,
103
		CreatedAt:            inp.CreatedAt,
104
		ExpiresAt:            inp.ExpiresAt,
105
	}
106
	if err := note.Validate(); err != nil {
107
		return "", err
108
	}
109
110
	if err := n.noterepo.Create(ctx, note); err != nil {
111
		return "", err
112
	}
113
114
	if !userID.IsNil() {
115
		if err := n.noterepo.SetAuthorIDBySlug(ctx, inp.Slug, userID); err != nil {
116
			return "", err
117
		}
118
	}
119
120
	return inp.Slug, nil
121
}
122
123
func (n *NoteSrv) GetBySlugAndRemoveIfNeeded(
124
	ctx context.Context,
125
	inp GetNoteBySlugInput,
126
) (dtos.GetNote, error) {
127
	note, err := n.getNote(ctx, inp)
128
	if err != nil {
129
		return dtos.GetNote{}, err
130
	}
131
132
	if note.IsExpired() {
133
		return dtos.GetNote{}, models.ErrNoteExpired
134
	}
135
136
	respNote := dtos.GetNote{
137
		Content:              note.Content,
138
		KeepBeforeExpiration: note.KeepBeforeExpiration,
139
		ReadAt:               note.ReadAt,
140
		CreatedAt:            note.CreatedAt,
141
		ExpiresAt:            note.ExpiresAt,
142
	}
143
144
	// since not every note should be burn before expiration
145
	// we return early if it's not
146
	if note.ShouldPreserveOnRead() {
147
		return respNote, nil
148
	}
149
150
	return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now())
151
}
152
153
func (n *NoteSrv) GetNoteMetadataBySlug(
154
	ctx context.Context,
155
	slug dtos.NoteSlug,
156
) (dtos.NoteMetadata, error) {
157
	note, err := n.noterepo.GetMetadataBySlug(ctx, slug)
158
	return note, err
159
}
160
161
func (n *NoteSrv) GetAllByAuthorID(
162
	ctx context.Context,
163
	authorID uuid.UUID,
164
) ([]dtos.NoteDetailed, error) {
165
	notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID)
166
	if err != nil {
167
		return nil, err
168
	}
169
170
	return n.mapNoteModelToDto(notes), nil
171
}
172
173
func (n *NoteSrv) GetAllReadByAuthorID(
174
	ctx context.Context,
175
	authorID uuid.UUID,
176
) ([]dtos.NoteDetailed, error) {
177
	notes, err := n.noterepo.GetAllReadByAuthorID(ctx, authorID)
178
	if err != nil {
179
		return nil, err
180
	}
181
182
	return n.mapNoteModelToDto(notes), nil
183
}
184
185
func (n *NoteSrv) GetAllUnreadByAuthorID(
186
	ctx context.Context,
187
	authorID uuid.UUID,
188
) ([]dtos.NoteDetailed, error) {
189
	notes, err := n.noterepo.GetAllUnreadByAuthorID(ctx, authorID)
190
	if err != nil {
191
		return nil, err
192
	}
193
194
	return n.mapNoteModelToDto(notes), nil
195
}
196
197
func (n *NoteSrv) UpdateExpirationTimeSettings(
198
	ctx context.Context,
199
	patchData dtos.PatchNote,
200
	slug dtos.NoteSlug,
201
	userID uuid.UUID,
202
) error {
203
	return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID)
204
}
205
206
func (n *NoteSrv) UpdatePassword(
207
	ctx context.Context,
208
	slug dtos.NoteSlug,
209
	passwd string,
210
	userID uuid.UUID,
211
) error {
212
	if len(passwd) == 0 {
213
		return ErrNotePasswordNotProvided
214
	}
215
216
	hashedPassword, err := n.hasher.Hash(passwd)
217
	if err != nil {
218
		return err
219
	}
220
221
	return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword)
222
}
223
224
func (n *NoteSrv) DeleteBySlug(
225
	ctx context.Context,
226
	slug dtos.NoteSlug,
227
	authorID uuid.UUID,
228
) error {
229
	return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID)
230
}
231
232
func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {
233
	if note, err := n.cache.GetNote(ctx, inp.Slug); err == nil {
234
		return note, nil
235
	}
236
237
	note, err := n.getNoteFromDBasedOnInput(ctx, inp)
238
	if err != nil {
239
		return models.Note{}, err
240
	}
241
242
	if note.IsRead() {
243
		if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil {
244
			slog.ErrorContext(ctx, "notecache", "err", err)
245
		}
246
	}
247
248
	return note, err
249
}
250
251
func (n *NoteSrv) getNoteFromDBasedOnInput(
252
	ctx context.Context,
253
	inp GetNoteBySlugInput,
254
) (models.Note, error) {
255
	if inp.HasPassword() {
256
		hashedPassword, err := n.hasher.Hash(inp.Password)
257
		if err != nil {
258
			return models.Note{}, err
259
		}
260
261
		return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword)
262
	}
263
	return n.noterepo.GetBySlug(ctx, inp.Slug)
264
}
265
266
func (n *NoteSrv) mapNoteModelToDto(notes []models.Note) []dtos.NoteDetailed {
267
	var resNotes []dtos.NoteDetailed
268
	for _, note := range notes {
269
		resNotes = append(resNotes, dtos.NoteDetailed{
270
			Content:              note.Content,
271
			Slug:                 note.Slug,
272
			KeepBeforeExpiration: note.KeepBeforeExpiration,
273
			HasPassword:          note.Password != "",
274
			CreatedAt:            note.CreatedAt,
275
			ExpiresAt:            note.ExpiresAt,
276
			ReadAt:               note.ReadAt,
277
		})
278
	}
279
280
	return resNotes
281
}