all repos

onasty @ efd970430597e242aa15c24f868fc0e7103b45ac

a one-time notes service

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

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