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,6 +25,7 @@ MIGRATION_DSN="postgres://$POSTGRES_USERNAME:$POSTGRES_PASSWORD@localhost:$POSTGRES_PORT/$POSTGRES_DATABASE?sslmode=disable"

REDIS_ADDR="redis:6379" CACHE_USERS_TTL=1h +CACHE_NOTE_TTL=1h NATS_URL="nats:4222"
M .github/workflows/golang.yml

@@ -34,7 +34,7 @@ runs-on: ubuntu-latest

steps: - uses: actions/checkout@v4 - name: Golangci Lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: version: latest args: ./...
M .golangci.yaml

@@ -1,19 +1,15 @@

+version: "2" run: - timeout: 3m tests: true linters: - # fast: true - disable-all: true + default: none enable: - errcheck # checking for unchecked errors - - gosimple # specializes in simplifying a code - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - ineffassign # detects when assignments to existing variables are not used - - typecheck # like the front-end of a Go compiler, parses and type-checks Go code - unused # checks for unused constants, variables, functions and types - ## disabled by default - asasalint # checks for pass []any as any in variadic func(...any) - asciicheck # checks that your code does not contain non-ASCII identifiers - bidichk # checks for dangerous unicode character sequences

@@ -32,8 +28,6 @@ - gochecksumtype # checks exhaustiveness on Go "sum types"

- goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues - gocyclo # computes and checks the cyclomatic complexity of functions - - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - - gofumpt - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems

@@ -59,8 +53,6 @@ - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint

- rowserrcheck # checks whether Err of rows is checked successfully - sloglint # ensure consistent code style when using log/slog - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - - stylecheck # is a replacement for golint - - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 - testableexamples # checks if examples are testable (have an expected output) - testifylint # checks usage of github.com/stretchr/testify - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes

@@ -76,82 +68,74 @@ - err113 # forbids usage of dynamic errors

- contextcheck # check whether the function uses a non-inherited context - ireturn # accept interfaces, return concrete types -linters-settings: - cyclop: - # The maximal code complexity to report. - # Default: 10 - max-complexity: 30 - # The maximal average package complexity. - # If it's higher than 0.0 (float) the check is enabled - # Default: 0.0 - package-average: 10.0 + settings: + cyclop: + max-complexity: 30 + package-average: 10 - errcheck: - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. - # Such cases aren't reported by default. - # Default: false - check-type-assertions: true + errcheck: + check-type-assertions: true - funlen: - # Checks the number of lines in a function. - # If lower than 0, disable the check. - # Default: 60 - lines: 100 - # Checks the number of statements in a function. - # If lower than 0, disable the check. - # Default: 40 - statements: 50 - # Ignore comments when counting lines. - # Default false - ignore-comments: true + exhaustruct: + exclude: + - log/slog\.HandlerOptions + - net/http\.Server + - github.com/golang-jwt/jwt/v5\.RegisteredClaims - gocritic: - # Settings passed to gocritic. - # The settings key is the name of a supported gocritic checker. - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - captLocal: - # Whether to restrict checker to params only. - # Default: true - paramsOnly: false - underef: - # Whether to skip (*x).method() calls where x is a pointer receiver. - # Default: true - skipRecvDeref: false - - govet: - enable-all: true - disable: - - fieldalignment # too strict + funlen: + lines: 100 + statements: 50 + ignore-comments: true - nakedret: - # the gods will judge me but I just don't like naked returns at all - max-func-lines: 0 + gocritic: + settings: + captLocal: + paramsOnly: false + underef: + skipRecvDeref: false - exhaustruct: - exclude: - - 'log/slog\.HandlerOptions' - - 'net/http\.Server' + govet: + disable: + - fieldalignment + enable-all: true - - 'github.com/golang-jwt/jwt/v5\.RegisteredClaims' + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - gocritic + source: //noinspection + - linters: + - bodyclose + - dupl + - err113 + - funlen + - goconst + - gosec + - lll + - noctx + - wrapcheck + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ issues: - # Maximum count of issues with the same text. - # Set to 0 to disable. - # Default: 3 max-same-issues: 50 - exclude-rules: - - source: "//noinspection" - linters: [ gocritic ] - - path: "_test\\.go" - linters: - - bodyclose - - dupl - - funlen - - goerr113 - - goconst - - gosec - - noctx - - wrapcheck - - lll +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$
M Dockerfile

@@ -9,7 +9,9 @@ COPY cmd cmd

COPY internal internal ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 -RUN go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server FROM alpine:3.21 COPY --from=builder /onasty /onasty
M Taskfile.yml

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

includes: migrate: ./migrations/Taskfile.yml +env: + DOCKER_BUILDKIT: 1 + tasks: run: - docker compose up -d --build --remove-orphans core mailer
M cmd/server/main.go

@@ -25,6 +25,7 @@ "github.com/olexsmir/onasty/internal/store/psql/userepo"

"github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/psqlutil" "github.com/olexsmir/onasty/internal/store/rdb" + "github.com/olexsmir/onasty/internal/store/rdb/notecache" "github.com/olexsmir/onasty/internal/store/rdb/usercache" httptransport "github.com/olexsmir/onasty/internal/transport/http" "github.com/olexsmir/onasty/internal/transport/http/httpserver"

@@ -98,8 +99,9 @@ cfg.VerificationTokenTTL,

cfg.AppURL, ) + notecache := notecache.New(redisDB, cfg.CacheNoteTTL) noterepo := noterepo.New(psqlDB) - notesrv := notesrv.New(noterepo, notePasswordHasher) + notesrv := notesrv.New(noterepo, notePasswordHasher, notecache) rateLimiterConfig := ratelimit.Config{ RPS: cfg.RateLimiterRPS,
M e2e/apiv1_notes_test.go

@@ -106,9 +106,10 @@ }

} type apiv1NoteGetResponse struct { - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` + Content string `json:"content"` + ReadAt *time.Time `json:"read_at"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` } func (e *AppTestSuite) TestNoteV1_Get() {

@@ -134,7 +135,8 @@

e.Equal(content, body.Content) dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) - e.Empty(dbNote) + e.Equal(dbNote.Content, "") + e.Equal(dbNote.ReadAt.IsZero(), false) } type apiv1NoteGetRequest struct {

@@ -157,9 +159,13 @@

var bodyCreated apiv1NoteCreateResponse e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) - httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ - Password: passwd, - })) + httpResp = e.httpRequest( + http.MethodGet, + "/api/v1/note/"+bodyCreated.Slug, + e.jsonify(apiv1NoteGetRequest{ + Password: passwd, + }), + ) e.Equal(httpResp.Code, http.StatusOK) var body apiv1NoteGetResponse

@@ -168,7 +174,8 @@

e.Equal(content, body.Content) dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) - e.Empty(dbNote) + e.Equal(dbNote.Content, "") + e.Equal(dbNote.ReadAt.IsZero(), false) } func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrongNoPassword() {

@@ -206,8 +213,12 @@

var bodyCreated apiv1NoteCreateResponse e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) - httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ - Password: e.uuid(), - })) + httpResp = e.httpRequest( + http.MethodGet, + "/api/v1/note/"+bodyCreated.Slug, + e.jsonify(apiv1NoteGetRequest{ + Password: e.uuid(), + }), + ) e.Equal(httpResp.Code, http.StatusNotFound) }
M e2e/e2e_test.go

@@ -25,6 +25,7 @@ "github.com/olexsmir/onasty/internal/store/psql/userepo"

"github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/psqlutil" "github.com/olexsmir/onasty/internal/store/rdb" + "github.com/olexsmir/onasty/internal/store/rdb/notecache" "github.com/olexsmir/onasty/internal/store/rdb/usercache" httptransport "github.com/olexsmir/onasty/internal/transport/http" "github.com/olexsmir/onasty/internal/transport/http/ratelimit"

@@ -119,8 +120,9 @@ cfg.VerificationTokenTTL,

cfg.AppURL, ) + notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL) noterepo := noterepo.New(e.postgresDB) - notesrv := notesrv.New(noterepo, e.hasher) + notesrv := notesrv.New(noterepo, e.hasher, notecache) // for testing purposes, it's ok to have high values ig ratelimitCfg := ratelimit.Config{
M e2e/e2e_utils_db_test.go

@@ -91,19 +91,39 @@ e.require.NoError(err)

return u } -func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note { +type noteModel struct { + ID uuid.UUID + Content string + Slug string + BurnBeforeExpiration bool + Password string + IsRead bool + ReadAt *time.Time + CreatedAt time.Time + ExpiresAt time.Time +} + +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) noteModel { query, args, err := pgq. - Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at"). + Select( + "id", + "content", + "slug", + "burn_before_expiration", + "read_at", + "created_at", + "expires_at", + ). From("notes"). Where(pgq.Eq{"slug": slug}). SQL() e.require.NoError(err) - var note models.Note + var note noteModel err = e.postgresDB.QueryRow(e.ctx, query, args...). - Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { - return models.Note{} //nolint:exhaustruct + return noteModel{} //nolint:exhaustruct } e.require.NoError(err)
M internal/config/config.go

@@ -22,14 +22,12 @@ RedisPassword string

RedisDB int CacheUsersTTL time.Duration + CacheNoteTTL time.Duration JwtSigningKey string JwtAccessTokenTTL time.Duration JwtRefreshTokenTTL time.Duration - MailgunFrom string - MailgunDomain string - MailgunAPIKey string VerificationTokenTTL time.Duration MetricsEnabled bool

@@ -60,6 +58,7 @@ RedisPassword: getenvOrDefault("REDIS_PASSWORD", ""),

RedisDB: mustGetenvOrDefaultInt(getenvOrDefault("REDIS_DB", "0"), 0), CacheUsersTTL: mustParseDuration(getenvOrDefault("CACHE_USERS_TTL", "1h")), + CacheNoteTTL: mustParseDuration(getenvOrDefault("CACHE_NOTE_TTL", "1h")), JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""), JwtAccessTokenTTL: mustParseDuration(

@@ -69,9 +68,6 @@ JwtRefreshTokenTTL: mustParseDuration(

getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "24h"), ), - MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), - MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""), - MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""), VerificationTokenTTL: mustParseDuration( getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"), ),
M internal/dtos/note.go

@@ -13,6 +13,8 @@ Content string

Slug string BurnBeforeExpiration bool Password string + IsRead bool + ReadAt *time.Time CreatedAt time.Time ExpiresAt time.Time }
M internal/models/notes.go

@@ -20,6 +20,7 @@ Content string

Slug string Password string BurnBeforeExpiration bool + ReadAt time.Time CreatedAt time.Time ExpiresAt time.Time }

@@ -45,3 +46,7 @@ func (n Note) ShouldBeBurnt() bool {

return !n.ExpiresAt.IsZero() && n.BurnBeforeExpiration } + +func (n Note) IsRead() bool { + return !n.ReadAt.IsZero() +}
M internal/models/notes_test.go

@@ -8,107 +8,83 @@ assert "github.com/stretchr/testify/require"

) func TestNote_Validate(t *testing.T) { - tests := []struct { - name string - note Note - willError bool - error error - }{ - // NOTE: there no need to test if note is expired since it tested in IsExpired test - { - name: "ok", - note: Note{ //nolint:exhaustruct - Content: "some wired ass content", - ExpiresAt: time.Now().Add(time.Hour), - }, - willError: false, - error: nil, - }, - { - name: "content missing", - note: Note{Content: ""}, //nolint:exhaustruct - willError: true, - error: ErrNoteContentIsEmpty, - }, - } + // NOTE: there no need to test if note is expired since it tested in IsExpired test - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.note.Validate() - if tt.willError { - assert.EqualError(t, err, tt.error.Error()) - } else { - assert.NoError(t, err) - } - }) - } + t.Run("should pass the validation only if content provided", func(t *testing.T) { + n := Note{Content: "the content"} //nolint:exhaustruct + assert.NoError(t, n.Validate()) + }) + t.Run("should pass validation with content and correct expiration time", func(t *testing.T) { + n := Note{ //nolint:exhaustruct + Content: "content", + ExpiresAt: time.Now().Add(time.Minute), + } + assert.NoError(t, n.Validate()) + }) + t.Run("should fail if content is missing", func(t *testing.T) { + n := Note{Content: ""} //nolint:exhaustruct + assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error()) + }) + t.Run("should fail if content is missing and other fields are set", func(t *testing.T) { + n := Note{ //nolint:exhaustruct + Slug: "some-slug", + Password: "some-password", + BurnBeforeExpiration: false, + } + assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error()) + }) + t.Run("should fail if expiration time is in the past", func(t *testing.T) { + n := Note{Content: "content", ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct + assert.EqualError(t, n.Validate(), ErrNoteExpired.Error()) + }) } func TestNote_IsExpired(t *testing.T) { - tests := []struct { - name string - note Note - expected bool - }{ - { - name: "expired", - note: Note{ExpiresAt: time.Now().Add(-time.Hour)}, //nolint:exhaustruct - expected: true, - }, - { - name: "not expired", - note: Note{ExpiresAt: time.Now().Add(time.Hour)}, //nolint:exhaustruct - expected: false, - }, - { - name: "zero expiration", - note: Note{ExpiresAt: time.Time{}}, //nolint:exhaustruct - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.note.IsExpired()) - }) - } + t.Run("should be expired", func(t *testing.T) { + note := Note{ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct + assert.True(t, note.IsExpired()) + }) + t.Run("should be not expired", func(t *testing.T) { + note := Note{ExpiresAt: time.Now().Add(time.Hour)} //nolint:exhaustruct + assert.False(t, note.IsExpired()) + }) + t.Run("should be not expired when [ExpiredAt] is zero", func(t *testing.T) { + note := Note{ExpiresAt: time.Time{}} //nolint:exhaustruct + assert.False(t, note.IsExpired()) + }) } func TestNote_ShouldBeBurnt(t *testing.T) { - tests := []struct { - name string - note Note - expected bool - }{ - { - name: "should be burnt", - note: Note{ //nolint:exhaustruct - BurnBeforeExpiration: true, - ExpiresAt: time.Now().Add(time.Hour), - }, - expected: true, - }, - { - name: "could not be burnt, no expiration time", - note: Note{ //nolint:exhaustruct - BurnBeforeExpiration: true, - ExpiresAt: time.Time{}, - }, - expected: false, - }, - { - name: "could not be burnt, burn when expiration and burn is false", - note: Note{ //nolint:exhaustruct - BurnBeforeExpiration: false, - ExpiresAt: time.Time{}, - }, - expected: false, - }, - } + t.Run("should be burnt", func(t *testing.T) { + note := Note{ //nolint:exhaustruct + BurnBeforeExpiration: true, + ExpiresAt: time.Now().Add(time.Hour), + } + assert.True(t, note.ShouldBeBurnt()) + }) + t.Run("should not be burnt", func(t *testing.T) { + note := Note{ //nolint:exhaustruct + BurnBeforeExpiration: true, + ExpiresAt: time.Time{}, + } + assert.False(t, note.ShouldBeBurnt()) + }) + t.Run("could not be burnt when expiration and shouldBurn set to false", func(t *testing.T) { + note := Note{ //nolint:exhaustruct + BurnBeforeExpiration: false, + ExpiresAt: time.Time{}, + } + assert.False(t, note.ShouldBeBurnt()) + }) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt()) - }) - } +func TestNote_IsRead(t *testing.T) { + t.Run("should be unread", func(t *testing.T) { + n := Note{ReadAt: time.Time{}} //nolint:exhaustruct + assert.False(t, n.IsRead()) + }) + t.Run("should be read", func(t *testing.T) { + n := Note{ReadAt: time.Now()} //nolint:exhaustruct + assert.True(t, n.IsRead()) + }) }
M internal/service/notesrv/notesrv.go

@@ -3,12 +3,14 @@

import ( "context" "log/slog" + "time" "github.com/gofrs/uuid/v5" "github.com/olexsmir/onasty/internal/dtos" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/store/psql/noterepo" + "github.com/olexsmir/onasty/internal/store/rdb/notecache" ) type NoteServicer interface {

@@ -26,12 +28,14 @@

type NoteSrv struct { noterepo noterepo.NoteStorer hasher hasher.Hasher + cache notecache.NoteCacher } -func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher) *NoteSrv { +func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher, cache notecache.NoteCacher) *NoteSrv { return &NoteSrv{ noterepo: noterepo, hasher: hasher, + cache: cache, } }

@@ -71,7 +75,7 @@ func (n *NoteSrv) GetBySlugAndRemoveIfNeeded(

ctx context.Context, inp GetNoteBySlugInput, ) (dtos.NoteDTO, error) { - note, err := n.getNoteFromDBasedOnInput(ctx, inp) + note, err := n.getNote(ctx, inp) if err != nil { return dtos.NoteDTO{}, err }

@@ -91,9 +95,26 @@ if m.ShouldBeBurnt() {

return note, nil } - // TODO: in future not remove, leave some metadata - // to shot user that note was already seen - return note, n.noterepo.DeleteBySlug(ctx, note.Slug) + return note, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) +} + +func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (dtos.NoteDTO, error) { + if r, err := n.cache.GetNote(ctx, inp.Slug); err == nil { + return r, nil + } + + note, err := n.getNoteFromDBasedOnInput(ctx, inp) + if err != nil { + return dtos.NoteDTO{}, err + } + + if note.ReadAt != nil && !note.ReadAt.IsZero() { + if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil { + slog.ErrorContext(ctx, "notecache", "err", err) + } + } + + return note, err } func (n *NoteSrv) getNoteFromDBasedOnInput(
M internal/store/psql/noterepo/noterepo.go

@@ -3,6 +3,7 @@

import ( "context" "errors" + "time" "github.com/gofrs/uuid/v5" "github.com/henvic/pgq"

@@ -30,8 +31,9 @@ slug dtos.NoteSlugDTO,

password string, ) (dtos.NoteDTO, error) - // DeleteBySlug deletes note by slug or returns [models.ErrNoteNotFound] if note if not found. - DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error + // RemoveBySlug marks note as read, deletes it's content, and keeps meta data + // Returns [models.ErrNoteNotFound] if note is not found. + RemoveBySlug(ctx context.Context, slug dtos.NoteSlugDTO, readAt time.Time) error // SetAuthorIDBySlug assigns author to note by slug. // Returns [models.ErrNoteNotFound] if note is not found.

@@ -68,7 +70,7 @@ }

func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) { query, args, err := pgq. - Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). + Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). From("notes"). Where("(password is null or password = '')"). Where(pgq.Eq{"slug": slug}).

@@ -79,7 +81,7 @@ }

var note dtos.NoteDTO err = s.db.QueryRow(ctx, query, args...). - Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return dtos.NoteDTO{}, models.ErrNoteNotFound

@@ -94,7 +96,7 @@ slug dtos.NoteSlugDTO,

passwd string, ) (dtos.NoteDTO, error) { query, args, err := pgq. - Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). + Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). From("notes"). Where(pgq.Eq{ "slug": slug,

@@ -107,7 +109,7 @@ }

var note dtos.NoteDTO err = s.db.QueryRow(ctx, query, args...). - Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return dtos.NoteDTO{}, models.ErrNoteNotFound

@@ -116,10 +118,19 @@

return note, err } -func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error { +func (s *NoteRepo) RemoveBySlug( + ctx context.Context, + slug dtos.NoteSlugDTO, + readAt time.Time, +) error { query, args, err := pgq. - Delete("notes"). - Where(pgq.Eq{"slug": slug}). + Update("notes"). + Set("content", ""). + Set("read_at", readAt). + Where(pgq.Eq{ + "slug": slug, + "read_at": nil, + }). SQL() if err != nil { return err
A internal/store/rdb/notecache/notecache.go

@@ -0,0 +1,60 @@

+package notecache + +import ( + "bytes" + "context" + "encoding/gob" + "strings" + "time" + + "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/store/rdb" +) + +type NoteCacher interface { + SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error + GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) +} + +type NoteCache struct { + rdb *rdb.DB + ttl time.Duration +} + +func New(rdb *rdb.DB, ttl time.Duration) *NoteCache { + return &NoteCache{ + rdb: rdb, + ttl: ttl, + } +} + +func (n *NoteCache) SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(note); err != nil { + return err + } + + _, err := n.rdb.Set(ctx, getKey(slug), buf.Bytes(), n.ttl).Result() + return err +} + +func (n *NoteCache) GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) { + val, err := n.rdb.Get(ctx, getKey(slug)).Bytes() + if err != nil { + return dtos.NoteDTO{}, err + } + + var note dtos.NoteDTO + if err = gob.NewDecoder(bytes.NewReader(val)).Decode(&note); err != nil { + return dtos.NoteDTO{}, err + } + + return note, err +} + +func getKey(slug string) string { + var sb strings.Builder + sb.WriteString("note:") + sb.WriteString(slug) + return sb.String() +}
M internal/transport/http/apiv1/note.go

@@ -67,9 +67,10 @@ Password string `json:"password,omitempty"`

} type getNoteBySlugResponse struct { - Content string `json:"content"` - CratedAt time.Time `json:"crated_at"` - ExpiresAt time.Time `json:"expires_at"` + Content string `json:"content,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CratedAt time.Time `json:"crated_at"` + ExpiresAt time.Time `json:"expires_at"` } func (a *APIV1) getNoteBySlugHandler(c *gin.Context) {

@@ -92,8 +93,14 @@ errorResponse(c, err)

return } - c.JSON(http.StatusOK, getNoteBySlugResponse{ + status := http.StatusOK + if note.ReadAt != nil && !note.ReadAt.IsZero() { + status = http.StatusNotFound + } + + c.JSON(status, getNoteBySlugResponse{ Content: note.Content, + ReadAt: note.ReadAt, CratedAt: note.CreatedAt, ExpiresAt: note.ExpiresAt, })
M mailer/Dockerfile

@@ -9,8 +9,9 @@ COPY internal internal

COPY mailer mailer ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 -RUN go build -trimpath -ldflags='-w -s' -o /mailer ./mailer - +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build -trimpath -ldflags='-w -s' -o /mailer ./mailer FROM alpine:3.21 COPY --from=builder /mailer /mailer
A migrations/20250401121105_notes_add_read.down.sql

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

+ALTER TABLE notes + DROP COLUMN read_at;
A migrations/20250401121105_notes_add_read.up.sql

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

+ALTER TABLE notes + ADD COLUMN "read_at" timestamptz;