all repos

onasty @ bc92b8268dc567b48d4d567ffdfefb0442152335

a one-time notes service
5 files changed, 326 insertions(+), 55 deletions(-)
feat: get all read and unread notes (#188)

* refactor: some moving of code around

* feat: implement get all read notes

* test(e2e): add missing test for "get all notes"

* feat: implement get all unread notes

* refactor: remove duplications for type conversions
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-13 15:12:37 +0300
Parent: efd9704
M e2e/apiv1_notes_authorized_test.go

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

import ( "net/http" + "slices" "time" )

@@ -199,3 +200,168 @@ )

e.Equal(httpResp.Code, http.StatusBadRequest) } + +type apiv1NoteGetAllResponse struct { + Content string `json:"content"` + Slug string `json:"slug"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` + HasPassword bool `json:"has_password"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + ReadAt time.Time `json:"read_at"` +} + +func (e *AppTestSuite) TestNoteV1_GetAll() { + notesInfo := []struct { + slug string + content string + read bool + }{ + {slug: e.uuid(), content: e.uuid(), read: true}, + {slug: e.uuid(), content: e.uuid(), read: true}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + } + + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + + // create notes + for _, ni := range notesInfo { + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: ni.content, + Slug: ni.slug, + }), + toks.AccessToken) + + e.Equal(http.StatusCreated, httpResp.Code) + } + + // read notes + for _, ni := range notesInfo { + if ni.read { + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/"+ni.slug, nil) + e.Equal(http.StatusOK, httpResp.Code) + } + } + + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note", nil, toks.AccessToken) + + var body []apiv1NoteGetAllResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(http.StatusOK, httpResp.Code) + e.Len(body, len(notesInfo)) +} + +func (e *AppTestSuite) TestNoteV1_GetAllRead_inaccesibleForAnUnauthorized() { + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/read", nil) + e.Equal(httpResp.Code, http.StatusUnauthorized) +} + +func (e *AppTestSuite) TestNoteV1_GetAllRead() { + notesInfo := []struct { + slug string + content string + }{ + {slug: e.uuid(), content: e.uuid()}, + {slug: e.uuid(), content: e.uuid()}, + {slug: e.uuid(), content: e.uuid()}, + {slug: e.uuid(), content: e.uuid()}, + } + + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + + // create few notes + for _, ni := range notesInfo { + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: ni.content, + Slug: ni.slug, + }), + toks.AccessToken) + + e.Equal(http.StatusCreated, httpResp.Code) + } + + // read those notes + for _, ni := range notesInfo { + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/"+ni.slug, nil) + e.Equal(http.StatusOK, httpResp.Code) + } + + // check if all notes are returned + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/read", nil, toks.AccessToken) + + var body []apiv1NoteGetAllResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(http.StatusOK, httpResp.Code) + e.require.Len(body, len(notesInfo)) +} + +func (e *AppTestSuite) TestNoteV1_GetAllUnread_inaccesibleForAnUnauthorized() { + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/unread", nil) + e.Equal(httpResp.Code, http.StatusUnauthorized) +} + +func (e *AppTestSuite) TestNoteV1_GetAllUnread() { + type notesTestData struct { + slug string + content string + read bool + } + + notesInfo := []notesTestData{ + {slug: e.uuid(), content: e.uuid(), read: true}, + {slug: e.uuid(), content: e.uuid(), read: true}, + {slug: e.uuid(), content: e.uuid(), read: true}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + {slug: e.uuid(), content: e.uuid(), read: false}, + } + unreadNotesTotal := len( + slices.DeleteFunc( + slices.Clone(notesInfo), + func(n notesTestData) bool { return n.read }), + ) + + _, toks := e.createAndSingIn(e.uuid()+"@test.com", "password") + + // create notes + for _, ni := range notesInfo { + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: ni.content, + Slug: ni.slug, + }), + toks.AccessToken) + + e.Equal(http.StatusCreated, httpResp.Code) + } + + // read notes + for _, ni := range notesInfo { + if ni.read { + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/"+ni.slug, nil) + e.Equal(http.StatusOK, httpResp.Code) + } + } + + httpResp := e.httpRequest(http.MethodGet, "/api/v1/note/unread", nil, toks.AccessToken) + + var body []apiv1NoteGetAllResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(http.StatusOK, httpResp.Code) + e.Len(body, unreadNotesTotal) +}
M internal/service/notesrv/notesrv.go

@@ -34,10 +34,13 @@ // If note is not found returns [models.ErrNoteNotFound].

GetNoteMetadataBySlug(ctx context.Context, slug dtos.NoteSlug) (dtos.NoteMetadata, error) // GetAllByAuthorID returns all notes by author id. - GetAllByAuthorID( - ctx context.Context, - authorID uuid.UUID, - ) ([]dtos.NoteDetailed, error) + GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error) + + // GetAllReadByAuthorID returns all notes that ARE READ and authored by author id. + GetAllReadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error) + + // GetAllUnreadByAuthorID returns all notes that ARE UNREAD and authored by author id. + GetAllUnreadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]dtos.NoteDetailed, error) // UpdateExpirationTimeSettings updates expiresAt and burnBeforeExpiration. // If notes is not found returns [models.ErrNoteNotFound].

@@ -164,20 +167,31 @@ if err != nil {

return nil, err } - var resNotes []dtos.NoteDetailed - for _, note := range notes { - resNotes = append(resNotes, dtos.NoteDetailed{ - Content: note.Content, - Slug: note.Slug, - BurnBeforeExpiration: note.BurnBeforeExpiration, - HasPassword: note.Password != "", - CreatedAt: note.CreatedAt, - ExpiresAt: note.ExpiresAt, - ReadAt: note.ReadAt, - }) + return n.mapNoteModelToDto(notes), nil +} + +func (n *NoteSrv) GetAllReadByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]dtos.NoteDetailed, error) { + notes, err := n.noterepo.GetAllReadByAuthorID(ctx, authorID) + if err != nil { + return nil, err + } + + return n.mapNoteModelToDto(notes), nil +} + +func (n *NoteSrv) GetAllUnreadByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]dtos.NoteDetailed, error) { + notes, err := n.noterepo.GetAllUnreadByAuthorID(ctx, authorID) + if err != nil { + return nil, err } - return resNotes, nil + return n.mapNoteModelToDto(notes), nil } func (n *NoteSrv) UpdateExpirationTimeSettings(

@@ -248,3 +262,20 @@ return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword)

} return n.noterepo.GetBySlug(ctx, inp.Slug) } + +func (n *NoteSrv) mapNoteModelToDto(notes []models.Note) []dtos.NoteDetailed { + var resNotes []dtos.NoteDetailed + for _, note := range notes { + resNotes = append(resNotes, dtos.NoteDetailed{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.Password != "", + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + return resNotes +}
M internal/store/psql/noterepo/noterepo.go

@@ -29,6 +29,12 @@

// GetAllByAuthorID returns all notes with specified author. GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) + // GetAllReadByAuthorID returns all notes that are read and authored by specified author. + GetAllReadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) + + // GetAllUnreadByAuthorID returns all notes that are unread and authored by specified author. + GetAllUnreadByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) + // GetCountOfNotesByAuthorID returns count of notes created by specified author. GetCountOfNotesByAuthorID(ctx context.Context, authorID uuid.UUID) (int64, error)

@@ -129,10 +135,9 @@ ctx context.Context,

slug dtos.NoteSlug, ) (dtos.NoteMetadata, error) { query := `--sql - select n.created_at, (n.password is not null and n.password <> '') has_password, n.read_at - from notes n - where slug = $1 - ` +select n.created_at, (n.password is not null and n.password <> '') has_password, n.read_at +from notes n +where slug = $1` var readAt sql.NullTime var metadata dtos.NoteMetadata

@@ -153,32 +158,40 @@ ctx context.Context,

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

@@ -359,3 +372,34 @@ }

return nil } + +// getAllNotes is a helper function for [NoteRepo.GetAllByAuthorID], [NoteRepo.GetAllReadByAuthorID], +// and [NoteRepo.GetAllUnreadByAuthorID]. +// The query's SELECT elements order should be consistent across all function calls. +func (s *NoteRepo) getAllNotes( + ctx context.Context, + query string, + authorID uuid.UUID, +) ([]models.Note, error) { + rows, err := s.db.Query(ctx, query, authorID.String()) + if err != nil { + return nil, err + } + + defer rows.Close() + + var notes []models.Note + for rows.Next() { + var note models.Note + var readAt sql.NullTime + if err := rows.Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.Password, + &readAt, &note.CreatedAt, &note.ExpiresAt); err != nil { + return nil, err + } + + note.ReadAt = psqlutil.NullTimeToTime(readAt) + notes = append(notes, note) + } + + return notes, rows.Err() +}
M internal/transport/http/apiv1/apiv1.go

@@ -34,6 +34,9 @@ }

func (a *APIV1) Routes(r *gin.RouterGroup) { r.Use(a.metricsMiddleware) + + r.GET("/me", a.authorizedMiddleware, a.getMeHandler) + auth := r.Group("/auth") { auth.POST("/signup", a.signUpHandler)

@@ -58,8 +61,6 @@ authorized.POST("/change-password", a.changePasswordHandler)

} } - r.GET("/me", a.authorizedMiddleware, a.getMeHandler) - note := r.Group("/note") { note.GET("/:slug", a.getNoteBySlugHandler)

@@ -74,6 +75,11 @@

authorized := note.Group("", a.authorizedMiddleware) { authorized.GET("", a.getNotesHandler) + + // FIXME: those links make slugs `read` and `unread` unavailable + authorized.GET("/read", a.getReadNotesHandler) + authorized.GET("/unread", a.getUnReadNotesHandler) + authorized.PATCH(":slug/expires", a.updateNoteHandler) authorized.PATCH(":slug/password", a.setNotePasswordHandler) authorized.DELETE(":slug", a.deleteNoteHandler)
M internal/transport/http/apiv1/note.go

@@ -154,20 +154,27 @@ errorResponse(c, err)

return } - var response []getNotesResponse - for _, note := range notes { - response = append(response, getNotesResponse{ - Content: note.Content, - Slug: note.Slug, - BurnBeforeExpiration: note.BurnBeforeExpiration, - HasPassword: note.HasPassword, - CreatedAt: note.CreatedAt, - ExpiresAt: note.ExpiresAt, - ReadAt: note.ReadAt, - }) + c.JSON(http.StatusOK, mapNotesDTOToResponse(notes)) +} + +func (a *APIV1) getReadNotesHandler(c *gin.Context) { + notes, err := a.notesrv.GetAllReadByAuthorID(c.Request.Context(), a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return + } + + c.JSON(http.StatusOK, mapNotesDTOToResponse(notes)) +} + +func (a *APIV1) getUnReadNotesHandler(c *gin.Context) { + notes, err := a.notesrv.GetAllUnreadByAuthorID(c.Request.Context(), a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return } - c.JSON(http.StatusOK, response) + c.JSON(http.StatusOK, mapNotesDTOToResponse(notes)) } type updateNoteRequest struct {

@@ -236,3 +243,20 @@ }

c.Status(http.StatusOK) } + +func mapNotesDTOToResponse(notes []dtos.NoteDetailed) []getNotesResponse { + var response []getNotesResponse + for _, note := range notes { + response = append(response, getNotesResponse{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.HasPassword, + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + return response +}