all repos

onasty @ 8e14c68b5b24937f042c7ae70f18c795b9d1aae5

a one-time notes service

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

Smirnov Olexandr Smirnov Olexandr
ss2316544@gmail.com
fix: get note metadata (#150)..., 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
	// 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, n.read_at
127
	from notes n
128
	where slug = $1
129
	`
130
131
	var readAt time.Time
132
	var metadata dtos.NoteMetadata
133
	err := s.db.QueryRow(ctx, query, slug).Scan(&metadata.CreatedAt, &metadata.HasPassword, &readAt)
134
	if errors.Is(err, pgx.ErrNoRows) {
135
		return dtos.NoteMetadata{}, models.ErrNoteNotFound
136
	}
137
138
	if !readAt.IsZero() {
139
		return dtos.NoteMetadata{}, models.ErrNoteNotFound
140
	}
141
142
	return metadata, err
143
}
144
145
func (s *NoteRepo) GetAllByAuthorID(
146
	ctx context.Context,
147
	authorID uuid.UUID,
148
) ([]models.Note, error) {
149
	query := `--sql
150
	select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at
151
	from notes n
152
	right join notes_authors na on n.id = na.note_id
153
	where na.user_id = $1`
154
155
	rows, err := s.db.Query(ctx, query, authorID.String())
156
	if err != nil {
157
		return nil, err
158
	}
159
160
	defer rows.Close()
161
162
	var notes []models.Note
163
	for rows.Next() {
164
		var note models.Note
165
		if err := rows.Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password,
166
			&note.ReadAt, &note.CreatedAt, &note.ExpiresAt); err != nil {
167
			return nil, err
168
		}
169
		notes = append(notes, note)
170
	}
171
172
	return notes, rows.Err()
173
}
174
175
func (s *NoteRepo) GetBySlugAndPassword(
176
	ctx context.Context,
177
	slug dtos.NoteSlug,
178
	passwd string,
179
) (models.Note, error) {
180
	query, args, err := pgq.
181
		Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at").
182
		From("notes").
183
		Where(pgq.Eq{
184
			"slug":     slug,
185
			"password": passwd,
186
		}).
187
		SQL()
188
	if err != nil {
189
		return models.Note{}, err
190
	}
191
192
	var note models.Note
193
	err = s.db.QueryRow(ctx, query, args...).
194
		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)
195
196
	if errors.Is(err, pgx.ErrNoRows) {
197
		return models.Note{}, models.ErrNoteNotFound
198
	}
199
200
	return note, err
201
}
202
203
func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug(
204
	ctx context.Context,
205
	slug dtos.NoteSlug,
206
	patch dtos.PatchNote,
207
	authorID uuid.UUID,
208
) error {
209
	query := `--sql
210
update notes n
211
set burn_before_expiration = COALESCE($1, n.burn_before_expiration),
212
    expires_at = COALESCE($2, n.expires_at)
213
from notes_authors na
214
where n.slug = $3
215
  and na.user_id = $4
216
  and na.note_id = n.id`
217
218
	ct, err := s.db.Exec(ctx, query,
219
		patch.BurnBeforeExpiration, patch.ExpiresAt,
220
		slug, authorID.String())
221
	if err != nil {
222
		return err
223
	}
224
225
	if ct.RowsAffected() == 0 {
226
		return models.ErrNoteNotFound
227
	}
228
229
	return nil
230
}
231
232
func (s *NoteRepo) RemoveBySlug(
233
	ctx context.Context,
234
	slug dtos.NoteSlug,
235
	readAt time.Time,
236
) error {
237
	query, args, err := pgq.
238
		Update("notes").
239
		Set("content", "").
240
		Set("read_at", readAt).
241
		Where(pgq.Eq{
242
			"slug":    slug,
243
			"read_at": time.Time{}, // check if time is null
244
		}).
245
		SQL()
246
	if err != nil {
247
		return err
248
	}
249
250
	_, err = s.db.Exec(ctx, query, args...)
251
	if errors.Is(err, pgx.ErrNoRows) {
252
		return models.ErrNoteNotFound
253
	}
254
255
	return err
256
}
257
258
func (s *NoteRepo) DeleteNoteBySlug(
259
	ctx context.Context,
260
	slug dtos.NoteSlug,
261
	authorID uuid.UUID,
262
) error {
263
	query := `--sql
264
delete from notes n
265
using notes_authors na
266
where n.slug = $1
267
  and na.user_id = $2`
268
269
	ct, err := s.db.Exec(ctx, query, slug, authorID.String())
270
	if err != nil {
271
		return err
272
	}
273
274
	if ct.RowsAffected() == 0 {
275
		return models.ErrNoteNotFound
276
	}
277
278
	return nil
279
}
280
281
func (s *NoteRepo) SetAuthorIDBySlug(
282
	ctx context.Context,
283
	slug dtos.NoteSlug,
284
	authorID uuid.UUID,
285
) error {
286
	tx, err := s.db.Begin(ctx)
287
	if err != nil {
288
		return err
289
	}
290
	defer tx.Rollback(ctx) //nolint:errcheck
291
292
	var noteID uuid.UUID
293
	err = tx.QueryRow(ctx, "select id from notes where slug = $1", slug).Scan(&noteID)
294
	if err != nil {
295
		if errors.Is(err, pgx.ErrNoRows) {
296
			return models.ErrNoteNotFound
297
		}
298
		return err
299
	}
300
301
	_, err = tx.Exec(
302
		ctx,
303
		"insert into notes_authors (note_id, user_id) values ($1, $2)",
304
		noteID, authorID,
305
	)
306
	if err != nil {
307
		return err
308
	}
309
310
	return tx.Commit(ctx)
311
}
312
313
func (s *NoteRepo) UpdatePasswordBySlug(
314
	ctx context.Context,
315
	slug dtos.NoteSlug,
316
	authorID uuid.UUID,
317
	passwd string,
318
) error {
319
	query := `--sql
320
update notes n
321
set password = $1
322
from notes_authors na
323
where n.slug = $2
324
  and na.user_id = $3
325
  and na.note_id = n.id`
326
327
	ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String())
328
	if err != nil {
329
		return err
330
	}
331
332
	if ct.RowsAffected() == 0 {
333
		return models.ErrNoteNotFound
334
	}
335
336
	return nil
337
}