all repos

onasty @ 86dba8ea0dc14c9a08dbb0baa9b0441168638328

a one-time notes service

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

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