all repos

onasty @ d2c87a81b9c7e589cbe6848e117470645d68e192

a one-time notes service

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
test(e2e): test all behaviours of 'read note' route (#198)..., 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 burnBeforeExpiration.
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
		BurnBeforeExpiration: inp.BurnBeforeExpiration,
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
		BurnBeforeExpiration: note.BurnBeforeExpiration,
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
	// TODO: fix naming
147
	if note.ShouldBeBurnt() {
148
		return respNote, nil
149
	}
150
151
	return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now())
152
}
153
154
func (n *NoteSrv) GetNoteMetadataBySlug(
155
	ctx context.Context,
156
	slug dtos.NoteSlug,
157
) (dtos.NoteMetadata, error) {
158
	note, err := n.noterepo.GetMetadataBySlug(ctx, slug)
159
	return note, err
160
}
161
162
func (n *NoteSrv) GetAllByAuthorID(
163
	ctx context.Context,
164
	authorID uuid.UUID,
165
) ([]dtos.NoteDetailed, error) {
166
	notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID)
167
	if err != nil {
168
		return nil, err
169
	}
170
171
	return n.mapNoteModelToDto(notes), nil
172
}
173
174
func (n *NoteSrv) GetAllReadByAuthorID(
175
	ctx context.Context,
176
	authorID uuid.UUID,
177
) ([]dtos.NoteDetailed, error) {
178
	notes, err := n.noterepo.GetAllReadByAuthorID(ctx, authorID)
179
	if err != nil {
180
		return nil, err
181
	}
182
183
	return n.mapNoteModelToDto(notes), nil
184
}
185
186
func (n *NoteSrv) GetAllUnreadByAuthorID(
187
	ctx context.Context,
188
	authorID uuid.UUID,
189
) ([]dtos.NoteDetailed, error) {
190
	notes, err := n.noterepo.GetAllUnreadByAuthorID(ctx, authorID)
191
	if err != nil {
192
		return nil, err
193
	}
194
195
	return n.mapNoteModelToDto(notes), nil
196
}
197
198
func (n *NoteSrv) UpdateExpirationTimeSettings(
199
	ctx context.Context,
200
	patchData dtos.PatchNote,
201
	slug dtos.NoteSlug,
202
	userID uuid.UUID,
203
) error {
204
	return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID)
205
}
206
207
func (n *NoteSrv) UpdatePassword(
208
	ctx context.Context,
209
	slug dtos.NoteSlug,
210
	passwd string,
211
	userID uuid.UUID,
212
) error {
213
	if len(passwd) == 0 {
214
		return ErrNotePasswordNotProvided
215
	}
216
217
	hashedPassword, err := n.hasher.Hash(passwd)
218
	if err != nil {
219
		return err
220
	}
221
222
	return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword)
223
}
224
225
func (n *NoteSrv) DeleteBySlug(
226
	ctx context.Context,
227
	slug dtos.NoteSlug,
228
	authorID uuid.UUID,
229
) error {
230
	return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID)
231
}
232
233
func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {
234
	if note, err := n.cache.GetNote(ctx, inp.Slug); err == nil {
235
		return note, nil
236
	}
237
238
	note, err := n.getNoteFromDBasedOnInput(ctx, inp)
239
	if err != nil {
240
		return models.Note{}, err
241
	}
242
243
	if note.IsRead() {
244
		if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil {
245
			slog.ErrorContext(ctx, "notecache", "err", err)
246
		}
247
	}
248
249
	return note, err
250
}
251
252
func (n *NoteSrv) getNoteFromDBasedOnInput(
253
	ctx context.Context,
254
	inp GetNoteBySlugInput,
255
) (models.Note, error) {
256
	if inp.HasPassword() {
257
		hashedPassword, err := n.hasher.Hash(inp.Password)
258
		if err != nil {
259
			return models.Note{}, err
260
		}
261
262
		return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword)
263
	}
264
	return n.noterepo.GetBySlug(ctx, inp.Slug)
265
}
266
267
func (n *NoteSrv) mapNoteModelToDto(notes []models.Note) []dtos.NoteDetailed {
268
	var resNotes []dtos.NoteDetailed
269
	for _, note := range notes {
270
		resNotes = append(resNotes, dtos.NoteDetailed{
271
			Content:              note.Content,
272
			Slug:                 note.Slug,
273
			BurnBeforeExpiration: note.BurnBeforeExpiration,
274
			HasPassword:          note.Password != "",
275
			CreatedAt:            note.CreatedAt,
276
			ExpiresAt:            note.ExpiresAt,
277
			ReadAt:               note.ReadAt,
278
		})
279
	}
280
281
	return resNotes
282
}