all repos

onasty @ d631546

a one-time notes service

onasty/internal/store/psql/noterepo/noterepo.go (view raw)

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
feat(api): return more info about user on /me route (#162)..., 11 months ago
1
package noterepo
2
3
import (
4
	"context"
5
	"errors"
6
	"time"
7
8
	"github.com/gofrs/uuid/v5"
9
	"github.com/henvic/pgq"
10
	"github.com/jackc/pgx/v5"
11
	"github.com/olexsmir/onasty/internal/dtos"
12
	"github.com/olexsmir/onasty/internal/models"
13
	"github.com/olexsmir/onasty/internal/store/psqlutil"
14
)
15
16
type NoteStorer interface {
17
	// Create creates a note.
18
	Create(ctx context.Context, note models.Note) error
19
20
	// GetBySlug gets a note by slug.
21
	// Returns [models.ErrNoteNotFound] if note is not found.
22
	GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error)
23
24
	// GetMetadataBySlug gets note's metadata by its slug.
25
	// Returns [models.ErrNoteNotFound] if note is not found OR read.
26
	GetMetadataBySlug(ctx context.Context, slug dtos.NoteSlug) (dtos.NoteMetadata, error)
27
28
	// GetAllByAuthorID returns all notes with specified author.
29
	GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error)
30
31
	// GetCountOfNotesByAuthorID returns count of notes created by specified author.
32
	GetCountOfNotesByAuthorID(ctx context.Context, authorID uuid.UUID) (int64, error)
33
34
	// GetBySlugAndPassword gets a note by slug and password.
35
	// the "password" should be hashed.
36
	//
37
	// Returns [models.ErrNoteNotFound] if note is not found.
38
	GetBySlugAndPassword(
39
		ctx context.Context,
40
		slug dtos.NoteSlug,
41
		password string,
42
	) (models.Note, error)
43
44
	// UpdateExpirationTimeSettingsBySlug patches note by updating expiresAt and burnBeforeExpiration if one is passwd
45
	// Returns [models.ErrNoteNotFound] if note is not found.
46
	UpdateExpirationTimeSettingsBySlug(
47
		ctx context.Context,
48
		slug dtos.NoteSlug,
49
		patch dtos.PatchNote,
50
		authorID uuid.UUID,
51
	) error
52
53
	// RemoveBySlug marks note as read, deletes it's content, and keeps meta data
54
	// Returns [models.ErrNoteNotFound] if note is not found.
55
	RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error
56
57
	// DeleteNoteBySlug deletes(unlike [RemoveBySlug]) note by slug.
58
	// Returns [models.ErrNoteNotFound] if note is not found.
59
	DeleteNoteBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error
60
61
	// SetAuthorIDBySlug assigns author to note by slug.
62
	// Returns [models.ErrNoteNotFound] if note is not found.
63
	SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error
64
65
	// UpdatePasswordBySlug updates or sets password on a note.
66
	UpdatePasswordBySlug(
67
		ctx context.Context,
68
		slug dtos.NoteSlug,
69
		authorID uuid.UUID,
70
		passwd string,
71
	) error
72
}
73
74
var _ NoteStorer = (*NoteRepo)(nil)
75
76
type NoteRepo struct {
77
	db *psqlutil.DB
78
}
79
80
func New(db *psqlutil.DB) *NoteRepo {
81
	return &NoteRepo{db}
82
}
83
84
func (s *NoteRepo) Create(ctx context.Context, inp models.Note) error {
85
	query, args, err := pgq.
86
		Insert("notes").
87
		Columns("content", "slug", "password", "burn_before_expiration", "created_at", "expires_at").
88
		Values(inp.Content, inp.Slug, inp.Password, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt).
89
		SQL()
90
	if err != nil {
91
		return err
92
	}
93
94
	_, err = s.db.Exec(ctx, query, args...)
95
	if psqlutil.IsDuplicateErr(err, "notes_slug_key") {
96
		return models.ErrNoteSlugIsAlreadyInUse
97
	}
98
99
	return err
100
}
101
102
func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) {
103
	query, args, err := pgq.
104
		Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at").
105
		From("notes").
106
		Where("(password is null or password = '')").
107
		Where(pgq.Eq{"slug": slug}).
108
		SQL()
109
	if err != nil {
110
		return models.Note{}, err
111
	}
112
113
	var note models.Note
114
	err = s.db.QueryRow(ctx, query, args...).
115
		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)
116
117
	if errors.Is(err, pgx.ErrNoRows) {
118
		return models.Note{}, models.ErrNoteNotFound
119
	}
120
121
	return note, err
122
}
123
124
func (s *NoteRepo) GetMetadataBySlug(
125
	ctx context.Context,
126
	slug dtos.NoteSlug,
127
) (dtos.NoteMetadata, error) {
128
	query := `--sql
129
	select n.created_at, (n.password is not null and n.password <> '') has_password, n.read_at
130
	from notes n
131
	where slug = $1
132
	`
133
134
	var readAt time.Time
135
	var metadata dtos.NoteMetadata
136
	err := s.db.QueryRow(ctx, query, slug).Scan(&metadata.CreatedAt, &metadata.HasPassword, &readAt)
137
	if errors.Is(err, pgx.ErrNoRows) {
138
		return dtos.NoteMetadata{}, models.ErrNoteNotFound
139
	}
140
141
	if !readAt.IsZero() {
142
		return dtos.NoteMetadata{}, models.ErrNoteNotFound
143
	}
144
145
	return metadata, err
146
}
147
148
func (s *NoteRepo) GetAllByAuthorID(
149
	ctx context.Context,
150
	authorID uuid.UUID,
151
) ([]models.Note, error) {
152
	query := `--sql
153
	select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at
154
	from notes n
155
	right join notes_authors na on n.id = na.note_id
156
	where na.user_id = $1`
157
158
	rows, err := s.db.Query(ctx, query, authorID.String())
159
	if err != nil {
160
		return nil, err
161
	}
162
163
	defer rows.Close()
164
165
	var notes []models.Note
166
	for rows.Next() {
167
		var note models.Note
168
		if err := rows.Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password,
169
			&note.ReadAt, &note.CreatedAt, &note.ExpiresAt); err != nil {
170
			return nil, err
171
		}
172
		notes = append(notes, note)
173
	}
174
175
	return notes, rows.Err()
176
}
177
178
func (s *NoteRepo) GetCountOfNotesByAuthorID(
179
	ctx context.Context,
180
	authorID uuid.UUID,
181
) (int64, error) {
182
	var count int64
183
	err := s.db.QueryRow(
184
		ctx,
185
		`select count(*) from notes_authors where user_id = $1`,
186
		authorID.String(),
187
	).Scan(&count)
188
189
	return count, err
190
}
191
192
func (s *NoteRepo) GetBySlugAndPassword(
193
	ctx context.Context,
194
	slug dtos.NoteSlug,
195
	passwd string,
196
) (models.Note, error) {
197
	query, args, err := pgq.
198
		Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at").
199
		From("notes").
200
		Where(pgq.Eq{
201
			"slug":     slug,
202
			"password": passwd,
203
		}).
204
		SQL()
205
	if err != nil {
206
		return models.Note{}, err
207
	}
208
209
	var note models.Note
210
	err = s.db.QueryRow(ctx, query, args...).
211
		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)
212
213
	if errors.Is(err, pgx.ErrNoRows) {
214
		return models.Note{}, models.ErrNoteNotFound
215
	}
216
217
	return note, err
218
}
219
220
func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug(
221
	ctx context.Context,
222
	slug dtos.NoteSlug,
223
	patch dtos.PatchNote,
224
	authorID uuid.UUID,
225
) error {
226
	query := `--sql
227
update notes n
228
set burn_before_expiration = COALESCE($1, n.burn_before_expiration),
229
    expires_at = COALESCE($2, n.expires_at)
230
from notes_authors na
231
where n.slug = $3
232
  and na.user_id = $4
233
  and na.note_id = n.id`
234
235
	ct, err := s.db.Exec(ctx, query,
236
		patch.BurnBeforeExpiration, patch.ExpiresAt,
237
		slug, authorID.String())
238
	if err != nil {
239
		return err
240
	}
241
242
	if ct.RowsAffected() == 0 {
243
		return models.ErrNoteNotFound
244
	}
245
246
	return nil
247
}
248
249
func (s *NoteRepo) RemoveBySlug(
250
	ctx context.Context,
251
	slug dtos.NoteSlug,
252
	readAt time.Time,
253
) error {
254
	query, args, err := pgq.
255
		Update("notes").
256
		Set("content", "").
257
		Set("read_at", readAt).
258
		Where(pgq.Eq{
259
			"slug":    slug,
260
			"read_at": time.Time{}, // check if time is null
261
		}).
262
		SQL()
263
	if err != nil {
264
		return err
265
	}
266
267
	_, err = s.db.Exec(ctx, query, args...)
268
	if errors.Is(err, pgx.ErrNoRows) {
269
		return models.ErrNoteNotFound
270
	}
271
272
	return err
273
}
274
275
func (s *NoteRepo) DeleteNoteBySlug(
276
	ctx context.Context,
277
	slug dtos.NoteSlug,
278
	authorID uuid.UUID,
279
) error {
280
	query := `--sql
281
delete from notes n
282
using notes_authors na
283
where n.slug = $1
284
  and na.user_id = $2`
285
286
	ct, err := s.db.Exec(ctx, query, slug, authorID.String())
287
	if err != nil {
288
		return err
289
	}
290
291
	if ct.RowsAffected() == 0 {
292
		return models.ErrNoteNotFound
293
	}
294
295
	return nil
296
}
297
298
func (s *NoteRepo) SetAuthorIDBySlug(
299
	ctx context.Context,
300
	slug dtos.NoteSlug,
301
	authorID uuid.UUID,
302
) error {
303
	tx, err := s.db.Begin(ctx)
304
	if err != nil {
305
		return err
306
	}
307
	defer tx.Rollback(ctx) //nolint:errcheck
308
309
	var noteID uuid.UUID
310
	err = tx.QueryRow(ctx, "select id from notes where slug = $1", slug).Scan(&noteID)
311
	if err != nil {
312
		if errors.Is(err, pgx.ErrNoRows) {
313
			return models.ErrNoteNotFound
314
		}
315
		return err
316
	}
317
318
	_, err = tx.Exec(
319
		ctx,
320
		"insert into notes_authors (note_id, user_id) values ($1, $2)",
321
		noteID, authorID,
322
	)
323
	if err != nil {
324
		return err
325
	}
326
327
	return tx.Commit(ctx)
328
}
329
330
func (s *NoteRepo) UpdatePasswordBySlug(
331
	ctx context.Context,
332
	slug dtos.NoteSlug,
333
	authorID uuid.UUID,
334
	passwd string,
335
) error {
336
	query := `--sql
337
update notes n
338
set password = $1
339
from notes_authors na
340
where n.slug = $2
341
  and na.user_id = $3
342
  and na.note_id = n.id`
343
344
	ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String())
345
	if err != nil {
346
		return err
347
	}
348
349
	if ct.RowsAffected() == 0 {
350
		return models.ErrNoteNotFound
351
	}
352
353
	return nil
354
}