all repos

onasty @ 9169390bcd74a5d25bca1482e47d926e260c205d

a one-time notes service
20 files changed, 328 insertions(+), 220 deletions(-)
feat: keep metadate on note removal (#96)

* chore: add read property for notes

* fix(config): remove fields that are not used anymore

* chore(docker): speed up build time

* fixup! chore: add read property for notes

* feat(models): add ReadAt property to note

* refactor(noterepo): mark note as read on read

* fix(api): show read time in response when needed

* fix(api): set 404 when note is read

* chore(golangci-lint): migrate to v2

* refactor(noterepo): remove unused method, and some renaming

* fix(api): dont panic if readat is nil

* test(note): check if all removes correctly

* feat(config): add note cache ttl

* fix: mains

* feat: add cache layer for notes

* feat(notesrv): add cache

* fix(notecache): only cache when note is read

* fix(notesrv): log properly

* run ci

* chore(ci): use golangci-lint to v2
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-04-14 01:27:30 +0300
Parent: 4073d2b
M .env.example
···
        25
        25
         

      
        26
        26
         REDIS_ADDR="redis:6379"

      
        27
        27
         CACHE_USERS_TTL=1h

      
        
        28
        +CACHE_NOTE_TTL=1h

      
        28
        29
         

      
        29
        30
         NATS_URL="nats:4222"

      
        30
        31
         

      
M .github/workflows/golang.yml
···
        34
        34
             steps:

      
        35
        35
               - uses: actions/checkout@v4

      
        36
        36
               - name: Golangci Lint

      
        37
        
        -        uses: golangci/golangci-lint-action@v6

      
        
        37
        +        uses: golangci/golangci-lint-action@v7

      
        38
        38
                 with:

      
        39
        39
                   version: latest

      
        40
        40
                   args: ./...

      
M .golangci.yaml
···
        
        1
        +version: "2"

      
        1
        2
         run:

      
        2
        
        -  timeout: 3m

      
        3
        3
           tests: true

      
        4
        4
         

      
        5
        5
         linters:

      
        6
        
        -  # fast: true

      
        7
        
        -  disable-all: true

      
        
        6
        +  default: none

      
        8
        7
           enable:

      
        9
        8
             - errcheck # checking for unchecked errors

      
        10
        
        -    - gosimple # specializes in simplifying a code

      
        11
        9
             - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string

      
        12
        10
             - staticcheck # is a go vet on steroids, applying a ton of static analysis checks

      
        13
        11
             - ineffassign # detects when assignments to existing variables are not used

      
        14
        
        -    - typecheck # like the front-end of a Go compiler, parses and type-checks Go code

      
        15
        12
             - unused # checks for unused constants, variables, functions and types

      
        16
        
        -    ## disabled by default

      
        17
        13
             - asasalint # checks for pass []any as any in variadic func(...any)

      
        18
        14
             - asciicheck # checks that your code does not contain non-ASCII identifiers

      
        19
        15
             - bidichk # checks for dangerous unicode character sequences

      ···
        32
        28
             - goconst # finds repeated strings that could be replaced by a constant

      
        33
        29
             - gocritic # provides diagnostics that check for bugs, performance and style issues

      
        34
        30
             - gocyclo # computes and checks the cyclomatic complexity of functions

      
        35
        
        -    - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt

      
        36
        
        -    - gofumpt

      
        37
        31
             - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod

      
        38
        32
             - goprintffuncname # checks that printf-like functions are named with f at the end

      
        39
        33
             - gosec # inspects source code for security problems

      ···
        59
        53
             - rowserrcheck # checks whether Err of rows is checked successfully

      
        60
        54
             - sloglint # ensure consistent code style when using log/slog

      
        61
        55
             - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed

      
        62
        
        -    - stylecheck # is a replacement for golint

      
        63
        
        -    - tenv # detects using os.Setenv instead of t.Setenv since Go1.17

      
        64
        56
             - testableexamples # checks if examples are testable (have an expected output)

      
        65
        57
             - testifylint # checks usage of github.com/stretchr/testify

      
        66
        58
             - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes

      ···
        76
        68
             - contextcheck # check whether the function uses a non-inherited context

      
        77
        69
             - ireturn # accept interfaces, return concrete types

      
        78
        70
         

      
        79
        
        -linters-settings:

      
        80
        
        -  cyclop:

      
        81
        
        -    # The maximal code complexity to report.

      
        82
        
        -    # Default: 10

      
        83
        
        -    max-complexity: 30

      
        84
        
        -    # The maximal average package complexity.

      
        85
        
        -    # If it's higher than 0.0 (float) the check is enabled

      
        86
        
        -    # Default: 0.0

      
        87
        
        -    package-average: 10.0

      
        
        71
        +  settings:

      
        
        72
        +    cyclop:

      
        
        73
        +      max-complexity: 30

      
        
        74
        +      package-average: 10

      
        88
        75
         

      
        89
        
        -  errcheck:

      
        90
        
        -    # Report about not checking of errors in type assertions: `a := b.(MyStruct)`.

      
        91
        
        -    # Such cases aren't reported by default.

      
        92
        
        -    # Default: false

      
        93
        
        -    check-type-assertions: true

      
        
        76
        +    errcheck:

      
        
        77
        +      check-type-assertions: true

      
        94
        78
         

      
        95
        
        -  funlen:

      
        96
        
        -    # Checks the number of lines in a function.

      
        97
        
        -    # If lower than 0, disable the check.

      
        98
        
        -    # Default: 60

      
        99
        
        -    lines: 100

      
        100
        
        -    # Checks the number of statements in a function.

      
        101
        
        -    # If lower than 0, disable the check.

      
        102
        
        -    # Default: 40

      
        103
        
        -    statements: 50

      
        104
        
        -    # Ignore comments when counting lines.

      
        105
        
        -    # Default false

      
        106
        
        -    ignore-comments: true

      
        
        79
        +    exhaustruct:

      
        
        80
        +      exclude:

      
        
        81
        +        - log/slog\.HandlerOptions

      
        
        82
        +        - net/http\.Server

      
        
        83
        +        - github.com/golang-jwt/jwt/v5\.RegisteredClaims

      
        107
        84
         

      
        108
        
        -  gocritic:

      
        109
        
        -    # Settings passed to gocritic.

      
        110
        
        -    # The settings key is the name of a supported gocritic checker.

      
        111
        
        -    # The list of supported checkers can be find in https://go-critic.github.io/overview.

      
        112
        
        -    settings:

      
        113
        
        -      captLocal:

      
        114
        
        -        # Whether to restrict checker to params only.

      
        115
        
        -        # Default: true

      
        116
        
        -        paramsOnly: false

      
        117
        
        -      underef:

      
        118
        
        -        # Whether to skip (*x).method() calls where x is a pointer receiver.

      
        119
        
        -        # Default: true

      
        120
        
        -        skipRecvDeref: false

      
        121
        
        -

      
        122
        
        -  govet:

      
        123
        
        -    enable-all: true

      
        124
        
        -    disable:

      
        125
        
        -      - fieldalignment # too strict

      
        
        85
        +    funlen:

      
        
        86
        +      lines: 100

      
        
        87
        +      statements: 50

      
        
        88
        +      ignore-comments: true

      
        126
        89
         

      
        127
        
        -  nakedret:

      
        128
        
        -    # the gods will judge me but I just don't like naked returns at all

      
        129
        
        -    max-func-lines: 0

      
        
        90
        +    gocritic:

      
        
        91
        +      settings:

      
        
        92
        +        captLocal:

      
        
        93
        +          paramsOnly: false

      
        
        94
        +        underef:

      
        
        95
        +          skipRecvDeref: false

      
        130
        96
         

      
        131
        
        -  exhaustruct:

      
        132
        
        -    exclude:

      
        133
        
        -      - 'log/slog\.HandlerOptions'

      
        134
        
        -      - 'net/http\.Server'

      
        
        97
        +    govet:

      
        
        98
        +      disable:

      
        
        99
        +        - fieldalignment

      
        
        100
        +      enable-all: true

      
        135
        101
         

      
        136
        
        -      - 'github.com/golang-jwt/jwt/v5\.RegisteredClaims'

      
        
        102
        +  exclusions:

      
        
        103
        +    generated: lax

      
        
        104
        +    presets:

      
        
        105
        +      - comments

      
        
        106
        +      - common-false-positives

      
        
        107
        +      - legacy

      
        
        108
        +      - std-error-handling

      
        
        109
        +    rules:

      
        
        110
        +      - linters:

      
        
        111
        +          - gocritic

      
        
        112
        +        source: //noinspection

      
        
        113
        +      - linters:

      
        
        114
        +          - bodyclose

      
        
        115
        +          - dupl

      
        
        116
        +          - err113

      
        
        117
        +          - funlen

      
        
        118
        +          - goconst

      
        
        119
        +          - gosec

      
        
        120
        +          - lll

      
        
        121
        +          - noctx

      
        
        122
        +          - wrapcheck

      
        
        123
        +        path: _test\.go

      
        
        124
        +    paths:

      
        
        125
        +      - third_party$

      
        
        126
        +      - builtin$

      
        
        127
        +      - examples$

      
        137
        128
         

      
        138
        129
         issues:

      
        139
        
        -  # Maximum count of issues with the same text.

      
        140
        
        -  # Set to 0 to disable.

      
        141
        
        -  # Default: 3

      
        142
        130
           max-same-issues: 50

      
        143
        131
         

      
        144
        
        -  exclude-rules:

      
        145
        
        -    - source: "//noinspection"

      
        146
        
        -      linters: [ gocritic ]

      
        147
        
        -    - path: "_test\\.go"

      
        148
        
        -      linters:

      
        149
        
        -        - bodyclose

      
        150
        
        -        - dupl

      
        151
        
        -        - funlen

      
        152
        
        -        - goerr113

      
        153
        
        -        - goconst

      
        154
        
        -        - gosec

      
        155
        
        -        - noctx

      
        156
        
        -        - wrapcheck

      
        157
        
        -        - lll

      
        
        132
        +formatters:

      
        
        133
        +  enable:

      
        
        134
        +    - gofumpt

      
        
        135
        +    - goimports

      
        
        136
        +  exclusions:

      
        
        137
        +    generated: lax

      
        
        138
        +    paths:

      
        
        139
        +      - third_party$

      
        
        140
        +      - builtin$

      
        
        141
        +      - examples$

      
M Dockerfile
···
        9
        9
         COPY internal internal

      
        10
        10
         

      
        11
        11
         ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64

      
        12
        
        -RUN go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server

      
        
        12
        +RUN --mount=type=cache,target=/root/.cache/go-build \

      
        
        13
        +    --mount=type=cache,target=/go/pkg/mod \

      
        
        14
        +    go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server

      
        13
        15
         

      
        14
        16
         FROM alpine:3.21

      
        15
        17
         COPY --from=builder /onasty /onasty

      
M Taskfile.yml
···
        6
        6
         includes:

      
        7
        7
           migrate: ./migrations/Taskfile.yml

      
        8
        8
         

      
        
        9
        +env:

      
        
        10
        +  DOCKER_BUILDKIT: 1

      
        
        11
        +

      
        9
        12
         tasks:

      
        10
        13
           run:

      
        11
        14
             - docker compose up -d --build --remove-orphans core mailer

      
M cmd/server/main.go
···
        25
        25
         	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      
        26
        26
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        27
        27
         	"github.com/olexsmir/onasty/internal/store/rdb"

      
        
        28
        +	"github.com/olexsmir/onasty/internal/store/rdb/notecache"

      
        28
        29
         	"github.com/olexsmir/onasty/internal/store/rdb/usercache"

      
        29
        30
         	httptransport "github.com/olexsmir/onasty/internal/transport/http"

      
        30
        31
         	"github.com/olexsmir/onasty/internal/transport/http/httpserver"

      ···
        98
        99
         		cfg.AppURL,

      
        99
        100
         	)

      
        100
        101
         

      
        
        102
        +	notecache := notecache.New(redisDB, cfg.CacheNoteTTL)

      
        101
        103
         	noterepo := noterepo.New(psqlDB)

      
        102
        
        -	notesrv := notesrv.New(noterepo, notePasswordHasher)

      
        
        104
        +	notesrv := notesrv.New(noterepo, notePasswordHasher, notecache)

      
        103
        105
         

      
        104
        106
         	rateLimiterConfig := ratelimit.Config{

      
        105
        107
         		RPS:   cfg.RateLimiterRPS,

      
M e2e/apiv1_notes_test.go
···
        106
        106
         }

      
        107
        107
         

      
        108
        108
         type apiv1NoteGetResponse struct {

      
        109
        
        -	Content   string    `json:"content"`

      
        110
        
        -	CreatedAt time.Time `json:"created_at"`

      
        111
        
        -	ExpiresAt time.Time `json:"expires_at"`

      
        
        109
        +	Content   string     `json:"content"`

      
        
        110
        +	ReadAt    *time.Time `json:"read_at"`

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

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

      
        112
        113
         }

      
        113
        114
         

      
        114
        115
         func (e *AppTestSuite) TestNoteV1_Get() {

      ···
        134
        135
         	e.Equal(content, body.Content)

      
        135
        136
         

      
        136
        137
         	dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug)

      
        137
        
        -	e.Empty(dbNote)

      
        
        138
        +	e.Equal(dbNote.Content, "")

      
        
        139
        +	e.Equal(dbNote.ReadAt.IsZero(), false)

      
        138
        140
         }

      
        139
        141
         

      
        140
        142
         type apiv1NoteGetRequest struct {

      ···
        157
        159
         	var bodyCreated apiv1NoteCreateResponse

      
        158
        160
         	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        159
        161
         

      
        160
        
        -	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{

      
        161
        
        -		Password: passwd,

      
        162
        
        -	}))

      
        
        162
        +	httpResp = e.httpRequest(

      
        
        163
        +		http.MethodGet,

      
        
        164
        +		"/api/v1/note/"+bodyCreated.Slug,

      
        
        165
        +		e.jsonify(apiv1NoteGetRequest{

      
        
        166
        +			Password: passwd,

      
        
        167
        +		}),

      
        
        168
        +	)

      
        163
        169
         	e.Equal(httpResp.Code, http.StatusOK)

      
        164
        170
         

      
        165
        171
         	var body apiv1NoteGetResponse

      ···
        168
        174
         	e.Equal(content, body.Content)

      
        169
        175
         

      
        170
        176
         	dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug)

      
        171
        
        -	e.Empty(dbNote)

      
        
        177
        +	e.Equal(dbNote.Content, "")

      
        
        178
        +	e.Equal(dbNote.ReadAt.IsZero(), false)

      
        172
        179
         }

      
        173
        180
         

      
        174
        181
         func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrongNoPassword() {

      ···
        206
        213
         	var bodyCreated apiv1NoteCreateResponse

      
        207
        214
         	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        208
        215
         

      
        209
        
        -	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{

      
        210
        
        -		Password: e.uuid(),

      
        211
        
        -	}))

      
        
        216
        +	httpResp = e.httpRequest(

      
        
        217
        +		http.MethodGet,

      
        
        218
        +		"/api/v1/note/"+bodyCreated.Slug,

      
        
        219
        +		e.jsonify(apiv1NoteGetRequest{

      
        
        220
        +			Password: e.uuid(),

      
        
        221
        +		}),

      
        
        222
        +	)

      
        212
        223
         	e.Equal(httpResp.Code, http.StatusNotFound)

      
        213
        224
         }

      
M e2e/e2e_test.go
···
        25
        25
         	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      
        26
        26
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        27
        27
         	"github.com/olexsmir/onasty/internal/store/rdb"

      
        
        28
        +	"github.com/olexsmir/onasty/internal/store/rdb/notecache"

      
        28
        29
         	"github.com/olexsmir/onasty/internal/store/rdb/usercache"

      
        29
        30
         	httptransport "github.com/olexsmir/onasty/internal/transport/http"

      
        30
        31
         	"github.com/olexsmir/onasty/internal/transport/http/ratelimit"

      ···
        119
        120
         		cfg.AppURL,

      
        120
        121
         	)

      
        121
        122
         

      
        
        123
        +	notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL)

      
        122
        124
         	noterepo := noterepo.New(e.postgresDB)

      
        123
        
        -	notesrv := notesrv.New(noterepo, e.hasher)

      
        
        125
        +	notesrv := notesrv.New(noterepo, e.hasher, notecache)

      
        124
        126
         

      
        125
        127
         	// for testing purposes, it's ok to have high values ig

      
        126
        128
         	ratelimitCfg := ratelimit.Config{

      
M e2e/e2e_utils_db_test.go
···
        91
        91
         	return u

      
        92
        92
         }

      
        93
        93
         

      
        94
        
        -func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note {

      
        
        94
        +type noteModel struct {

      
        
        95
        +	ID                   uuid.UUID

      
        
        96
        +	Content              string

      
        
        97
        +	Slug                 string

      
        
        98
        +	BurnBeforeExpiration bool

      
        
        99
        +	Password             string

      
        
        100
        +	IsRead               bool

      
        
        101
        +	ReadAt               *time.Time

      
        
        102
        +	CreatedAt            time.Time

      
        
        103
        +	ExpiresAt            time.Time

      
        
        104
        +}

      
        
        105
        +

      
        
        106
        +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) noteModel {

      
        95
        107
         	query, args, err := pgq.

      
        96
        
        -		Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        108
        +		Select(

      
        
        109
        +			"id",

      
        
        110
        +			"content",

      
        
        111
        +			"slug",

      
        
        112
        +			"burn_before_expiration",

      
        
        113
        +			"read_at",

      
        
        114
        +			"created_at",

      
        
        115
        +			"expires_at",

      
        
        116
        +		).

      
        97
        117
         		From("notes").

      
        98
        118
         		Where(pgq.Eq{"slug": slug}).

      
        99
        119
         		SQL()

      
        100
        120
         	e.require.NoError(err)

      
        101
        121
         

      
        102
        
        -	var note models.Note

      
        
        122
        +	var note noteModel

      
        103
        123
         	err = e.postgresDB.QueryRow(e.ctx, query, args...).

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

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

      
        105
        125
         	if errors.Is(err, pgx.ErrNoRows) {

      
        106
        
        -		return models.Note{} //nolint:exhaustruct

      
        
        126
        +		return noteModel{} //nolint:exhaustruct

      
        107
        127
         	}

      
        108
        128
         

      
        109
        129
         	e.require.NoError(err)

      
M internal/config/config.go
···
        22
        22
         	RedisDB       int

      
        23
        23
         

      
        24
        24
         	CacheUsersTTL time.Duration

      
        
        25
        +	CacheNoteTTL  time.Duration

      
        25
        26
         

      
        26
        27
         	JwtSigningKey      string

      
        27
        28
         	JwtAccessTokenTTL  time.Duration

      
        28
        29
         	JwtRefreshTokenTTL time.Duration

      
        29
        30
         

      
        30
        
        -	MailgunFrom          string

      
        31
        
        -	MailgunDomain        string

      
        32
        
        -	MailgunAPIKey        string

      
        33
        31
         	VerificationTokenTTL time.Duration

      
        34
        32
         

      
        35
        33
         	MetricsEnabled bool

      ···
        60
        58
         		RedisDB:       mustGetenvOrDefaultInt(getenvOrDefault("REDIS_DB", "0"), 0),

      
        61
        59
         

      
        62
        60
         		CacheUsersTTL: mustParseDuration(getenvOrDefault("CACHE_USERS_TTL", "1h")),

      
        
        61
        +		CacheNoteTTL:  mustParseDuration(getenvOrDefault("CACHE_NOTE_TTL", "1h")),

      
        63
        62
         

      
        64
        63
         		JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""),

      
        65
        64
         		JwtAccessTokenTTL: mustParseDuration(

      ···
        69
        68
         			getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "24h"),

      
        70
        69
         		),

      
        71
        70
         

      
        72
        
        -		MailgunFrom:   getenvOrDefault("MAILGUN_FROM", ""),

      
        73
        
        -		MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""),

      
        74
        
        -		MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""),

      
        75
        71
         		VerificationTokenTTL: mustParseDuration(

      
        76
        72
         			getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"),

      
        77
        73
         		),

      
M internal/dtos/note.go
···
        13
        13
         	Slug                 string

      
        14
        14
         	BurnBeforeExpiration bool

      
        15
        15
         	Password             string

      
        
        16
        +	IsRead               bool

      
        
        17
        +	ReadAt               *time.Time

      
        16
        18
         	CreatedAt            time.Time

      
        17
        19
         	ExpiresAt            time.Time

      
        18
        20
         }

      
M internal/models/notes.go
···
        20
        20
         	Slug                 string

      
        21
        21
         	Password             string

      
        22
        22
         	BurnBeforeExpiration bool

      
        
        23
        +	ReadAt               time.Time

      
        23
        24
         	CreatedAt            time.Time

      
        24
        25
         	ExpiresAt            time.Time

      
        25
        26
         }

      ···
        45
        46
         	return !n.ExpiresAt.IsZero() &&

      
        46
        47
         		n.BurnBeforeExpiration

      
        47
        48
         }

      
        
        49
        +

      
        
        50
        +func (n Note) IsRead() bool {

      
        
        51
        +	return !n.ReadAt.IsZero()

      
        
        52
        +}

      
M internal/models/notes_test.go
···
        8
        8
         )

      
        9
        9
         

      
        10
        10
         func TestNote_Validate(t *testing.T) {

      
        11
        
        -	tests := []struct {

      
        12
        
        -		name      string

      
        13
        
        -		note      Note

      
        14
        
        -		willError bool

      
        15
        
        -		error     error

      
        16
        
        -	}{

      
        17
        
        -		// NOTE: there no need to test if note is expired since it tested in IsExpired test

      
        18
        
        -		{

      
        19
        
        -			name: "ok",

      
        20
        
        -			note: Note{ //nolint:exhaustruct

      
        21
        
        -				Content:   "some wired ass content",

      
        22
        
        -				ExpiresAt: time.Now().Add(time.Hour),

      
        23
        
        -			},

      
        24
        
        -			willError: false,

      
        25
        
        -			error:     nil,

      
        26
        
        -		},

      
        27
        
        -		{

      
        28
        
        -			name:      "content missing",

      
        29
        
        -			note:      Note{Content: ""}, //nolint:exhaustruct

      
        30
        
        -			willError: true,

      
        31
        
        -			error:     ErrNoteContentIsEmpty,

      
        32
        
        -		},

      
        33
        
        -	}

      
        
        11
        +	// NOTE: there no need to test if note is expired since it tested in IsExpired test

      
        34
        12
         

      
        35
        
        -	for _, tt := range tests {

      
        36
        
        -		t.Run(tt.name, func(t *testing.T) {

      
        37
        
        -			err := tt.note.Validate()

      
        38
        
        -			if tt.willError {

      
        39
        
        -				assert.EqualError(t, err, tt.error.Error())

      
        40
        
        -			} else {

      
        41
        
        -				assert.NoError(t, err)

      
        42
        
        -			}

      
        43
        
        -		})

      
        44
        
        -	}

      
        
        13
        +	t.Run("should pass the validation only if content provided", func(t *testing.T) {

      
        
        14
        +		n := Note{Content: "the content"} //nolint:exhaustruct

      
        
        15
        +		assert.NoError(t, n.Validate())

      
        
        16
        +	})

      
        
        17
        +	t.Run("should pass validation with content and correct expiration time", func(t *testing.T) {

      
        
        18
        +		n := Note{ //nolint:exhaustruct

      
        
        19
        +			Content:   "content",

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

      
        
        21
        +		}

      
        
        22
        +		assert.NoError(t, n.Validate())

      
        
        23
        +	})

      
        
        24
        +	t.Run("should fail if content is missing", func(t *testing.T) {

      
        
        25
        +		n := Note{Content: ""} //nolint:exhaustruct

      
        
        26
        +		assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error())

      
        
        27
        +	})

      
        
        28
        +	t.Run("should fail if content is missing and other fields are set", func(t *testing.T) {

      
        
        29
        +		n := Note{ //nolint:exhaustruct

      
        
        30
        +			Slug:                 "some-slug",

      
        
        31
        +			Password:             "some-password",

      
        
        32
        +			BurnBeforeExpiration: false,

      
        
        33
        +		}

      
        
        34
        +		assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error())

      
        
        35
        +	})

      
        
        36
        +	t.Run("should fail if expiration time is in the past", func(t *testing.T) {

      
        
        37
        +		n := Note{Content: "content", ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct

      
        
        38
        +		assert.EqualError(t, n.Validate(), ErrNoteExpired.Error())

      
        
        39
        +	})

      
        45
        40
         }

      
        46
        41
         

      
        47
        42
         func TestNote_IsExpired(t *testing.T) {

      
        48
        
        -	tests := []struct {

      
        49
        
        -		name     string

      
        50
        
        -		note     Note

      
        51
        
        -		expected bool

      
        52
        
        -	}{

      
        53
        
        -		{

      
        54
        
        -			name:     "expired",

      
        55
        
        -			note:     Note{ExpiresAt: time.Now().Add(-time.Hour)}, //nolint:exhaustruct

      
        56
        
        -			expected: true,

      
        57
        
        -		},

      
        58
        
        -		{

      
        59
        
        -			name:     "not expired",

      
        60
        
        -			note:     Note{ExpiresAt: time.Now().Add(time.Hour)}, //nolint:exhaustruct

      
        61
        
        -			expected: false,

      
        62
        
        -		},

      
        63
        
        -		{

      
        64
        
        -			name:     "zero expiration",

      
        65
        
        -			note:     Note{ExpiresAt: time.Time{}}, //nolint:exhaustruct

      
        66
        
        -			expected: false,

      
        67
        
        -		},

      
        68
        
        -	}

      
        69
        
        -

      
        70
        
        -	for _, tt := range tests {

      
        71
        
        -		t.Run(tt.name, func(t *testing.T) {

      
        72
        
        -			assert.Equal(t, tt.expected, tt.note.IsExpired())

      
        73
        
        -		})

      
        74
        
        -	}

      
        
        43
        +	t.Run("should be expired", func(t *testing.T) {

      
        
        44
        +		note := Note{ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct

      
        
        45
        +		assert.True(t, note.IsExpired())

      
        
        46
        +	})

      
        
        47
        +	t.Run("should be not expired", func(t *testing.T) {

      
        
        48
        +		note := Note{ExpiresAt: time.Now().Add(time.Hour)} //nolint:exhaustruct

      
        
        49
        +		assert.False(t, note.IsExpired())

      
        
        50
        +	})

      
        
        51
        +	t.Run("should be not expired when [ExpiredAt] is zero", func(t *testing.T) {

      
        
        52
        +		note := Note{ExpiresAt: time.Time{}} //nolint:exhaustruct

      
        
        53
        +		assert.False(t, note.IsExpired())

      
        
        54
        +	})

      
        75
        55
         }

      
        76
        56
         

      
        77
        57
         func TestNote_ShouldBeBurnt(t *testing.T) {

      
        78
        
        -	tests := []struct {

      
        79
        
        -		name     string

      
        80
        
        -		note     Note

      
        81
        
        -		expected bool

      
        82
        
        -	}{

      
        83
        
        -		{

      
        84
        
        -			name: "should be burnt",

      
        85
        
        -			note: Note{ //nolint:exhaustruct

      
        86
        
        -				BurnBeforeExpiration: true,

      
        87
        
        -				ExpiresAt:            time.Now().Add(time.Hour),

      
        88
        
        -			},

      
        89
        
        -			expected: true,

      
        90
        
        -		},

      
        91
        
        -		{

      
        92
        
        -			name: "could not be burnt, no expiration time",

      
        93
        
        -			note: Note{ //nolint:exhaustruct

      
        94
        
        -				BurnBeforeExpiration: true,

      
        95
        
        -				ExpiresAt:            time.Time{},

      
        96
        
        -			},

      
        97
        
        -			expected: false,

      
        98
        
        -		},

      
        99
        
        -		{

      
        100
        
        -			name: "could not be burnt, burn when expiration and burn is false",

      
        101
        
        -			note: Note{ //nolint:exhaustruct

      
        102
        
        -				BurnBeforeExpiration: false,

      
        103
        
        -				ExpiresAt:            time.Time{},

      
        104
        
        -			},

      
        105
        
        -			expected: false,

      
        106
        
        -		},

      
        107
        
        -	}

      
        
        58
        +	t.Run("should be burnt", func(t *testing.T) {

      
        
        59
        +		note := Note{ //nolint:exhaustruct

      
        
        60
        +			BurnBeforeExpiration: true,

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

      
        
        62
        +		}

      
        
        63
        +		assert.True(t, note.ShouldBeBurnt())

      
        
        64
        +	})

      
        
        65
        +	t.Run("should not be burnt", func(t *testing.T) {

      
        
        66
        +		note := Note{ //nolint:exhaustruct

      
        
        67
        +			BurnBeforeExpiration: true,

      
        
        68
        +			ExpiresAt:            time.Time{},

      
        
        69
        +		}

      
        
        70
        +		assert.False(t, note.ShouldBeBurnt())

      
        
        71
        +	})

      
        
        72
        +	t.Run("could not be burnt when expiration and shouldBurn set to false", func(t *testing.T) {

      
        
        73
        +		note := Note{ //nolint:exhaustruct

      
        
        74
        +			BurnBeforeExpiration: false,

      
        
        75
        +			ExpiresAt:            time.Time{},

      
        
        76
        +		}

      
        
        77
        +		assert.False(t, note.ShouldBeBurnt())

      
        
        78
        +	})

      
        
        79
        +}

      
        108
        80
         

      
        109
        
        -	for _, tt := range tests {

      
        110
        
        -		t.Run(tt.name, func(t *testing.T) {

      
        111
        
        -			assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt())

      
        112
        
        -		})

      
        113
        
        -	}

      
        
        81
        +func TestNote_IsRead(t *testing.T) {

      
        
        82
        +	t.Run("should be unread", func(t *testing.T) {

      
        
        83
        +		n := Note{ReadAt: time.Time{}} //nolint:exhaustruct

      
        
        84
        +		assert.False(t, n.IsRead())

      
        
        85
        +	})

      
        
        86
        +	t.Run("should be read", func(t *testing.T) {

      
        
        87
        +		n := Note{ReadAt: time.Now()} //nolint:exhaustruct

      
        
        88
        +		assert.True(t, n.IsRead())

      
        
        89
        +	})

      
        114
        90
         }

      
M internal/service/notesrv/notesrv.go
···
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        5
         	"log/slog"

      
        
        6
        +	"time"

      
        6
        7
         

      
        7
        8
         	"github.com/gofrs/uuid/v5"

      
        8
        9
         	"github.com/olexsmir/onasty/internal/dtos"

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

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

      
        11
        12
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

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

      
        12
        14
         )

      
        13
        15
         

      
        14
        16
         type NoteServicer interface {

      ···
        26
        28
         type NoteSrv struct {

      
        27
        29
         	noterepo noterepo.NoteStorer

      
        28
        30
         	hasher   hasher.Hasher

      
        
        31
        +	cache    notecache.NoteCacher

      
        29
        32
         }

      
        30
        33
         

      
        31
        
        -func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher) *NoteSrv {

      
        
        34
        +func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher, cache notecache.NoteCacher) *NoteSrv {

      
        32
        35
         	return &NoteSrv{

      
        33
        36
         		noterepo: noterepo,

      
        34
        37
         		hasher:   hasher,

      
        
        38
        +		cache:    cache,

      
        35
        39
         	}

      
        36
        40
         }

      
        37
        41
         

      ···
        71
        75
         	ctx context.Context,

      
        72
        76
         	inp GetNoteBySlugInput,

      
        73
        77
         ) (dtos.NoteDTO, error) {

      
        74
        
        -	note, err := n.getNoteFromDBasedOnInput(ctx, inp)

      
        
        78
        +	note, err := n.getNote(ctx, inp)

      
        75
        79
         	if err != nil {

      
        76
        80
         		return dtos.NoteDTO{}, err

      
        77
        81
         	}

      ···
        91
        95
         		return note, nil

      
        92
        96
         	}

      
        93
        97
         

      
        94
        
        -	// TODO: in future not remove, leave some metadata

      
        95
        
        -	// to shot user that note was already seen

      
        96
        
        -	return note, n.noterepo.DeleteBySlug(ctx, note.Slug)

      
        
        98
        +	return note, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now())

      
        
        99
        +}

      
        
        100
        +

      
        
        101
        +func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (dtos.NoteDTO, error) {

      
        
        102
        +	if r, err := n.cache.GetNote(ctx, inp.Slug); err == nil {

      
        
        103
        +		return r, nil

      
        
        104
        +	}

      
        
        105
        +

      
        
        106
        +	note, err := n.getNoteFromDBasedOnInput(ctx, inp)

      
        
        107
        +	if err != nil {

      
        
        108
        +		return dtos.NoteDTO{}, err

      
        
        109
        +	}

      
        
        110
        +

      
        
        111
        +	if note.ReadAt != nil && !note.ReadAt.IsZero() {

      
        
        112
        +		if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil {

      
        
        113
        +			slog.ErrorContext(ctx, "notecache", "err", err)

      
        
        114
        +		}

      
        
        115
        +	}

      
        
        116
        +

      
        
        117
        +	return note, err

      
        97
        118
         }

      
        98
        119
         

      
        99
        120
         func (n *NoteSrv) getNoteFromDBasedOnInput(

      
M internal/store/psql/noterepo/noterepo.go
···
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        5
         	"errors"

      
        
        6
        +	"time"

      
        6
        7
         

      
        7
        8
         	"github.com/gofrs/uuid/v5"

      
        8
        9
         	"github.com/henvic/pgq"

      ···
        30
        31
         		password string,

      
        31
        32
         	) (dtos.NoteDTO, error)

      
        32
        33
         

      
        33
        
        -	// DeleteBySlug deletes note by slug or returns [models.ErrNoteNotFound] if note if not found.

      
        34
        
        -	DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error

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

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

      
        
        36
        +	RemoveBySlug(ctx context.Context, slug dtos.NoteSlugDTO, readAt time.Time) error

      
        35
        37
         

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

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

      ···
        68
        70
         

      
        69
        71
         func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) {

      
        70
        72
         	query, args, err := pgq.

      
        71
        
        -		Select("content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        73
        +		Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at").

      
        72
        74
         		From("notes").

      
        73
        75
         		Where("(password is null or password = '')").

      
        74
        76
         		Where(pgq.Eq{"slug": slug}).

      ···
        79
        81
         

      
        80
        82
         	var note dtos.NoteDTO

      
        81
        83
         	err = s.db.QueryRow(ctx, query, args...).

      
        82
        
        -		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt)

      
        
        84
        +		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)

      
        83
        85
         

      
        84
        86
         	if errors.Is(err, pgx.ErrNoRows) {

      
        85
        87
         		return dtos.NoteDTO{}, models.ErrNoteNotFound

      ···
        94
        96
         	passwd string,

      
        95
        97
         ) (dtos.NoteDTO, error) {

      
        96
        98
         	query, args, err := pgq.

      
        97
        
        -		Select("content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        99
        +		Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at").

      
        98
        100
         		From("notes").

      
        99
        101
         		Where(pgq.Eq{

      
        100
        102
         			"slug":     slug,

      ···
        107
        109
         

      
        108
        110
         	var note dtos.NoteDTO

      
        109
        111
         	err = s.db.QueryRow(ctx, query, args...).

      
        110
        
        -		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt)

      
        
        112
        +		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt)

      
        111
        113
         

      
        112
        114
         	if errors.Is(err, pgx.ErrNoRows) {

      
        113
        115
         		return dtos.NoteDTO{}, models.ErrNoteNotFound

      ···
        116
        118
         	return note, err

      
        117
        119
         }

      
        118
        120
         

      
        119
        
        -func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error {

      
        
        121
        +func (s *NoteRepo) RemoveBySlug(

      
        
        122
        +	ctx context.Context,

      
        
        123
        +	slug dtos.NoteSlugDTO,

      
        
        124
        +	readAt time.Time,

      
        
        125
        +) error {

      
        120
        126
         	query, args, err := pgq.

      
        121
        
        -		Delete("notes").

      
        122
        
        -		Where(pgq.Eq{"slug": slug}).

      
        
        127
        +		Update("notes").

      
        
        128
        +		Set("content", "").

      
        
        129
        +		Set("read_at", readAt).

      
        
        130
        +		Where(pgq.Eq{

      
        
        131
        +			"slug":    slug,

      
        
        132
        +			"read_at": nil,

      
        
        133
        +		}).

      
        123
        134
         		SQL()

      
        124
        135
         	if err != nil {

      
        125
        136
         		return err

      
A internal/store/rdb/notecache/notecache.go
···
        
        1
        +package notecache

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"context"

      
        
        6
        +	"encoding/gob"

      
        
        7
        +	"strings"

      
        
        8
        +	"time"

      
        
        9
        +

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

      
        
        11
        +	"github.com/olexsmir/onasty/internal/store/rdb"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +type NoteCacher interface {

      
        
        15
        +	SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error

      
        
        16
        +	GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error)

      
        
        17
        +}

      
        
        18
        +

      
        
        19
        +type NoteCache struct {

      
        
        20
        +	rdb *rdb.DB

      
        
        21
        +	ttl time.Duration

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +func New(rdb *rdb.DB, ttl time.Duration) *NoteCache {

      
        
        25
        +	return &NoteCache{

      
        
        26
        +		rdb: rdb,

      
        
        27
        +		ttl: ttl,

      
        
        28
        +	}

      
        
        29
        +}

      
        
        30
        +

      
        
        31
        +func (n *NoteCache) SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error {

      
        
        32
        +	var buf bytes.Buffer

      
        
        33
        +	if err := gob.NewEncoder(&buf).Encode(note); err != nil {

      
        
        34
        +		return err

      
        
        35
        +	}

      
        
        36
        +

      
        
        37
        +	_, err := n.rdb.Set(ctx, getKey(slug), buf.Bytes(), n.ttl).Result()

      
        
        38
        +	return err

      
        
        39
        +}

      
        
        40
        +

      
        
        41
        +func (n *NoteCache) GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) {

      
        
        42
        +	val, err := n.rdb.Get(ctx, getKey(slug)).Bytes()

      
        
        43
        +	if err != nil {

      
        
        44
        +		return dtos.NoteDTO{}, err

      
        
        45
        +	}

      
        
        46
        +

      
        
        47
        +	var note dtos.NoteDTO

      
        
        48
        +	if err = gob.NewDecoder(bytes.NewReader(val)).Decode(&note); err != nil {

      
        
        49
        +		return dtos.NoteDTO{}, err

      
        
        50
        +	}

      
        
        51
        +

      
        
        52
        +	return note, err

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +func getKey(slug string) string {

      
        
        56
        +	var sb strings.Builder

      
        
        57
        +	sb.WriteString("note:")

      
        
        58
        +	sb.WriteString(slug)

      
        
        59
        +	return sb.String()

      
        
        60
        +}

      
M internal/transport/http/apiv1/note.go
···
        67
        67
         }

      
        68
        68
         

      
        69
        69
         type getNoteBySlugResponse struct {

      
        70
        
        -	Content   string    `json:"content"`

      
        71
        
        -	CratedAt  time.Time `json:"crated_at"`

      
        72
        
        -	ExpiresAt time.Time `json:"expires_at"`

      
        
        70
        +	Content   string     `json:"content,omitempty"`

      
        
        71
        +	ReadAt    *time.Time `json:"read_at,omitempty"`

      
        
        72
        +	CratedAt  time.Time  `json:"crated_at"`

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

      
        73
        74
         }

      
        74
        75
         

      
        75
        76
         func (a *APIV1) getNoteBySlugHandler(c *gin.Context) {

      ···
        92
        93
         		return

      
        93
        94
         	}

      
        94
        95
         

      
        95
        
        -	c.JSON(http.StatusOK, getNoteBySlugResponse{

      
        
        96
        +	status := http.StatusOK

      
        
        97
        +	if note.ReadAt != nil && !note.ReadAt.IsZero() {

      
        
        98
        +		status = http.StatusNotFound

      
        
        99
        +	}

      
        
        100
        +

      
        
        101
        +	c.JSON(status, getNoteBySlugResponse{

      
        96
        102
         		Content:   note.Content,

      
        
        103
        +		ReadAt:    note.ReadAt,

      
        97
        104
         		CratedAt:  note.CreatedAt,

      
        98
        105
         		ExpiresAt: note.ExpiresAt,

      
        99
        106
         	})

      
M mailer/Dockerfile
···
        9
        9
         COPY mailer mailer

      
        10
        10
         

      
        11
        11
         ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64

      
        12
        
        -RUN go build -trimpath -ldflags='-w -s' -o /mailer ./mailer

      
        13
        
        -

      
        
        12
        +RUN --mount=type=cache,target=/root/.cache/go-build \

      
        
        13
        +    --mount=type=cache,target=/go/pkg/mod \

      
        
        14
        +    go build -trimpath -ldflags='-w -s' -o /mailer ./mailer

      
        14
        15
         

      
        15
        16
         FROM alpine:3.21

      
        16
        17
         COPY --from=builder /mailer /mailer

      
A migrations/20250401121105_notes_add_read.down.sql
···
        
        1
        +ALTER TABLE notes

      
        
        2
        +    DROP COLUMN read_at;

      
A migrations/20250401121105_notes_add_read.up.sql
···
        
        1
        +ALTER TABLE notes

      
        
        2
        +    ADD COLUMN "read_at" timestamptz;