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
        11
           DOCKER_BUILDKIT: 1

      
        12
        12
           COMPOSE_DOCKER_CLI_BUILD: 1

      
        13
        13
         

      
        
        14
        +vars:

      
        
        15
        +  gotest:

      
        
        16
        +    sh: 'command -v gotest >/dev/null && echo gotest || echo "go test"'

      
        
        17
        +

      
        14
        18
         tasks:

      
        15
        19
           run:

      
        16
        20
             - docker compose up -d --build --remove-orphans core mailer

      ···
        31
        35
             - task: test:e2e

      
        32
        36
         

      
        33
        37
           test:unit:

      
        34
        
        -    - go test --count=1 -v --short ./...

      
        
        38
        +    - '{{.gotest}} --count=1 -v --short ./...'

      
        35
        39
         

      
        36
        40
           test:e2e:

      
        37
        
        -    - go test --count=1 -v ./e2e/

      
        
        41
        +    - '{{.gotest}} --count=1 -v ./e2e/'

      
M cmd/seed/notes.go
···
        32
        32
         		expiresAt:            time.Now().Add(24 * time.Hour),

      
        33
        33
         	},

      
        34
        34
         	{ //nolint:exhaustruct

      
        35
        
        -		content:              "that passworded note",

      
        36
        
        -		slug:                 "passwd",

      
        37
        
        -		burnBeforeExpiration: false,

      
        38
        
        -		password:             "pass",

      
        
        35
        +		content:  "that passworded note",

      
        
        36
        +		slug:     "passwd",

      
        
        37
        +		password: "pass",

      
        39
        38
         	},

      
        40
        39
         	{ //nolint:exhaustruct

      
        41
        
        -		content:              "that note with author",

      
        42
        
        -		slug:                 "user",

      
        43
        
        -		burnBeforeExpiration: false,

      
        44
        
        -		hasAuthor:            true,

      
        45
        
        -		authorID:             0,

      
        
        40
        +		content:   "that note with author",

      
        
        41
        +		slug:      "user",

      
        
        42
        +		hasAuthor: true,

      
        
        43
        +		authorID:  0,

      
        46
        44
         	},

      
        47
        45
         	{ //nolint:exhaustruct

      
        48
        
        -		content:              "that another authored note",

      
        49
        
        -		slug:                 "user2",

      
        50
        
        -		burnBeforeExpiration: false,

      
        51
        
        -		hasAuthor:            true,

      
        52
        
        -		authorID:             0,

      
        
        46
        +		content:   "that another authored note",

      
        
        47
        +		slug:      "user2",

      
        
        48
        +		hasAuthor: true,

      
        
        49
        +		authorID:  0,

      
        53
        50
         	},

      
        54
        51
         	{ //nolint:exhaustruct

      
        55
        
        -		content:              "that another authored note",

      
        56
        
        -		slug:                 "user2",

      
        57
        
        -		password:             "passwd",

      
        58
        
        -		burnBeforeExpiration: false,

      
        59
        
        -		hasAuthor:            true,

      
        60
        
        -		authorID:             0,

      
        
        52
        +		content:   "that another authored note",

      
        
        53
        +		slug:      "user3",

      
        
        54
        +		password:  "passwd",

      
        
        55
        +		hasAuthor: true,

      
        
        56
        +		authorID:  0,

      
        61
        57
         	},

      
        62
        58
         }

      
        63
        59
         

      
M e2e/apiv1_notes_authorized_test.go
···
        1
        1
         package e2e_test

      
        2
        2
         

      
        3
        
        -import "net/http"

      
        
        3
        +import (

      
        
        4
        +	"net/http"

      
        
        5
        +	"time"

      
        
        6
        +)

      
        4
        7
         

      
        5
        8
         func (e *AppTestSuite) TestNoteV1_Create_authorized() {

      
        6
        9
         	uid, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      ···
        8
        11
         		http.MethodPost,

      
        9
        12
         		"/api/v1/note",

      
        10
        13
         		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        11
        
        -			Content: "some random ass content for the test",

      
        
        14
        +			Content: "sample content for the test",

      
        12
        15
         		}),

      
        13
        16
         		toks.AccessToken,

      
        14
        17
         	)

      ···
        22
        25
         	e.Equal(http.StatusCreated, httpResp.Code)

      
        23
        26
         	e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String())

      
        24
        27
         }

      
        
        28
        +

      
        
        29
        +func (e *AppTestSuite) TestNoteV1_Delete() {

      
        
        30
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        31
        +	httpResp := e.httpRequest(

      
        
        32
        +		http.MethodPost,

      
        
        33
        +		"/api/v1/note",

      
        
        34
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        35
        +			Content: "sample content for the test",

      
        
        36
        +		}),

      
        
        37
        +		toks.AccessToken,

      
        
        38
        +	)

      
        
        39
        +

      
        
        40
        +	e.Equal(httpResp.Code, http.StatusCreated)

      
        
        41
        +

      
        
        42
        +	var body apiv1NoteCreateResponse

      
        
        43
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        44
        +

      
        
        45
        +	dbNote := e.getNoteBySlug(body.Slug)

      
        
        46
        +	e.NotEmpty(dbNote)

      
        
        47
        +

      
        
        48
        +	httpResp = e.httpRequest(

      
        
        49
        +		http.MethodDelete,

      
        
        50
        +		"/api/v1/note/"+body.Slug,

      
        
        51
        +		nil,

      
        
        52
        +		toks.AccessToken,

      
        
        53
        +	)

      
        
        54
        +	e.Equal(httpResp.Code, http.StatusNoContent)

      
        
        55
        +

      
        
        56
        +	dbNote = e.getNoteBySlug(body.Slug)

      
        
        57
        +	e.Empty(dbNote)

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +type apiV1NotePatchRequest struct {

      
        
        61
        +	ExpiresAt            time.Time `json:"expires_at"`

      
        
        62
        +	BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        
        63
        +}

      
        
        64
        +

      
        
        65
        +func (e *AppTestSuite) TestNoteV1_updateExpirationTime() {

      
        
        66
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        67
        +	httpResp := e.httpRequest(

      
        
        68
        +		http.MethodPost,

      
        
        69
        +		"/api/v1/note",

      
        
        70
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        71
        +			Content:              "sample content for the test",

      
        
        72
        +			ExpiresAt:            time.Now().Add(time.Minute),

      
        
        73
        +			BurnBeforeExpiration: false,

      
        
        74
        +		}),

      
        
        75
        +		toks.AccessToken,

      
        
        76
        +	)

      
        
        77
        +

      
        
        78
        +	e.Equal(httpResp.Code, http.StatusCreated)

      
        
        79
        +

      
        
        80
        +	var body apiv1NoteCreateResponse

      
        
        81
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        82
        +

      
        
        83
        +	patchTime := time.Now().Add(time.Hour)

      
        
        84
        +	httpResp = e.httpRequest(

      
        
        85
        +		http.MethodPatch,

      
        
        86
        +		"/api/v1/note/"+body.Slug+"/expires",

      
        
        87
        +		e.jsonify(apiV1NotePatchRequest{

      
        
        88
        +			ExpiresAt:            patchTime,

      
        
        89
        +			BurnBeforeExpiration: true,

      
        
        90
        +		}),

      
        
        91
        +		toks.AccessToken,

      
        
        92
        +	)

      
        
        93
        +

      
        
        94
        +	e.Equal(httpResp.Code, http.StatusOK)

      
        
        95
        +

      
        
        96
        +	dbNote := e.getNoteBySlug(body.Slug)

      
        
        97
        +	e.Equal(true, dbNote.BurnBeforeExpiration)

      
        
        98
        +	e.Equal(patchTime.Unix(), dbNote.ExpiresAt.Unix())

      
        
        99
        +}

      
        
        100
        +

      
        
        101
        +func (e *AppTestSuite) TestNoteV1_updateExpirationTime_notFound() {

      
        
        102
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        103
        +	httpResp := e.httpRequest(

      
        
        104
        +		http.MethodPatch,

      
        
        105
        +		"/api/v1/note/"+e.uuid(),

      
        
        106
        +		e.jsonify(apiV1NotePatchRequest{

      
        
        107
        +			ExpiresAt:            time.Now().Add(time.Hour),

      
        
        108
        +			BurnBeforeExpiration: true,

      
        
        109
        +		}),

      
        
        110
        +		toks.AccessToken,

      
        
        111
        +	)

      
        
        112
        +

      
        
        113
        +	e.Equal(httpResp.Code, http.StatusNotFound)

      
        
        114
        +}

      
        
        115
        +

      
        
        116
        +type apiV1NoteSetPasswordRequest struct {

      
        
        117
        +	Password string `json:"password"`

      
        
        118
        +}

      
        
        119
        +

      
        
        120
        +func (e *AppTestSuite) TestNoteV1_UpdatePassword() {

      
        
        121
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        122
        +	httpResp := e.httpRequest(

      
        
        123
        +		http.MethodPost,

      
        
        124
        +		"/api/v1/note",

      
        
        125
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        126
        +			Content: "content",

      
        
        127
        +		}),

      
        
        128
        +		toks.AccessToken,

      
        
        129
        +	)

      
        
        130
        +

      
        
        131
        +	e.Equal(httpResp.Code, http.StatusCreated)

      
        
        132
        +

      
        
        133
        +	var body apiv1NoteCreateResponse

      
        
        134
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        135
        +

      
        
        136
        +	dbNoteOriginal := e.getNoteBySlug(body.Slug)

      
        
        137
        +	e.Empty(dbNoteOriginal.Password)

      
        
        138
        +

      
        
        139
        +	passwd := "new-password"

      
        
        140
        +	httpResp = e.httpRequest(

      
        
        141
        +		http.MethodPatch,

      
        
        142
        +		"/api/v1/note/"+body.Slug+"/password",

      
        
        143
        +		e.jsonify(apiV1NoteSetPasswordRequest{

      
        
        144
        +			Password: passwd,

      
        
        145
        +		}),

      
        
        146
        +		toks.AccessToken,

      
        
        147
        +	)

      
        
        148
        +

      
        
        149
        +	e.Equal(httpResp.Code, http.StatusOK)

      
        
        150
        +

      
        
        151
        +	dbNote := e.getNoteBySlug(body.Slug)

      
        
        152
        +	e.NotEmpty(dbNote.Password)

      
        
        153
        +

      
        
        154
        +	err := e.hasher.Compare(dbNote.Password, passwd)

      
        
        155
        +	e.require.NoError(err)

      
        
        156
        +}

      
        
        157
        +

      
        
        158
        +func (e *AppTestSuite) TestNoteV1_UpdatePassword_notFound() {

      
        
        159
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        160
        +	httpResp := e.httpRequest(

      
        
        161
        +		http.MethodPatch,

      
        
        162
        +		"/api/v1/note/"+e.uuid()+"/password",

      
        
        163
        +		e.jsonify(apiV1NoteSetPasswordRequest{

      
        
        164
        +			Password: "passwd",

      
        
        165
        +		}),

      
        
        166
        +		toks.AccessToken,

      
        
        167
        +	)

      
        
        168
        +

      
        
        169
        +	e.Equal(httpResp.Code, http.StatusNotFound)

      
        
        170
        +}

      
        
        171
        +

      
        
        172
        +func (e *AppTestSuite) TestNoteV1_UpdatePassword_passwordNotProvided() {

      
        
        173
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", "password")

      
        
        174
        +	httpResp := e.httpRequest(

      
        
        175
        +		http.MethodPost,

      
        
        176
        +		"/api/v1/note",

      
        
        177
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        178
        +			Content: "content",

      
        
        179
        +		}),

      
        
        180
        +		toks.AccessToken,

      
        
        181
        +	)

      
        
        182
        +

      
        
        183
        +	e.Equal(httpResp.Code, http.StatusCreated)

      
        
        184
        +

      
        
        185
        +	var body apiv1NoteCreateResponse

      
        
        186
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        187
        +

      
        
        188
        +	dbNoteOriginal := e.getNoteBySlug(body.Slug)

      
        
        189
        +	e.Empty(dbNoteOriginal.Password)

      
        
        190
        +

      
        
        191
        +	httpResp = e.httpRequest(

      
        
        192
        +		http.MethodPatch,

      
        
        193
        +		"/api/v1/note/"+body.Slug+"/password",

      
        
        194
        +		e.jsonify(apiV1NoteSetPasswordRequest{

      
        
        195
        +			Password: "",

      
        
        196
        +		}),

      
        
        197
        +		toks.AccessToken,

      
        
        198
        +	)

      
        
        199
        +

      
        
        200
        +	e.Equal(httpResp.Code, http.StatusBadRequest)

      
        
        201
        +}

      
M e2e/e2e_utils_db_test.go
···
        104
        104
         			"content",

      
        105
        105
         			"slug",

      
        106
        106
         			"burn_before_expiration",

      
        
        107
        +			"password",

      
        107
        108
         			"read_at",

      
        108
        109
         			"created_at",

      
        109
        110
         			"expires_at",

      ···
        115
        116
         

      
        116
        117
         	var note models.Note

      
        117
        118
         	err = e.postgresDB.QueryRow(e.ctx, query, args...).

      
        118
        
        -		Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)

      
        
        119
        +		Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)

      
        119
        120
         	if errors.Is(err, pgx.ErrNoRows) {

      
        120
        121
         		return models.Note{} //nolint:exhaustruct

      
        121
        122
         	}

      
M internal/dtos/note.go
···
        24
        24
         	CreatedAt            time.Time

      
        25
        25
         	ExpiresAt            time.Time

      
        26
        26
         }

      
        
        27
        +

      
        
        28
        +type NoteDetailed struct {

      
        
        29
        +	Content              string

      
        
        30
        +	Slug                 NoteSlug

      
        
        31
        +	BurnBeforeExpiration bool

      
        
        32
        +	HasPassword          bool

      
        
        33
        +	CreatedAt            time.Time

      
        
        34
        +	ExpiresAt            time.Time

      
        
        35
        +	ReadAt               time.Time

      
        
        36
        +}

      
        
        37
        +

      
        
        38
        +type PatchNote struct {

      
        
        39
        +	ExpiresAt            *time.Time

      
        
        40
        +	BurnBeforeExpiration *bool

      
        
        41
        +}

      
M internal/jwtutil/jwtutil.go
···
        9
        9
         	"github.com/golang-jwt/jwt/v5"

      
        10
        10
         )

      
        11
        11
         

      
        12
        
        -var ErrUnexpectedSigningMethod = errors.New("unexpected signing method")

      
        
        12
        +var (

      
        
        13
        +	ErrUnexpectedSigningMethod = errors.New("unexpected signing method")

      
        
        14
        +	ErrTokenExpired            = errors.New("token expired")

      
        
        15
        +)

      
        13
        16
         

      
        14
        17
         type JWTTokenizer interface {

      
        15
        18
         	// AccessToken generates a new access token with the given [Payload].

      ···
        65
        68
         		}

      
        66
        69
         		return []byte(j.signingKey), nil

      
        67
        70
         	})

      
        
        71
        +

      
        
        72
        +	if errors.Is(err, jwt.ErrTokenExpired) {

      
        
        73
        +		return Payload{}, ErrTokenExpired

      
        
        74
        +	}

      
        
        75
        +

      
        68
        76
         	return Payload{

      
        69
        77
         		UserID: claims.Subject,

      
        70
        78
         	}, err

      
M internal/service/notesrv/notesrv.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        
        5
        +	"errors"

      
        5
        6
         	"log/slog"

      
        6
        7
         	"time"

      
        7
        8
         

      ···
        13
        14
         	"github.com/olexsmir/onasty/internal/store/rdb/notecache"

      
        14
        15
         )

      
        15
        16
         

      
        
        17
        +var ErrNotePasswordNotProvided = errors.New("note: password was not provided")

      
        
        18
        +

      
        16
        19
         type NoteServicer interface {

      
        17
        20
         	// Create creates note

      
        18
        21
         	// if slug is empty it will be generated, otherwise used as is

      
        19
        22
         	// if userID is empty it means user isn't authorized so it will be used

      
        20
        23
         	Create(ctx context.Context, note dtos.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error)

      
        21
        24
         

      
        22
        
        -	// GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed

      
        
        25
        +	// GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed.

      
        
        26
        +	// If notes is not found returns [models.ErrNoteNotFound].

      
        23
        27
         	GetBySlugAndRemoveIfNeeded(

      
        24
        28
         		ctx context.Context,

      
        25
        29
         		input GetNoteBySlugInput,

      
        26
        30
         	) (dtos.GetNote, error)

      
        
        31
        +

      
        
        32
        +	// GetAllByAuthorID returns all notes by author id.

      
        
        33
        +	GetAllByAuthorID(

      
        
        34
        +		ctx context.Context,

      
        
        35
        +		authorID uuid.UUID,

      
        
        36
        +	) ([]dtos.NoteDetailed, error)

      
        
        37
        +

      
        
        38
        +	// UpdateExpirationTimeSettings updates expiresAt and burnBeforeExpiration.

      
        
        39
        +	// If notes is not found returns [models.ErrNoteNotFound].

      
        
        40
        +	UpdateExpirationTimeSettings(

      
        
        41
        +		ctx context.Context,

      
        
        42
        +		patchData dtos.PatchNote,

      
        
        43
        +		slug dtos.NoteSlug,

      
        
        44
        +		userID uuid.UUID,

      
        
        45
        +	) error

      
        
        46
        +

      
        
        47
        +	// UpdatePassword sets or updates notes password.

      
        
        48
        +	// If notes is not found returns [models.ErrNoteNotFound].

      
        
        49
        +	UpdatePassword(ctx context.Context, slug dtos.NoteSlug, passwd string, userID uuid.UUID) error

      
        
        50
        +

      
        
        51
        +	// DeleteBySlug deletes note by slug

      
        
        52
        +	DeleteBySlug(ctx context.Context, slug dtos.NoteSlug, userID uuid.UUID) error

      
        27
        53
         }

      
        28
        54
         

      
        29
        55
         var _ NoteServicer = (*NoteSrv)(nil)

      ···
        114
        140
         	}

      
        115
        141
         

      
        116
        142
         	return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now())

      
        
        143
        +}

      
        
        144
        +

      
        
        145
        +func (n *NoteSrv) GetAllByAuthorID(

      
        
        146
        +	ctx context.Context,

      
        
        147
        +	authorID uuid.UUID,

      
        
        148
        +) ([]dtos.NoteDetailed, error) {

      
        
        149
        +	notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID)

      
        
        150
        +	if err != nil {

      
        
        151
        +		return nil, err

      
        
        152
        +	}

      
        
        153
        +

      
        
        154
        +	var resNotes []dtos.NoteDetailed

      
        
        155
        +	for _, note := range notes {

      
        
        156
        +		resNotes = append(resNotes, dtos.NoteDetailed{

      
        
        157
        +			Content:              note.Content,

      
        
        158
        +			Slug:                 note.Slug,

      
        
        159
        +			BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        
        160
        +			HasPassword:          note.Password != "",

      
        
        161
        +			CreatedAt:            note.CreatedAt,

      
        
        162
        +			ExpiresAt:            note.ExpiresAt,

      
        
        163
        +			ReadAt:               note.ReadAt,

      
        
        164
        +		})

      
        
        165
        +	}

      
        
        166
        +

      
        
        167
        +	return resNotes, nil

      
        
        168
        +}

      
        
        169
        +

      
        
        170
        +func (n *NoteSrv) UpdateExpirationTimeSettings(

      
        
        171
        +	ctx context.Context,

      
        
        172
        +	patchData dtos.PatchNote,

      
        
        173
        +	slug dtos.NoteSlug,

      
        
        174
        +	userID uuid.UUID,

      
        
        175
        +) error {

      
        
        176
        +	return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID)

      
        
        177
        +}

      
        
        178
        +

      
        
        179
        +func (n *NoteSrv) UpdatePassword(

      
        
        180
        +	ctx context.Context,

      
        
        181
        +	slug dtos.NoteSlug,

      
        
        182
        +	passwd string,

      
        
        183
        +	userID uuid.UUID,

      
        
        184
        +) error {

      
        
        185
        +	if len(passwd) == 0 {

      
        
        186
        +		return ErrNotePasswordNotProvided

      
        
        187
        +	}

      
        
        188
        +

      
        
        189
        +	hashedPassword, err := n.hasher.Hash(passwd)

      
        
        190
        +	if err != nil {

      
        
        191
        +		return err

      
        
        192
        +	}

      
        
        193
        +

      
        
        194
        +	return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword)

      
        
        195
        +}

      
        
        196
        +

      
        
        197
        +func (n *NoteSrv) DeleteBySlug(

      
        
        198
        +	ctx context.Context,

      
        
        199
        +	slug dtos.NoteSlug,

      
        
        200
        +	authorID uuid.UUID,

      
        
        201
        +) error {

      
        
        202
        +	return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID)

      
        117
        203
         }

      
        118
        204
         

      
        119
        205
         func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {

      
M internal/store/psql/noterepo/noterepo.go
···
        21
        21
         	// Returns [models.ErrNoteNotFound] if note is not found.

      
        22
        22
         	GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error)

      
        23
        23
         

      
        
        24
        +	// GetAllByAuthorID returns all notes with specified author.

      
        
        25
        +	GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error)

      
        
        26
        +

      
        24
        27
         	// GetBySlugAndPassword gets a note by slug and password.

      
        25
        28
         	// the "password" should be hashed.

      
        26
        29
         	//

      ···
        30
        33
         		slug dtos.NoteSlug,

      
        31
        34
         		password string,

      
        32
        35
         	) (models.Note, error)

      
        
        36
        +

      
        
        37
        +	// UpdateExpirationTimeSettingsBySlug patches note by updating expiresAt and burnBeforeExpiration if one is passwd

      
        
        38
        +	// Returns [models.ErrNoteNotFound] if note is not found.

      
        
        39
        +	UpdateExpirationTimeSettingsBySlug(

      
        
        40
        +		ctx context.Context,

      
        
        41
        +		slug dtos.NoteSlug,

      
        
        42
        +		patch dtos.PatchNote,

      
        
        43
        +		authorID uuid.UUID,

      
        
        44
        +	) error

      
        33
        45
         

      
        34
        46
         	// RemoveBySlug marks note as read, deletes it's content, and keeps meta data

      
        35
        47
         	// Returns [models.ErrNoteNotFound] if note is not found.

      
        36
        48
         	RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error

      
        37
        49
         

      
        
        50
        +	// DeleteNoteBySlug deletes(unlike [RemoveBySlug]) note by slug.

      
        
        51
        +	// Returns [models.ErrNoteNotFound] if note is not found.

      
        
        52
        +	DeleteNoteBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error

      
        
        53
        +

      
        38
        54
         	// SetAuthorIDBySlug assigns author to note by slug.

      
        39
        55
         	// Returns [models.ErrNoteNotFound] if note is not found.

      
        40
        56
         	SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error

      
        
        57
        +

      
        
        58
        +	// UpdatePasswordBySlug updates or sets password on a note.

      
        
        59
        +	UpdatePasswordBySlug(

      
        
        60
        +		ctx context.Context,

      
        
        61
        +		slug dtos.NoteSlug,

      
        
        62
        +		authorID uuid.UUID,

      
        
        63
        +		passwd string,

      
        
        64
        +	) error

      
        41
        65
         }

      
        42
        66
         

      
        43
        67
         var _ NoteStorer = (*NoteRepo)(nil)

      ···
        90
        114
         	return note, err

      
        91
        115
         }

      
        92
        116
         

      
        
        117
        +func (s *NoteRepo) GetAllByAuthorID(

      
        
        118
        +	ctx context.Context,

      
        
        119
        +	authorID uuid.UUID,

      
        
        120
        +) ([]models.Note, error) {

      
        
        121
        +	query := `--sql

      
        
        122
        +	select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at

      
        
        123
        +	from notes n

      
        
        124
        +	right join notes_authors na on n.id = na.note_id

      
        
        125
        +	where na.user_id = $1`

      
        
        126
        +

      
        
        127
        +	rows, err := s.db.Query(ctx, query, authorID.String())

      
        
        128
        +	if err != nil {

      
        
        129
        +		return nil, err

      
        
        130
        +	}

      
        
        131
        +

      
        
        132
        +	defer rows.Close()

      
        
        133
        +

      
        
        134
        +	var notes []models.Note

      
        
        135
        +	for rows.Next() {

      
        
        136
        +		var note models.Note

      
        
        137
        +		if err := rows.Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password,

      
        
        138
        +			&note.ReadAt, &note.CreatedAt, &note.ExpiresAt); err != nil {

      
        
        139
        +			return nil, err

      
        
        140
        +		}

      
        
        141
        +		notes = append(notes, note)

      
        
        142
        +	}

      
        
        143
        +

      
        
        144
        +	return notes, rows.Err()

      
        
        145
        +}

      
        
        146
        +

      
        93
        147
         func (s *NoteRepo) GetBySlugAndPassword(

      
        94
        148
         	ctx context.Context,

      
        95
        149
         	slug dtos.NoteSlug,

      ···
        118
        172
         	return note, err

      
        119
        173
         }

      
        120
        174
         

      
        
        175
        +func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug(

      
        
        176
        +	ctx context.Context,

      
        
        177
        +	slug dtos.NoteSlug,

      
        
        178
        +	patch dtos.PatchNote,

      
        
        179
        +	authorID uuid.UUID,

      
        
        180
        +) error {

      
        
        181
        +	query := `--sql

      
        
        182
        +update notes n

      
        
        183
        +set burn_before_expiration = COALESCE($1, n.burn_before_expiration),

      
        
        184
        +    expires_at = COALESCE($2, n.expires_at)

      
        
        185
        +from notes_authors na

      
        
        186
        +where n.slug = $3

      
        
        187
        +  and na.user_id = $4

      
        
        188
        +  and na.note_id = n.id`

      
        
        189
        +

      
        
        190
        +	ct, err := s.db.Exec(ctx, query,

      
        
        191
        +		patch.BurnBeforeExpiration, patch.ExpiresAt,

      
        
        192
        +		slug, authorID.String())

      
        
        193
        +	if err != nil {

      
        
        194
        +		return err

      
        
        195
        +	}

      
        
        196
        +

      
        
        197
        +	if ct.RowsAffected() == 0 {

      
        
        198
        +		return models.ErrNoteNotFound

      
        
        199
        +	}

      
        
        200
        +

      
        
        201
        +	return nil

      
        
        202
        +}

      
        
        203
        +

      
        121
        204
         func (s *NoteRepo) RemoveBySlug(

      
        122
        205
         	ctx context.Context,

      
        123
        206
         	slug dtos.NoteSlug,

      ···
        144
        227
         	return err

      
        145
        228
         }

      
        146
        229
         

      
        
        230
        +func (s *NoteRepo) DeleteNoteBySlug(

      
        
        231
        +	ctx context.Context,

      
        
        232
        +	slug dtos.NoteSlug,

      
        
        233
        +	authorID uuid.UUID,

      
        
        234
        +) error {

      
        
        235
        +	query := `--sql

      
        
        236
        +delete from notes n

      
        
        237
        +using notes_authors na

      
        
        238
        +where n.slug = $1

      
        
        239
        +  and na.user_id = $2`

      
        
        240
        +

      
        
        241
        +	ct, err := s.db.Exec(ctx, query, slug, authorID.String())

      
        
        242
        +	if err != nil {

      
        
        243
        +		return err

      
        
        244
        +	}

      
        
        245
        +

      
        
        246
        +	if ct.RowsAffected() == 0 {

      
        
        247
        +		return models.ErrNoteNotFound

      
        
        248
        +	}

      
        
        249
        +

      
        
        250
        +	return nil

      
        
        251
        +}

      
        
        252
        +

      
        147
        253
         func (s *NoteRepo) SetAuthorIDBySlug(

      
        148
        254
         	ctx context.Context,

      
        149
        255
         	slug dtos.NoteSlug,

      ···
        175
        281
         

      
        176
        282
         	return tx.Commit(ctx)

      
        177
        283
         }

      
        
        284
        +

      
        
        285
        +func (s *NoteRepo) UpdatePasswordBySlug(

      
        
        286
        +	ctx context.Context,

      
        
        287
        +	slug dtos.NoteSlug,

      
        
        288
        +	authorID uuid.UUID,

      
        
        289
        +	passwd string,

      
        
        290
        +) error {

      
        
        291
        +	query := `--sql

      
        
        292
        +update notes n

      
        
        293
        +set password = $1

      
        
        294
        +from notes_authors na

      
        
        295
        +where n.slug = $2

      
        
        296
        +  and na.user_id = $3

      
        
        297
        +  and na.note_id = n.id`

      
        
        298
        +

      
        
        299
        +	ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String())

      
        
        300
        +	if err != nil {

      
        
        301
        +		return err

      
        
        302
        +	}

      
        
        303
        +

      
        
        304
        +	if ct.RowsAffected() == 0 {

      
        
        305
        +		return models.ErrNoteNotFound

      
        
        306
        +	}

      
        
        307
        +

      
        
        308
        +	return nil

      
        
        309
        +}

      
M internal/transport/http/apiv1/apiv1.go
···
        54
        54
         		{

      
        55
        55
         			possiblyAuthorized.POST("", a.createNoteHandler)

      
        56
        56
         		}

      
        
        57
        +

      
        
        58
        +		authorized := note.Group("", a.authorizedMiddleware)

      
        
        59
        +		{

      
        
        60
        +			authorized.GET("", a.getNotesHandler)

      
        
        61
        +			authorized.PATCH(":slug/expires", a.updateNoteHandler)

      
        
        62
        +			authorized.PATCH(":slug/password", a.setNotePasswordHandler)

      
        
        63
        +			authorized.DELETE(":slug", a.deleteNoteHandler)

      
        
        64
        +		}

      
        57
        65
         	}

      
        58
        66
         }

      
M internal/transport/http/apiv1/middleware.go
···
        90
        90
         

      
        91
        91
         // getUserId returns userId from the context

      
        92
        92
         // getting user id is only possible if user is authorized

      
        
        93
        +// if userID is not set, [uuid.Nil] will be returned.

      
        93
        94
         func (a *APIV1) getUserID(c *gin.Context) uuid.UUID {

      
        94
        95
         	userID, exists := c.Get(userIDCtxKey)

      
        95
        96
         	if !exists {

      
M internal/transport/http/apiv1/note.go
···
        8
        8
         

      
        9
        9
         	"github.com/gin-gonic/gin"

      
        10
        10
         	"github.com/olexsmir/onasty/internal/dtos"

      
        11
        
        -	"github.com/olexsmir/onasty/internal/models"

      
        12
        11
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        13
        12
         )

      
        14
        13
         

      ···
        31
        30
         		return

      
        32
        31
         	}

      
        33
        32
         

      
        34
        
        -	note := models.Note{ //nolint:exhaustruct

      
        
        33
        +	// TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at

      
        
        34
        +

      
        
        35
        +	slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{

      
        35
        36
         		Content:              req.Content,

      
        
        37
        +		UserID:               a.getUserID(c),

      
        36
        38
         		Slug:                 req.Slug,

      
        
        39
        +		Password:             req.Password,

      
        37
        40
         		BurnBeforeExpiration: req.BurnBeforeExpiration,

      
        38
        41
         		CreatedAt:            time.Now(),

      
        39
        
        -		Password:             req.Password,

      
        40
        42
         		ExpiresAt:            req.ExpiresAt,

      
        41
        
        -	}

      
        42
        
        -

      
        43
        
        -	if err := note.Validate(); err != nil {

      
        44
        
        -		newErrorStatus(c, http.StatusBadRequest, err.Error())

      
        45
        
        -		return

      
        46
        
        -	}

      
        47
        
        -

      
        48
        
        -	slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{

      
        49
        
        -		Content:              note.Content,

      
        50
        
        -		UserID:               a.getUserID(c),

      
        51
        
        -		Slug:                 note.Slug,

      
        52
        
        -		Password:             note.Password,

      
        53
        
        -		BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        54
        
        -		CreatedAt:            note.CreatedAt,

      
        55
        
        -		ExpiresAt:            note.ExpiresAt,

      
        56
        43
         	}, a.getUserID(c))

      
        57
        44
         	if err != nil {

      
        58
        45
         		errorResponse(c, err)

      ···
        104
        91
         		ExpiresAt: note.ExpiresAt,

      
        105
        92
         	})

      
        106
        93
         }

      
        
        94
        +

      
        
        95
        +type getNotesResponse struct {

      
        
        96
        +	Content              string    `json:"content"`

      
        
        97
        +	Slug                 string    `json:"slug"`

      
        
        98
        +	BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        
        99
        +	HasPassword          bool      `json:"has_password"`

      
        
        100
        +	CreatedAt            time.Time `json:"created_at"`

      
        
        101
        +	ExpiresAt            time.Time `json:"expires_at,omitzero"`

      
        
        102
        +	ReadAt               time.Time `json:"read_at,omitzero"`

      
        
        103
        +}

      
        
        104
        +

      
        
        105
        +func (a *APIV1) getNotesHandler(c *gin.Context) {

      
        
        106
        +	notes, err := a.notesrv.GetAllByAuthorID(c.Request.Context(), a.getUserID(c))

      
        
        107
        +	if err != nil {

      
        
        108
        +		errorResponse(c, err)

      
        
        109
        +		return

      
        
        110
        +	}

      
        
        111
        +

      
        
        112
        +	var response []getNotesResponse

      
        
        113
        +	for _, note := range notes {

      
        
        114
        +		response = append(response, getNotesResponse{

      
        
        115
        +			Content:              note.Content,

      
        
        116
        +			Slug:                 note.Slug,

      
        
        117
        +			BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        
        118
        +			HasPassword:          note.HasPassword,

      
        
        119
        +			CreatedAt:            note.CreatedAt,

      
        
        120
        +			ExpiresAt:            note.ExpiresAt,

      
        
        121
        +			ReadAt:               note.ReadAt,

      
        
        122
        +		})

      
        
        123
        +	}

      
        
        124
        +

      
        
        125
        +	c.JSON(http.StatusOK, response)

      
        
        126
        +}

      
        
        127
        +

      
        
        128
        +type updateNoteRequest struct {

      
        
        129
        +	ExpiresAt            *time.Time `json:"expires_at,omitempty"`

      
        
        130
        +	BurnBeforeExpiration *bool      `json:"burn_before_expiration,omitempty"`

      
        
        131
        +}

      
        
        132
        +

      
        
        133
        +func (a *APIV1) updateNoteHandler(c *gin.Context) {

      
        
        134
        +	var req updateNoteRequest

      
        
        135
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        136
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        137
        +		return

      
        
        138
        +	}

      
        
        139
        +

      
        
        140
        +	// TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at

      
        
        141
        +

      
        
        142
        +	if err := a.notesrv.UpdateExpirationTimeSettings(

      
        
        143
        +		c.Request.Context(),

      
        
        144
        +		dtos.PatchNote{

      
        
        145
        +			BurnBeforeExpiration: req.BurnBeforeExpiration,

      
        
        146
        +			ExpiresAt:            req.ExpiresAt,

      
        
        147
        +		},

      
        
        148
        +		c.Param("slug"),

      
        
        149
        +		a.getUserID(c),

      
        
        150
        +	); err != nil {

      
        
        151
        +		errorResponse(c, err)

      
        
        152
        +		return

      
        
        153
        +	}

      
        
        154
        +

      
        
        155
        +	c.Status(http.StatusOK)

      
        
        156
        +}

      
        
        157
        +

      
        
        158
        +func (a *APIV1) deleteNoteHandler(c *gin.Context) {

      
        
        159
        +	if err := a.notesrv.DeleteBySlug(

      
        
        160
        +		c.Request.Context(),

      
        
        161
        +		c.Param("slug"),

      
        
        162
        +		a.getUserID(c),

      
        
        163
        +	); err != nil {

      
        
        164
        +		errorResponse(c, err)

      
        
        165
        +		return

      
        
        166
        +	}

      
        
        167
        +

      
        
        168
        +	c.Status(http.StatusNoContent)

      
        
        169
        +}

      
        
        170
        +

      
        
        171
        +type setNotePasswordRequest struct {

      
        
        172
        +	Password string `json:"password"`

      
        
        173
        +}

      
        
        174
        +

      
        
        175
        +func (a *APIV1) setNotePasswordHandler(c *gin.Context) {

      
        
        176
        +	var req setNotePasswordRequest

      
        
        177
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        178
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        179
        +		return

      
        
        180
        +	}

      
        
        181
        +

      
        
        182
        +	if err := a.notesrv.UpdatePassword(

      
        
        183
        +		c.Request.Context(),

      
        
        184
        +		c.Param("slug"),

      
        
        185
        +		req.Password,

      
        
        186
        +		a.getUserID(c),

      
        
        187
        +	); err != nil {

      
        
        188
        +		errorResponse(c, err)

      
        
        189
        +		return

      
        
        190
        +	}

      
        
        191
        +

      
        
        192
        +	c.Status(http.StatusOK)

      
        
        193
        +}

      
M internal/transport/http/apiv1/response.go
···
        6
        6
         	"net/http"

      
        7
        7
         

      
        8
        8
         	"github.com/gin-gonic/gin"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/jwtutil"

      
        9
        10
         	"github.com/olexsmir/onasty/internal/models"

      
        
        11
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        10
        12
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        11
        13
         )

      
        12
        14
         

      ···
        27
        29
         		errors.Is(err, models.ErrUserInvalidPassword) ||

      
        28
        30
         		errors.Is(err, models.ErrUserNotFound) ||

      
        29
        31
         		// notes

      
        
        32
        +		errors.Is(err, notesrv.ErrNotePasswordNotProvided) ||

      
        30
        33
         		errors.Is(err, models.ErrNoteContentIsEmpty) ||

      
        31
        34
         		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) {

      
        32
        35
         		newError(c, http.StatusBadRequest, err.Error())

      ···
        45
        48
         	}

      
        46
        49
         

      
        47
        50
         	if errors.Is(err, ErrUnauthorized) ||

      
        
        51
        +		errors.Is(err, jwtutil.ErrTokenExpired) ||

      
        48
        52
         		errors.Is(err, models.ErrUserWrongCredentials) {

      
        49
        53
         		newErrorStatus(c, http.StatusUnauthorized, err.Error())

      
        50
        54
         		return

      
M mailer/service.go
···
        33
        33
         	go func() {

      
        34
        34
         		select {

      
        35
        35
         		case <-ctx.Done():

      
        36
        
        -			slog.ErrorContext(ctx, "failed to send email",

      
        37
        
        -				"template_name", templateName,

      
        38
        
        -				"err", ctx.Err())

      
        39
        36
         			return

      
        40
        37
         		default:

      
        41
        38
         			if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil {

      
M migrations/20240613092407_users.up.sql
···
        1
        1
         CREATE TABLE users (

      
        2
        
        -    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        3
        3
             username varchar(255) NOT NULL UNIQUE,

      
        4
        4
             email varchar(255) NOT NULL UNIQUE,

      
        5
        5
             password varchar(255) NOT NULL,

      
M migrations/20240613092532_sessions.up.sql
···
        1
        1
         CREATE TABLE sessions (

      
        2
        
        -    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        3
        3
             user_id uuid REFERENCES users (id),

      
        4
        4
             refresh_token varchar(255) NOT NULL UNIQUE,

      
        5
        5
             expires_at timestamptz NOT NULL

      
M migrations/20240716235210_notes.up.sql
···
        1
        1
         CREATE TABLE notes (

      
        2
        
        -    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        3
        3
             content text NOT NULL,

      
        4
        4
             slug varchar(255) NOT NULL UNIQUE,

      
        5
        5
             burn_before_expiration boolean DEFAULT FALSE,

      
M migrations/20240724122920_notes_authors.up.sql
···
        1
        1
         CREATE TABLE notes_authors (

      
        2
        
        -    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        3
        3
             note_id uuid REFERENCES notes (id) ON DELETE CASCADE,

      
        4
        4
             user_id uuid REFERENCES users (id) ON DELETE CASCADE,

      
        5
        5
             created_at timestamptz NOT NULL DEFAULT now()

      
M migrations/20240729115827_verification_tokens.up.sql
···
        1
        1
         CREATE TABLE verification_tokens (

      
        2
        
        -    id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        3
        3
             user_id uuid NOT NULL UNIQUE REFERENCES users (id),

      
        4
        4
             token varchar(255) NOT NULL UNIQUE,

      
        5
        5
             created_at timestamptz NOT NULL DEFAULT now(),

      
M migrations/20250520211029_remove_username.down.sql
···
        1
        1
         ALTER TABLE users

      
        2
        
        -     add column username varchar(255) NOT NULL UNIQUE;

      
        
        2
        +    ADD COLUMN username varchar(255) NOT NULL UNIQUE;

      
A migrations/20250530135721_note_author_add_unique_constraint.down.sql
···
        
        1
        +ALTER TABLE notes_authors

      
        
        2
        +    DROP CONSTRAINT notes_authors_pair_user;

      
A migrations/20250530135721_note_author_add_unique_constraint.up.sql
···
        
        1
        +ALTER TABLE notes_authors

      
        
        2
        +    ADD CONSTRAINT notes_authors_pair_user UNIQUE (note_id, user_id)