all repos

onasty @ 7e5389d

a one-time notes service
23 files changed, 574 insertions(+), 54 deletions(-)
feat: notes manipulations for the note authors (#117)

* feat(api): implement get all my notes

* fix: add unique constraint so the same note cannot be assigned to the
user twice

* fixup! fix: add unique constraint so the same note cannot be assigned to the user twice

* fix(api): do not return internal error if tokens are expired

* fixup! feat(api): implement get all my notes

* feat: implement delete note

* feat: implement patching of the notes

* refactor(models): rename file, keep everything singular

* refactor(api): remove unnecessary logic

* test(e2e): delete note

* chore: add todo comments

* test(e2e): test patch note

* refactor(api): remove delete suffix

* feat(api): set password to a note

* docs(middleware): add comments

* fixup! feat(api): set password to a note

* fixup! feat(api): set password to a note

* test(e2e): test set note

* test(e2e): add cases for note not found

* fixup! feat(api): set password to a note

* fixup! chore: add todo comments

* fix: typos

* chore: use gotest for test running, if installed

* refactor(mailer): remove unnecessary log

* refactor: update route and naming for updating expiration time

* fixup! refactor: update route and naming for updating expiration time

* refactor(migrations): reformat sql

* refactor: renaming

* fixup! refactor: renaming

* refactor: update password; naming; add add tests

* chore: use more neutral language
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-06-05 16:17:32 +0300
Parent: ebcfde1
M Taskfile.yml

@@ -11,6 +11,10 @@ env:

DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 +vars: + gotest: + sh: 'command -v gotest >/dev/null && echo gotest || echo "go test"' + tasks: run: - docker compose up -d --build --remove-orphans core mailer

@@ -31,7 +35,7 @@ - task: test:unit

- task: test:e2e test:unit: - - go test --count=1 -v --short ./... + - '{{.gotest}} --count=1 -v --short ./...' test:e2e: - - go test --count=1 -v ./e2e/ + - '{{.gotest}} --count=1 -v ./e2e/'
M cmd/seed/notes.go

@@ -32,32 +32,28 @@ password: "",

expiresAt: time.Now().Add(24 * time.Hour), }, { //nolint:exhaustruct - content: "that passworded note", - slug: "passwd", - burnBeforeExpiration: false, - password: "pass", + content: "that passworded note", + slug: "passwd", + password: "pass", }, { //nolint:exhaustruct - content: "that note with author", - slug: "user", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that note with author", + slug: "user", + hasAuthor: true, + authorID: 0, }, { //nolint:exhaustruct - content: "that another authored note", - slug: "user2", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that another authored note", + slug: "user2", + hasAuthor: true, + authorID: 0, }, { //nolint:exhaustruct - content: "that another authored note", - slug: "user2", - password: "passwd", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that another authored note", + slug: "user3", + password: "passwd", + hasAuthor: true, + authorID: 0, }, }
M e2e/apiv1_notes_authorized_test.go

@@ -1,6 +1,9 @@

package e2e_test -import "net/http" +import ( + "net/http" + "time" +) func (e *AppTestSuite) TestNoteV1_Create_authorized() { uid, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

@@ -8,7 +11,7 @@ httpResp := e.httpRequest(

http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct - Content: "some random ass content for the test", + Content: "sample content for the test", }), toks.AccessToken, )

@@ -22,3 +25,177 @@

e.Equal(http.StatusCreated, httpResp.Code) e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String()) } + +func (e *AppTestSuite) TestNoteV1_Delete() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: "sample content for the test", + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + dbNote := e.getNoteBySlug(body.Slug) + e.NotEmpty(dbNote) + + httpResp = e.httpRequest( + http.MethodDelete, + "/api/v1/note/"+body.Slug, + nil, + toks.AccessToken, + ) + e.Equal(httpResp.Code, http.StatusNoContent) + + dbNote = e.getNoteBySlug(body.Slug) + e.Empty(dbNote) +} + +type apiV1NotePatchRequest struct { + ExpiresAt time.Time `json:"expires_at"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` +} + +func (e *AppTestSuite) TestNoteV1_updateExpirationTime() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: "sample content for the test", + ExpiresAt: time.Now().Add(time.Minute), + BurnBeforeExpiration: false, + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + patchTime := time.Now().Add(time.Hour) + httpResp = e.httpRequest( + http.MethodPatch, + "/api/v1/note/"+body.Slug+"/expires", + e.jsonify(apiV1NotePatchRequest{ + ExpiresAt: patchTime, + BurnBeforeExpiration: true, + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusOK) + + dbNote := e.getNoteBySlug(body.Slug) + e.Equal(true, dbNote.BurnBeforeExpiration) + e.Equal(patchTime.Unix(), dbNote.ExpiresAt.Unix()) +} + +func (e *AppTestSuite) TestNoteV1_updateExpirationTime_notFound() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPatch, + "/api/v1/note/"+e.uuid(), + e.jsonify(apiV1NotePatchRequest{ + ExpiresAt: time.Now().Add(time.Hour), + BurnBeforeExpiration: true, + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusNotFound) +} + +type apiV1NoteSetPasswordRequest struct { + Password string `json:"password"` +} + +func (e *AppTestSuite) TestNoteV1_UpdatePassword() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: "content", + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + dbNoteOriginal := e.getNoteBySlug(body.Slug) + e.Empty(dbNoteOriginal.Password) + + passwd := "new-password" + httpResp = e.httpRequest( + http.MethodPatch, + "/api/v1/note/"+body.Slug+"/password", + e.jsonify(apiV1NoteSetPasswordRequest{ + Password: passwd, + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusOK) + + dbNote := e.getNoteBySlug(body.Slug) + e.NotEmpty(dbNote.Password) + + err := e.hasher.Compare(dbNote.Password, passwd) + e.require.NoError(err) +} + +func (e *AppTestSuite) TestNoteV1_UpdatePassword_notFound() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPatch, + "/api/v1/note/"+e.uuid()+"/password", + e.jsonify(apiV1NoteSetPasswordRequest{ + Password: "passwd", + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusNotFound) +} + +func (e *AppTestSuite) TestNoteV1_UpdatePassword_passwordNotProvided() { + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: "content", + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusCreated) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + dbNoteOriginal := e.getNoteBySlug(body.Slug) + e.Empty(dbNoteOriginal.Password) + + httpResp = e.httpRequest( + http.MethodPatch, + "/api/v1/note/"+body.Slug+"/password", + e.jsonify(apiV1NoteSetPasswordRequest{ + Password: "", + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusBadRequest) +}
M e2e/e2e_utils_db_test.go

@@ -104,6 +104,7 @@ "id",

"content", "slug", "burn_before_expiration", + "password", "read_at", "created_at", "expires_at",

@@ -115,7 +116,7 @@ e.require.NoError(err)

var note models.Note err = e.postgresDB.QueryRow(e.ctx, query, args...). - Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt) + Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return models.Note{} //nolint:exhaustruct }
M internal/dtos/note.go

@@ -24,3 +24,18 @@ Password string

CreatedAt time.Time ExpiresAt time.Time } + +type NoteDetailed struct { + Content string + Slug NoteSlug + BurnBeforeExpiration bool + HasPassword bool + CreatedAt time.Time + ExpiresAt time.Time + ReadAt time.Time +} + +type PatchNote struct { + ExpiresAt *time.Time + BurnBeforeExpiration *bool +}
M internal/jwtutil/jwtutil.go

@@ -9,7 +9,10 @@

"github.com/golang-jwt/jwt/v5" ) -var ErrUnexpectedSigningMethod = errors.New("unexpected signing method") +var ( + ErrUnexpectedSigningMethod = errors.New("unexpected signing method") + ErrTokenExpired = errors.New("token expired") +) type JWTTokenizer interface { // AccessToken generates a new access token with the given [Payload].

@@ -65,6 +68,11 @@ return nil, ErrUnexpectedSigningMethod

} return []byte(j.signingKey), nil }) + + if errors.Is(err, jwt.ErrTokenExpired) { + return Payload{}, ErrTokenExpired + } + return Payload{ UserID: claims.Subject, }, err
M internal/service/notesrv/notesrv.go

@@ -2,6 +2,7 @@ package notesrv

import ( "context" + "errors" "log/slog" "time"

@@ -13,17 +14,42 @@ "github.com/olexsmir/onasty/internal/store/psql/noterepo"

"github.com/olexsmir/onasty/internal/store/rdb/notecache" ) +var ErrNotePasswordNotProvided = errors.New("note: password was not provided") + type NoteServicer interface { // Create creates note // if slug is empty it will be generated, otherwise used as is // if userID is empty it means user isn't authorized so it will be used Create(ctx context.Context, note dtos.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error) - // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed + // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed. + // If notes is not found returns [models.ErrNoteNotFound]. GetBySlugAndRemoveIfNeeded( ctx context.Context, input GetNoteBySlugInput, ) (dtos.GetNote, error) + + // GetAllByAuthorID returns all notes by author id. + GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, + ) ([]dtos.NoteDetailed, error) + + // UpdateExpirationTimeSettings updates expiresAt and burnBeforeExpiration. + // If notes is not found returns [models.ErrNoteNotFound]. + UpdateExpirationTimeSettings( + ctx context.Context, + patchData dtos.PatchNote, + slug dtos.NoteSlug, + userID uuid.UUID, + ) error + + // UpdatePassword sets or updates notes password. + // If notes is not found returns [models.ErrNoteNotFound]. + UpdatePassword(ctx context.Context, slug dtos.NoteSlug, passwd string, userID uuid.UUID) error + + // DeleteBySlug deletes note by slug + DeleteBySlug(ctx context.Context, slug dtos.NoteSlug, userID uuid.UUID) error } var _ NoteServicer = (*NoteSrv)(nil)

@@ -114,6 +140,66 @@ return respNote, nil

} return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) +} + +func (n *NoteSrv) GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]dtos.NoteDetailed, error) { + notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID) + if err != nil { + return nil, err + } + + var resNotes []dtos.NoteDetailed + for _, note := range notes { + resNotes = append(resNotes, dtos.NoteDetailed{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.Password != "", + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + return resNotes, nil +} + +func (n *NoteSrv) UpdateExpirationTimeSettings( + ctx context.Context, + patchData dtos.PatchNote, + slug dtos.NoteSlug, + userID uuid.UUID, +) error { + return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID) +} + +func (n *NoteSrv) UpdatePassword( + ctx context.Context, + slug dtos.NoteSlug, + passwd string, + userID uuid.UUID, +) error { + if len(passwd) == 0 { + return ErrNotePasswordNotProvided + } + + hashedPassword, err := n.hasher.Hash(passwd) + if err != nil { + return err + } + + return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword) +} + +func (n *NoteSrv) DeleteBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, +) error { + return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID) } func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {
M internal/store/psql/noterepo/noterepo.go

@@ -21,6 +21,9 @@ // GetBySlug gets a note by slug.

// Returns [models.ErrNoteNotFound] if note is not found. GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) + // GetAllByAuthorID returns all notes with specified author. + GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) + // GetBySlugAndPassword gets a note by slug and password. // the "password" should be hashed. //

@@ -30,14 +33,35 @@ ctx context.Context,

slug dtos.NoteSlug, password string, ) (models.Note, error) + + // UpdateExpirationTimeSettingsBySlug patches note by updating expiresAt and burnBeforeExpiration if one is passwd + // Returns [models.ErrNoteNotFound] if note is not found. + UpdateExpirationTimeSettingsBySlug( + ctx context.Context, + slug dtos.NoteSlug, + patch dtos.PatchNote, + authorID uuid.UUID, + ) error // RemoveBySlug marks note as read, deletes it's content, and keeps meta data // Returns [models.ErrNoteNotFound] if note is not found. RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error + // DeleteNoteBySlug deletes(unlike [RemoveBySlug]) note by slug. + // Returns [models.ErrNoteNotFound] if note is not found. + DeleteNoteBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error + // SetAuthorIDBySlug assigns author to note by slug. // Returns [models.ErrNoteNotFound] if note is not found. SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error + + // UpdatePasswordBySlug updates or sets password on a note. + UpdatePasswordBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, + passwd string, + ) error } var _ NoteStorer = (*NoteRepo)(nil)

@@ -90,6 +114,36 @@

return note, err } +func (s *NoteRepo) GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]models.Note, error) { + query := `--sql + select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at + from notes n + right join notes_authors na on n.id = na.note_id + where na.user_id = $1` + + rows, err := s.db.Query(ctx, query, authorID.String()) + if err != nil { + return nil, err + } + + defer rows.Close() + + var notes []models.Note + for rows.Next() { + var note models.Note + if err := rows.Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password, + &note.ReadAt, &note.CreatedAt, &note.ExpiresAt); err != nil { + return nil, err + } + notes = append(notes, note) + } + + return notes, rows.Err() +} + func (s *NoteRepo) GetBySlugAndPassword( ctx context.Context, slug dtos.NoteSlug,

@@ -118,6 +172,35 @@

return note, err } +func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug( + ctx context.Context, + slug dtos.NoteSlug, + patch dtos.PatchNote, + authorID uuid.UUID, +) error { + query := `--sql +update notes n +set burn_before_expiration = COALESCE($1, n.burn_before_expiration), + expires_at = COALESCE($2, n.expires_at) +from notes_authors na +where n.slug = $3 + and na.user_id = $4 + and na.note_id = n.id` + + ct, err := s.db.Exec(ctx, query, + patch.BurnBeforeExpiration, patch.ExpiresAt, + slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +} + func (s *NoteRepo) RemoveBySlug( ctx context.Context, slug dtos.NoteSlug,

@@ -144,6 +227,29 @@

return err } +func (s *NoteRepo) DeleteNoteBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, +) error { + query := `--sql +delete from notes n +using notes_authors na +where n.slug = $1 + and na.user_id = $2` + + ct, err := s.db.Exec(ctx, query, slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +} + func (s *NoteRepo) SetAuthorIDBySlug( ctx context.Context, slug dtos.NoteSlug,

@@ -175,3 +281,29 @@ }

return tx.Commit(ctx) } + +func (s *NoteRepo) UpdatePasswordBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, + passwd string, +) error { + query := `--sql +update notes n +set password = $1 +from notes_authors na +where n.slug = $2 + and na.user_id = $3 + and na.note_id = n.id` + + ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +}
M internal/transport/http/apiv1/apiv1.go

@@ -54,5 +54,13 @@ possiblyAuthorized := note.Group("", a.couldBeAuthorizedMiddleware)

{ possiblyAuthorized.POST("", a.createNoteHandler) } + + authorized := note.Group("", a.authorizedMiddleware) + { + authorized.GET("", a.getNotesHandler) + authorized.PATCH(":slug/expires", a.updateNoteHandler) + authorized.PATCH(":slug/password", a.setNotePasswordHandler) + authorized.DELETE(":slug", a.deleteNoteHandler) + } } }
M internal/transport/http/apiv1/middleware.go

@@ -90,6 +90,7 @@ }

// getUserId returns userId from the context // getting user id is only possible if user is authorized +// if userID is not set, [uuid.Nil] will be returned. func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { userID, exists := c.Get(userIDCtxKey) if !exists {
M internal/transport/http/apiv1/note.go

@@ -8,7 +8,6 @@ "time"

"github.com/gin-gonic/gin" "github.com/olexsmir/onasty/internal/dtos" - "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/service/notesrv" )

@@ -31,28 +30,16 @@ newError(c, http.StatusBadRequest, "invalid request")

return } - note := models.Note{ //nolint:exhaustruct + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at + + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ Content: req.Content, + UserID: a.getUserID(c), Slug: req.Slug, + Password: req.Password, BurnBeforeExpiration: req.BurnBeforeExpiration, CreatedAt: time.Now(), - Password: req.Password, ExpiresAt: req.ExpiresAt, - } - - if err := note.Validate(); err != nil { - newErrorStatus(c, http.StatusBadRequest, err.Error()) - return - } - - slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ - Content: note.Content, - UserID: a.getUserID(c), - Slug: note.Slug, - Password: note.Password, - BurnBeforeExpiration: note.BurnBeforeExpiration, - CreatedAt: note.CreatedAt, - ExpiresAt: note.ExpiresAt, }, a.getUserID(c)) if err != nil { errorResponse(c, err)

@@ -104,3 +91,103 @@ CreatedAt: note.CreatedAt,

ExpiresAt: note.ExpiresAt, }) } + +type getNotesResponse struct { + Content string `json:"content"` + Slug string `json:"slug"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` + HasPassword bool `json:"has_password"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at,omitzero"` + ReadAt time.Time `json:"read_at,omitzero"` +} + +func (a *APIV1) getNotesHandler(c *gin.Context) { + notes, err := a.notesrv.GetAllByAuthorID(c.Request.Context(), a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return + } + + var response []getNotesResponse + for _, note := range notes { + response = append(response, getNotesResponse{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.HasPassword, + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + c.JSON(http.StatusOK, response) +} + +type updateNoteRequest struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` + BurnBeforeExpiration *bool `json:"burn_before_expiration,omitempty"` +} + +func (a *APIV1) updateNoteHandler(c *gin.Context) { + var req updateNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at + + if err := a.notesrv.UpdateExpirationTimeSettings( + c.Request.Context(), + dtos.PatchNote{ + BurnBeforeExpiration: req.BurnBeforeExpiration, + ExpiresAt: req.ExpiresAt, + }, + c.Param("slug"), + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +func (a *APIV1) deleteNoteHandler(c *gin.Context) { + if err := a.notesrv.DeleteBySlug( + c.Request.Context(), + c.Param("slug"), + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +type setNotePasswordRequest struct { + Password string `json:"password"` +} + +func (a *APIV1) setNotePasswordHandler(c *gin.Context) { + var req setNotePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.notesrv.UpdatePassword( + c.Request.Context(), + c.Param("slug"), + req.Password, + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +}
M internal/transport/http/apiv1/response.go

@@ -6,7 +6,9 @@ "log/slog"

"net/http" "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" )

@@ -27,6 +29,7 @@ errors.Is(err, models.ErrUserInvalidEmail) ||

errors.Is(err, models.ErrUserInvalidPassword) || errors.Is(err, models.ErrUserNotFound) || // notes + errors.Is(err, notesrv.ErrNotePasswordNotProvided) || errors.Is(err, models.ErrNoteContentIsEmpty) || errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { newError(c, http.StatusBadRequest, err.Error())

@@ -45,6 +48,7 @@ return

} if errors.Is(err, ErrUnauthorized) || + errors.Is(err, jwtutil.ErrTokenExpired) || errors.Is(err, models.ErrUserWrongCredentials) { newErrorStatus(c, http.StatusUnauthorized, err.Error()) return
M mailer/service.go

@@ -33,9 +33,6 @@

go func() { select { case <-ctx.Done(): - slog.ErrorContext(ctx, "failed to send email", - "template_name", templateName, - "err", ctx.Err()) return default: if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil {
M migrations/20240613092407_users.up.sql

@@ -1,5 +1,5 @@

CREATE TABLE users ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), username varchar(255) NOT NULL UNIQUE, email varchar(255) NOT NULL UNIQUE, password varchar(255) NOT NULL,
M migrations/20240613092532_sessions.up.sql

@@ -1,5 +1,5 @@

CREATE TABLE sessions ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), user_id uuid REFERENCES users (id), refresh_token varchar(255) NOT NULL UNIQUE, expires_at timestamptz NOT NULL
M migrations/20240716235210_notes.up.sql

@@ -1,5 +1,5 @@

CREATE TABLE notes ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), content text NOT NULL, slug varchar(255) NOT NULL UNIQUE, burn_before_expiration boolean DEFAULT FALSE,
M migrations/20240724122920_notes_authors.up.sql

@@ -1,5 +1,5 @@

CREATE TABLE notes_authors ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), note_id uuid REFERENCES notes (id) ON DELETE CASCADE, user_id uuid REFERENCES users (id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now()
M migrations/20240729115827_verification_tokens.up.sql

@@ -1,5 +1,5 @@

CREATE TABLE verification_tokens ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), user_id uuid NOT NULL UNIQUE REFERENCES users (id), token varchar(255) NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now(),
M migrations/20250520211029_remove_username.down.sql

@@ -1,2 +1,2 @@

ALTER TABLE users - add column username varchar(255) NOT NULL UNIQUE; + ADD COLUMN username varchar(255) NOT NULL UNIQUE;
A migrations/20250530135721_note_author_add_unique_constraint.down.sql

@@ -0,0 +1,2 @@

+ALTER TABLE notes_authors + DROP CONSTRAINT notes_authors_pair_user;
A migrations/20250530135721_note_author_add_unique_constraint.up.sql

@@ -0,0 +1,2 @@

+ALTER TABLE notes_authors + ADD CONSTRAINT notes_authors_pair_user UNIQUE (note_id, user_id)