diff --git a/application/article/getArticlesByAuthor/response.go b/application/article/getArticlesByAuthor/response.go index 4fa2dfbc..a7411f86 100644 --- a/application/article/getArticlesByAuthor/response.go +++ b/application/article/getArticlesByAuthor/response.go @@ -13,7 +13,10 @@ import ( type Response struct { ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` - Author authorResponse `json:"author"` + Author authorResponse `json:"author"` + // How much of each kind the author has published, so the page can label both + // content tabs without asking twice. + Totals totalsResponse `json:"totals"` LanguageCode languageResponse `json:"language_code"` Items []articleResponse `json:"items"` Elements []element.Response `json:"elements"` @@ -30,6 +33,11 @@ type articleResponse struct { AvailableLanguages []languageResponse `json:"available_languages"` } +type totalsResponse struct { + Articles uint `json:"articles"` + Notes uint `json:"notes"` +} + type authorResponse struct { UUID string `json:"uuid"` Name string `json:"name"` @@ -48,7 +56,7 @@ type paginationResponse struct { CurrentPage uint `json:"current_page"` } -func NewResponse(author user.User, a []article.Article, articlesPublishedLanguages map[string][]language.Language, requestedLanguage language.Language, elementsResponse []element.Response, totalPages, currentPage uint) *Response { +func NewResponse(author user.User, a []article.Article, articlesPublishedLanguages map[string][]language.Language, requestedLanguage language.Language, elementsResponse []element.Response, totalArticles, totalNotes uint, totalPages, currentPage uint) *Response { items := make([]articleResponse, len(a)) for i := range a { @@ -77,6 +85,10 @@ func NewResponse(author user.User, a []article.Article, articlesPublishedLanguag Username: author.Username, CreatedAt: author.CreatedAt.Format(time.RFC3339), }, + Totals: totalsResponse{ + Articles: totalArticles, + Notes: totalNotes, + }, LanguageCode: languageResponse{ Code: requestedLanguage.Code, Name: requestedLanguage.Name, diff --git a/application/article/getArticlesByAuthor/usecase.go b/application/article/getArticlesByAuthor/usecase.go index d3d87626..dc3b4b36 100644 --- a/application/article/getArticlesByAuthor/usecase.go +++ b/application/article/getArticlesByAuthor/usecase.go @@ -9,6 +9,7 @@ import ( "github.com/khanzadimahdi/testproject/domain" "github.com/khanzadimahdi/testproject/domain/article" "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" "github.com/khanzadimahdi/testproject/domain/user" ) @@ -16,6 +17,7 @@ const limit = 10 type UseCase struct { articleRepository article.Repository + noteRepository note.Repository userRepository user.Repository languageRepository language.Repository languageResolver resolver.Resolver @@ -25,6 +27,7 @@ type UseCase struct { func NewUseCase( articleRepository article.Repository, + noteRepository note.Repository, userRepository user.Repository, languageRepository language.Repository, languageResolver resolver.Resolver, @@ -33,6 +36,7 @@ func NewUseCase( ) *UseCase { return &UseCase{ articleRepository: articleRepository, + noteRepository: noteRepository, userRepository: userRepository, languageRepository: languageRepository, languageResolver: languageResolver, @@ -73,6 +77,12 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er return nil, err } + // Only for the notes tab's label. + totalNotes, err := uc.noteRepository.CountPublishedByAuthor(ctx, author.UUID, languageCode) + if err != nil { + return nil, err + } + currentPage := request.Page if currentPage == 0 { currentPage = 1 @@ -117,7 +127,7 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er publishedLanguages[a[i].UUID] = al } - return NewResponse(author, a, publishedLanguages, l, elementsResponse, totalPages, currentPage), nil + return NewResponse(author, a, publishedLanguages, l, elementsResponse, totalArticles, totalNotes, totalPages, currentPage), nil } func (uc *UseCase) resolveAuthor(ctx context.Context, request *Request) (user.User, error) { diff --git a/application/article/getArticlesByAuthor/usecase_test.go b/application/article/getArticlesByAuthor/usecase_test.go index 9a1dfb9e..6507f748 100644 --- a/application/article/getArticlesByAuthor/usecase_test.go +++ b/application/article/getArticlesByAuthor/usecase_test.go @@ -16,6 +16,7 @@ import ( "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" "github.com/khanzadimahdi/testproject/infrastructure/validator" "github.com/stretchr/testify/assert" @@ -30,6 +31,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -64,13 +66,14 @@ func TestUseCase_Execute(t *testing.T) { defer userRepository.AssertExpectations(t) articleRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(len(a)), nil) + notesRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(0), nil) articleRepository.On("GetPublishedByAuthor", mock.Anything, authorUUID, "EN", uint(0), uint(10)).Once().Return(a, nil) elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) articleRepository.On("GetPublishedLanguageCodes", mock.Anything, "").Return([]string{}, nil) languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) defer articleRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) assert.NoError(t, err) assert.NotNil(t, response) @@ -87,6 +90,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -117,13 +121,14 @@ func TestUseCase_Execute(t *testing.T) { defer userRepository.AssertExpectations(t) articleRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(len(a)), nil) + notesRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(0), nil) articleRepository.On("GetPublishedByAuthor", mock.Anything, authorUUID, "EN", uint(0), uint(10)).Once().Return(a, nil) elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) articleRepository.On("GetPublishedLanguageCodes", mock.Anything, "").Return([]string{}, nil) languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) defer articleRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) userRepository.AssertNotCalled(t, "GetOneByIdentity") @@ -137,6 +142,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -154,7 +160,7 @@ func TestUseCase_Execute(t *testing.T) { requestValidator.On("Validate", &request).Once().Return(expectedResponse.ValidationErrors) defer requestValidator.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) userRepository.AssertNotCalled(t, "GetOne") userRepository.AssertNotCalled(t, "GetOneByIdentity") @@ -170,6 +176,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -189,7 +196,7 @@ func TestUseCase_Execute(t *testing.T) { userRepository.On("GetOneByIdentity", mock.Anything, request.Username).Once().Return(user.User{}, domain.ErrNotExists) defer userRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) articleRepository.AssertNotCalled(t, "CountPublishedByAuthor") articleRepository.AssertNotCalled(t, "GetPublishedByAuthor") @@ -203,6 +210,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -223,7 +231,7 @@ func TestUseCase_Execute(t *testing.T) { userRepository.On("GetOneByIdentity", mock.Anything, request.Username).Once().Return(user.User{}, expectedErr) defer userRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) articleRepository.AssertNotCalled(t, "CountPublishedByAuthor") articleRepository.AssertNotCalled(t, "GetPublishedByAuthor") @@ -237,6 +245,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -260,9 +269,10 @@ func TestUseCase_Execute(t *testing.T) { defer userRepository.AssertExpectations(t) articleRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(0), expectedErr) + notesRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(0), nil) defer articleRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) articleRepository.AssertNotCalled(t, "GetPublishedByAuthor") @@ -275,6 +285,7 @@ func TestUseCase_Execute(t *testing.T) { var ( articleRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -298,10 +309,11 @@ func TestUseCase_Execute(t *testing.T) { defer userRepository.AssertExpectations(t) articleRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(5), nil) + notesRepository.On("CountPublishedByAuthor", mock.Anything, authorUUID, "EN").Once().Return(uint(0), nil) articleRepository.On("GetPublishedByAuthor", mock.Anything, authorUUID, "EN", uint(0), uint(10)).Once().Return(nil, expectedErr) defer articleRepository.AssertExpectations(t) - response, err := NewUseCase(&articleRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) + response, err := NewUseCase(&articleRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articleRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator).Execute(context.Background(), &request) assert.ErrorIs(t, err, expectedErr) assert.Nil(t, response) diff --git a/application/article/getArticlesByHashtag/response.go b/application/article/getArticlesByHashtag/response.go deleted file mode 100644 index b43b0a4a..00000000 --- a/application/article/getArticlesByHashtag/response.go +++ /dev/null @@ -1,95 +0,0 @@ -package getArticlesByHashtag - -import ( - "time" - - "github.com/khanzadimahdi/testproject/application/element" - "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/article" - "github.com/khanzadimahdi/testproject/domain/language" - "github.com/khanzadimahdi/testproject/domain/user" -) - -type Response struct { - ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` - - Items []articleResponse `json:"items"` - LanguageCode languageResponse `json:"language_code"` - Elements []element.Response `json:"elements"` - Pagination paginationResponse `json:"pagination"` -} - -type articleResponse struct { - CorrelationUUID string `json:"correlation_uuid"` - Cover string `json:"cover"` - Video string `json:"video"` - Title string `json:"title"` - Excerpt string `json:"excerpt"` - PublishedAt string `json:"published_at"` - Author authorResponse `json:"author"` - AvailableLanguages []languageResponse `json:"available_languages"` -} - -type authorResponse struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Avatar string `json:"avatar"` - Username string `json:"username"` -} - -type languageResponse struct { - Code string `json:"code"` - Name string `json:"name"` -} - -type paginationResponse struct { - TotalPages uint `json:"total_pages"` - CurrentPage uint `json:"current_page"` -} - -func NewResponse(a []article.Article, authors []user.User, articlesPublishedLanguages map[string][]language.Language, requestedLanguage language.Language, elementsResponse []element.Response, totalPages, currentPage uint) *Response { - authorByUUID := make(map[string]user.User, len(authors)) - for i := range authors { - authorByUUID[authors[i].UUID] = authors[i] - } - - items := make([]articleResponse, len(a)) - - for i := range a { - items[i].CorrelationUUID = a[i].CorrelationUUID - items[i].Cover = a[i].Cover - items[i].Video = a[i].Video - items[i].Title = a[i].Title - items[i].Excerpt = a[i].Excerpt - items[i].PublishedAt = a[i].PublishedAt.Format(time.RFC3339) - - if u, ok := authorByUUID[a[i].AuthorUUID]; ok { - items[i].Author.UUID = u.UUID - items[i].Author.Name = u.Name - items[i].Author.Avatar = u.Avatar - items[i].Author.Username = u.Username - } - - if al, ok := articlesPublishedLanguages[a[i].UUID]; ok { - for l := range al { - items[i].AvailableLanguages = append(items[i].AvailableLanguages, languageResponse{ - Code: al[l].Code, - Name: al[l].Name, - }) - } - } - } - - return &Response{ - Items: items, - LanguageCode: languageResponse{ - Code: requestedLanguage.Code, - Name: requestedLanguage.Name, - }, - Elements: elementsResponse, - Pagination: paginationResponse{ - TotalPages: totalPages, - CurrentPage: currentPage, - }, - } -} diff --git a/application/article/getArticlesByHashtag/usecase_test.go b/application/article/getArticlesByHashtag/usecase_test.go deleted file mode 100644 index d93efa1f..00000000 --- a/application/article/getArticlesByHashtag/usecase_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package getArticlesByHashtag - -import ( - "context" - "errors" - "testing" - - "github.com/khanzadimahdi/testproject/application/element" - "github.com/khanzadimahdi/testproject/application/language/resolver" - "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/article" - "github.com/khanzadimahdi/testproject/domain/language" - "github.com/khanzadimahdi/testproject/domain/user" - "github.com/khanzadimahdi/testproject/infrastructure/matcher" - "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" - "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" - "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" - "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" - "github.com/khanzadimahdi/testproject/infrastructure/validator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestUseCase_Execute(t *testing.T) { - t.Parallel() - - t.Run("returns articles by hashtag", func(t *testing.T) { - t.Parallel() - - var ( - repository articles.MockArticlesRepository - elementsRepository elements.MockElementsRepository - userRepository users.MockUsersRepository - languagesRepository languages.MockLanguagesRepository - languageResolver resolver.MockResolver - validator validator.MockValidator - - hashtag = "test-hashtag" - a = []article.Article{ - {UUID: "test-article-1", AuthorUUID: "author-uuid-1"}, - {UUID: "test-article-2", AuthorUUID: "author-uuid-2"}, - {UUID: "test-article-3", AuthorUUID: "author-uuid-1"}, - } - u = []user.User{ - {UUID: "author-uuid-1", Name: "author-name-1", Avatar: "author-avatar-1"}, - {UUID: "author-uuid-2", Name: "author-name-2", Avatar: "author-avatar-2"}, - } - request = Request{Page: 1, Hashtag: hashtag} - ) - - validator.On("Validate", &request).Once().Return(nil) - defer validator.AssertExpectations(t) - - languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) - languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) - defer languageResolver.AssertExpectations(t) - - repository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(len(a)), nil) - repository.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(a, nil) - elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) - defer repository.AssertExpectations(t) - - userRepository.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1", "author-uuid-2", "author-uuid-1"}).Once().Return(u, nil) - defer userRepository.AssertExpectations(t) - - repository.On("GetPublishedLanguageCodes", mock.Anything, "").Return([]string{}, nil) - languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) - - usecase := NewUseCase(&repository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&repository, &elementsRepository, &userRepository, matcher.New()), &validator) - response, err := usecase.Execute(context.Background(), &request) - - assert.NoError(t, err, "unexpected error") - assert.NotNil(t, response, "unexpected response") - }) - - t.Run("validation failed", func(t *testing.T) { - t.Parallel() - - var ( - repository articles.MockArticlesRepository - elementsRepository elements.MockElementsRepository - userRepository users.MockUsersRepository - languagesRepository languages.MockLanguagesRepository - languageResolver resolver.MockResolver - validator validator.MockValidator - - hashtag = "test-hashtag" - request = Request{Page: 1, Hashtag: hashtag} - - expectedResponse = Response{ - ValidationErrors: domain.ValidationErrors{ - "hashtag": "this field is required", - }, - } - ) - - validator.On("Validate", &request).Once().Return(expectedResponse.ValidationErrors) - defer validator.AssertExpectations(t) - - usecase := NewUseCase(&repository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&repository, &elementsRepository, &userRepository, matcher.New()), &validator) - response, err := usecase.Execute(context.Background(), &request) - - repository.AssertNotCalled(t, "CountPublishedByHashtags") - repository.AssertNotCalled(t, "GetPublishedByHashtags") - userRepository.AssertNotCalled(t, "GetByUUIDs") - - assert.NoError(t, err) - assert.Equal(t, &expectedResponse, response) - }) - - t.Run("returns an error on counting items", func(t *testing.T) { - t.Parallel() - - var ( - repository articles.MockArticlesRepository - elementsRepository elements.MockElementsRepository - userRepository users.MockUsersRepository - languagesRepository languages.MockLanguagesRepository - languageResolver resolver.MockResolver - validator validator.MockValidator - - hashtag = "test-hashtag" - expectedErr = errors.New("test error") - request = Request{Page: 1, Hashtag: hashtag} - ) - - validator.On("Validate", &request).Once().Return(nil) - defer validator.AssertExpectations(t) - - languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) - languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) - defer languageResolver.AssertExpectations(t) - - repository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(0), expectedErr) - defer repository.AssertExpectations(t) - - usecase := NewUseCase(&repository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&repository, &elementsRepository, &userRepository, matcher.New()), &validator) - response, err := usecase.Execute(context.Background(), &request) - - repository.AssertNotCalled(t, "GetPublishedByHashtags") - userRepository.AssertNotCalled(t, "GetByUUIDs") - - assert.ErrorIs(t, err, expectedErr) - assert.Nil(t, response, "unexpected response") - }) - - t.Run("returns an error on getting items", func(t *testing.T) { - t.Parallel() - - var ( - repository articles.MockArticlesRepository - elementsRepository elements.MockElementsRepository - userRepository users.MockUsersRepository - languagesRepository languages.MockLanguagesRepository - languageResolver resolver.MockResolver - validator validator.MockValidator - - hashtag = "test-hashtag" - expectedErr = errors.New("test error") - request = Request{Page: 1, Hashtag: hashtag} - ) - - validator.On("Validate", &request).Once().Return(nil) - defer validator.AssertExpectations(t) - - languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) - languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) - defer languageResolver.AssertExpectations(t) - - repository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(5), nil) - repository.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(nil, expectedErr) - defer repository.AssertExpectations(t) - - usecase := NewUseCase(&repository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&repository, &elementsRepository, &userRepository, matcher.New()), &validator) - response, err := usecase.Execute(context.Background(), &request) - - userRepository.AssertNotCalled(t, "GetByUUIDs") - - assert.ErrorIs(t, err, expectedErr) - assert.Nil(t, response, "unexpected response") - }) - - t.Run("returns an error on getting authors", func(t *testing.T) { - t.Parallel() - - var ( - repository articles.MockArticlesRepository - elementsRepository elements.MockElementsRepository - userRepository users.MockUsersRepository - languagesRepository languages.MockLanguagesRepository - languageResolver resolver.MockResolver - validator validator.MockValidator - - hashtag = "test-hashtag" - expectedErr = errors.New("test error") - a = []article.Article{ - {UUID: "test-article-1", AuthorUUID: "author-uuid-1"}, - } - request = Request{Page: 1, Hashtag: hashtag} - ) - - validator.On("Validate", &request).Once().Return(nil) - defer validator.AssertExpectations(t) - - languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) - languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) - defer languageResolver.AssertExpectations(t) - - repository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(1), nil) - repository.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(a, nil) - elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) - defer repository.AssertExpectations(t) - - userRepository.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1"}).Once().Return(nil, expectedErr) - defer userRepository.AssertExpectations(t) - - usecase := NewUseCase(&repository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&repository, &elementsRepository, &userRepository, matcher.New()), &validator) - response, err := usecase.Execute(context.Background(), &request) - - assert.ErrorIs(t, err, expectedErr) - assert.Nil(t, response, "unexpected response") - }) -} diff --git a/application/bookmark/bookmarkExists/request.go b/application/bookmark/bookmarkExists/request.go index 27e37729..2599a64f 100644 --- a/application/bookmark/bookmarkExists/request.go +++ b/application/bookmark/bookmarkExists/request.go @@ -15,7 +15,7 @@ type Request struct { func (r *Request) Validate() domain.ValidationErrors { errors := make(domain.ValidationErrors) - if r.ObjectType != bookmark.ObjectTypeArticle { + if !bookmark.IsValidObjectType(r.ObjectType) { errors["object_type"] = "invalid_value" } diff --git a/application/bookmark/updateBookmark/request.go b/application/bookmark/updateBookmark/request.go index 0172b676..f51f8163 100644 --- a/application/bookmark/updateBookmark/request.go +++ b/application/bookmark/updateBookmark/request.go @@ -23,7 +23,7 @@ func (r *Request) Validate() domain.ValidationErrors { validationErrors["title"] = "required_field" } - if r.ObjectType != bookmark.ObjectTypeArticle { + if !bookmark.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/bookmark/updateBookmark/request_test.go b/application/bookmark/updateBookmark/request_test.go index 902752d0..9f867b99 100644 --- a/application/bookmark/updateBookmark/request_test.go +++ b/application/bookmark/updateBookmark/request_test.go @@ -26,6 +26,18 @@ func TestRequest_Validate(t *testing.T) { }, want: domain.ValidationErrors{}, }, + { + name: "valid request bookmarking a note", + request: Request{ + Keep: true, + Title: "My Bookmark", + ObjectType: bookmark.ObjectTypeNote, + ObjectUUID: "note-uuid-123", + LanguageCode: "en", + OwnerUUID: "owner-uuid-456", + }, + want: domain.ValidationErrors{}, + }, { name: "valid request with keep false", request: Request{ diff --git a/application/comment/createComment/request.go b/application/comment/createComment/request.go index 4c8fcd56..f8023193 100644 --- a/application/comment/createComment/request.go +++ b/application/comment/createComment/request.go @@ -23,7 +23,7 @@ func (r *Request) Validate() domain.ValidationErrors { validationErrors["body"] = "required_field" } - if r.ObjectType != comment.ObjectTypeArticle { + if !comment.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/comment/getComments/request.go b/application/comment/getComments/request.go index 338f1401..22440f50 100644 --- a/application/comment/getComments/request.go +++ b/application/comment/getComments/request.go @@ -17,7 +17,7 @@ var _ domain.Validatable = &Request{} func (r *Request) Validate() domain.ValidationErrors { validationErrors := make(domain.ValidationErrors) - if r.ObjectType != comment.ObjectTypeArticle { + if !comment.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/dashboard/bookmark/deleteUserBookmark/request.go b/application/dashboard/bookmark/deleteUserBookmark/request.go index f9a2027f..c5c3a873 100644 --- a/application/dashboard/bookmark/deleteUserBookmark/request.go +++ b/application/dashboard/bookmark/deleteUserBookmark/request.go @@ -17,7 +17,7 @@ var _ domain.Validatable = &Request{} func (r *Request) Validate() domain.ValidationErrors { validationErrors := make(domain.ValidationErrors) - if r.ObjectType != bookmark.ObjectTypeArticle { + if !bookmark.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/dashboard/comment/createComment/request.go b/application/dashboard/comment/createComment/request.go index 6e4f9187..cfca14f1 100644 --- a/application/dashboard/comment/createComment/request.go +++ b/application/dashboard/comment/createComment/request.go @@ -26,7 +26,7 @@ func (r *Request) Validate() domain.ValidationErrors { validationErrors["body"] = "required_field" } - if r.ObjectType != comment.ObjectTypeArticle { + if !comment.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/dashboard/comment/updateComment/request.go b/application/dashboard/comment/updateComment/request.go index 802b64ce..62176349 100644 --- a/application/dashboard/comment/updateComment/request.go +++ b/application/dashboard/comment/updateComment/request.go @@ -30,7 +30,7 @@ func (r *Request) Validate() domain.ValidationErrors { validationErrors["body"] = "required_field" } - if r.ObjectType != comment.ObjectTypeArticle { + if !comment.IsValidObjectType(r.ObjectType) { validationErrors["object_type"] = "invalid_value" } diff --git a/application/dashboard/note/createNote/request.go b/application/dashboard/note/createNote/request.go new file mode 100644 index 00000000..ada8b1c8 --- /dev/null +++ b/application/dashboard/note/createNote/request.go @@ -0,0 +1,36 @@ +package createnote + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain" +) + +type Request struct { + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` + AuthorUUID string `json:"-"` + Tags []string `json:"tags"` + LanguageCode string `json:"language_code"` + CorrelationUUID string `json:"correlation_uuid"` +} + +var _ domain.Validatable = &Request{} + +func (r *Request) Validate() domain.ValidationErrors { + validationErrors := make(domain.ValidationErrors) + + if len(r.Body) == 0 { + validationErrors["body"] = "required_field" + } + + if len(r.AuthorUUID) == 0 { + validationErrors["author_uuid"] = "required_field" + } + + if len(r.LanguageCode) == 0 { + validationErrors["language_code"] = "required_field" + } + + return validationErrors +} diff --git a/application/dashboard/note/createNote/response.go b/application/dashboard/note/createNote/response.go new file mode 100644 index 00000000..af6d5776 --- /dev/null +++ b/application/dashboard/note/createNote/response.go @@ -0,0 +1,10 @@ +package createnote + +import "github.com/khanzadimahdi/testproject/domain" + +type Response struct { + ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` + + CorrelationUUID string `json:"correlation_uuid,omitempty"` + LanguageCode string `json:"language_code,omitempty"` +} diff --git a/application/dashboard/note/createNote/usecase.go b/application/dashboard/note/createNote/usecase.go new file mode 100644 index 00000000..e3b68534 --- /dev/null +++ b/application/dashboard/note/createNote/usecase.go @@ -0,0 +1,90 @@ +package createnote + +import ( + "context" + + "github.com/gofrs/uuid/v5" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/translator" +) + +type UseCase struct { + noteRepository note.Repository + languageRepository language.Repository + validator domain.Validator + translator translator.Translator +} + +func NewUseCase( + noteRepository note.Repository, + languageRepository language.Repository, + validator domain.Validator, + translator translator.Translator, +) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + languageRepository: languageRepository, + validator: validator, + translator: translator, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + if validationErrors := uc.validator.Validate(request); len(validationErrors) > 0 { + return &Response{ + ValidationErrors: validationErrors, + }, nil + } + + if !uc.languageRepository.Exists(ctx, request.LanguageCode) { + return &Response{ + ValidationErrors: domain.ValidationErrors{ + "language_code": uc.translator.Translate("invalid_value"), + }, + }, nil + } + + if len(request.CorrelationUUID) > 0 { + exist, err := uc.noteRepository.CorrelationExist(ctx, request.CorrelationUUID) + if err != nil { + return nil, err + } + + if !exist { + return &Response{ + ValidationErrors: domain.ValidationErrors{ + "correlation_uuid": uc.translator.Translate("invalid_value"), + }, + }, nil + } + } + + // Generate a new CorrelationUUID if it's not provided in the request + if len(request.CorrelationUUID) == 0 { + correlationUUID, err := uuid.NewV7() + if err != nil { + return nil, err + } + request.CorrelationUUID = correlationUUID.String() + } + + n := note.Note{ + Body: request.Body, + PublishedAt: request.PublishedAt, + AuthorUUID: request.AuthorUUID, + Tags: request.Tags, + LanguageCode: request.LanguageCode, + CorrelationUUID: request.CorrelationUUID, + } + + if _, err := uc.noteRepository.Save(ctx, &n); err != nil { + return nil, err + } + + return &Response{ + CorrelationUUID: request.CorrelationUUID, + LanguageCode: n.LanguageCode, + }, nil +} diff --git a/application/dashboard/note/deleteNote/request.go b/application/dashboard/note/deleteNote/request.go new file mode 100644 index 00000000..f5a2f848 --- /dev/null +++ b/application/dashboard/note/deleteNote/request.go @@ -0,0 +1,9 @@ +package deletenote + +type Request struct { + CorrelationUUID string + LanguageCode string + // OwnerUUID scopes the deletion to a single author's own notes. It is empty + // on the routes guarded by the global notes permissions. + OwnerUUID string +} diff --git a/application/dashboard/note/deleteNote/usecase.go b/application/dashboard/note/deleteNote/usecase.go new file mode 100644 index 00000000..048d44cc --- /dev/null +++ b/application/dashboard/note/deleteNote/usecase.go @@ -0,0 +1,35 @@ +package deletenote + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/note" +) + +type UseCase struct { + noteRepository note.Repository +} + +func NewUseCase(noteRepository note.Repository) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) error { + if len(request.OwnerUUID) > 0 { + existing, err := uc.noteRepository.GetByCorrelationUUIDAndLanguage(ctx, request.CorrelationUUID, request.LanguageCode) + if err != nil { + return err + } + + // Scoped to the caller's own notes: someone else's note is treated as if + // it doesn't exist. + if existing.AuthorUUID != request.OwnerUUID { + return domain.ErrNotExists + } + } + + return uc.noteRepository.DeleteByCorrelationUUIDAndLanguage(ctx, request.CorrelationUUID, request.LanguageCode) +} diff --git a/application/dashboard/note/deleteNote/usecase_test.go b/application/dashboard/note/deleteNote/usecase_test.go new file mode 100644 index 00000000..34a71391 --- /dev/null +++ b/application/dashboard/note/deleteNote/usecase_test.go @@ -0,0 +1,77 @@ +package deletenote + +import ( + "context" + "testing" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("deletes a note", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + + request = Request{CorrelationUUID: "correlation-1", LanguageCode: "EN"} + ) + + notesRepository.On("DeleteByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository) + err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "GetByCorrelationUUIDAndLanguage") + + assert.NoError(t, err) + }) + + t.Run("deletes the owner's own note", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + + existing = note.Note{UUID: "note-uuid-1", AuthorUUID: "author-uuid-1"} + request = Request{CorrelationUUID: "correlation-1", LanguageCode: "EN", OwnerUUID: "author-uuid-1"} + ) + + notesRepository.On("GetByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(existing, nil) + notesRepository.On("DeleteByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository) + err := usecase.Execute(context.Background(), &request) + + assert.NoError(t, err) + }) + + t.Run("refuses to delete another author's note when scoped to the owner", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + + existing = note.Note{UUID: "note-uuid-1", AuthorUUID: "someone-else"} + request = Request{CorrelationUUID: "correlation-1", LanguageCode: "EN", OwnerUUID: "author-uuid-1"} + ) + + notesRepository.On("GetByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(existing, nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository) + err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "DeleteByCorrelationUUIDAndLanguage") + + assert.ErrorIs(t, err, domain.ErrNotExists) + }) +} diff --git a/application/dashboard/note/getNote/request.go b/application/dashboard/note/getNote/request.go new file mode 100644 index 00000000..e5a795c9 --- /dev/null +++ b/application/dashboard/note/getNote/request.go @@ -0,0 +1,9 @@ +package getnote + +type Request struct { + CorrelationUUID string + LanguageCode string + // OwnerUUID scopes the lookup to a single author's own notes. It is empty on + // the routes guarded by the global notes permissions. + OwnerUUID string +} diff --git a/application/dashboard/note/getNote/response.go b/application/dashboard/note/getNote/response.go new file mode 100644 index 00000000..2637f3f7 --- /dev/null +++ b/application/dashboard/note/getNote/response.go @@ -0,0 +1,43 @@ +package getnote + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type Response struct { + Body string `json:"body"` + PublishedAt string `json:"published_at"` + Author author `json:"author"` + Tags []string `json:"tags"` + LanguageCode string `json:"language_code"` + CorrelationUUID string `json:"correlation_uuid"` +} + +type author struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Username string `json:"username"` +} + +func NewResponse(n note.Note, u user.User) *Response { + tags := make([]string, len(n.Tags)) + copy(tags, n.Tags) + + return &Response{ + Body: n.Body, + PublishedAt: n.PublishedAt.Format(time.RFC3339), + Author: author{ + UUID: u.UUID, + Name: u.Name, + Avatar: u.Avatar, + Username: u.Username, + }, + Tags: tags, + LanguageCode: n.LanguageCode, + CorrelationUUID: n.CorrelationUUID, + } +} diff --git a/application/dashboard/note/getNote/usecase.go b/application/dashboard/note/getNote/usecase.go new file mode 100644 index 00000000..c7822c9d --- /dev/null +++ b/application/dashboard/note/getNote/usecase.go @@ -0,0 +1,42 @@ +package getnote + +import ( + "context" + "errors" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type UseCase struct { + noteRepository note.Repository + userRepository user.Repository +} + +func NewUseCase(noteRepository note.Repository, userRepository user.Repository) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + userRepository: userRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + n, err := uc.noteRepository.GetByCorrelationUUIDAndLanguage(ctx, request.CorrelationUUID, request.LanguageCode) + if err != nil { + return nil, err + } + + // Scoped to the caller's own notes: someone else's note is treated as if it + // doesn't exist. + if len(request.OwnerUUID) > 0 && n.AuthorUUID != request.OwnerUUID { + return nil, domain.ErrNotExists + } + + u, err := uc.userRepository.GetOne(ctx, n.AuthorUUID) + if err != nil && !errors.Is(err, domain.ErrNotExists) { + return nil, err + } + + return NewResponse(n, u), nil +} diff --git a/application/dashboard/note/getNotes/request.go b/application/dashboard/note/getNotes/request.go new file mode 100644 index 00000000..a54ebb24 --- /dev/null +++ b/application/dashboard/note/getNotes/request.go @@ -0,0 +1,9 @@ +package getnotes + +type Request struct { + Page uint + // AuthorUUID scopes the listing to a single author's own notes. It is empty + // on the routes guarded by the global notes permissions, which list every + // author's notes. + AuthorUUID string +} diff --git a/application/dashboard/note/getNotes/response.go b/application/dashboard/note/getNotes/response.go new file mode 100644 index 00000000..6d79d7f0 --- /dev/null +++ b/application/dashboard/note/getNotes/response.go @@ -0,0 +1,104 @@ +package getnotes + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type Response struct { + Items []noteResponse `json:"items"` + Pagination pagination `json:"pagination"` +} + +type noteResponse struct { + CorrelationUUID string `json:"correlation_uuid"` + CorrolatedItems []corrolatedNoteResponse `json:"corrolated_items"` +} + +type corrolatedNoteResponse struct { + Body string `json:"body"` + PublishedAt string `json:"published_at"` + + Author author `json:"author"` + Language languageResponse `json:"language"` +} + +type languageResponse struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type author struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Username string `json:"username"` +} + +type pagination struct { + TotalPages uint `json:"total_pages"` + CurrentPage uint `json:"current_page"` +} + +func NewResponse( + correlationUUIDs []string, + notes []note.Note, + authors []user.User, + languages []language.Language, + totalPages, currentPage uint, +) *Response { + authorByUUID := make(map[string]user.User, len(authors)) + for i := range authors { + authorByUUID[authors[i].UUID] = authors[i] + } + + languageByCode := make(map[string]language.Language, len(languages)) + for i := range languages { + languageByCode[languages[i].Code] = languages[i] + } + + // group the notes (one per language) under their correlation uuid, + // preserving the order notes arrive in within each group. + itemsByCorrelation := make(map[string][]corrolatedNoteResponse, len(correlationUUIDs)) + for i := range notes { + item := corrolatedNoteResponse{ + Body: notes[i].Body, + PublishedAt: notes[i].PublishedAt.Format(time.RFC3339), + Language: languageResponse{ + Code: notes[i].LanguageCode, + Name: languageByCode[notes[i].LanguageCode].Name, + }, + } + + if u, ok := authorByUUID[notes[i].AuthorUUID]; ok { + item.Author = author{ + UUID: u.UUID, + Name: u.Name, + Avatar: u.Avatar, + Username: u.Username, + } + } + + itemsByCorrelation[notes[i].CorrelationUUID] = append(itemsByCorrelation[notes[i].CorrelationUUID], item) + } + + // emit one entry per correlation uuid, keeping the page order. + items := make([]noteResponse, 0, len(correlationUUIDs)) + for _, correlationUUID := range correlationUUIDs { + items = append(items, noteResponse{ + CorrelationUUID: correlationUUID, + CorrolatedItems: itemsByCorrelation[correlationUUID], + }) + } + + return &Response{ + Items: items, + Pagination: pagination{ + TotalPages: totalPages, + CurrentPage: currentPage, + }, + } +} diff --git a/application/dashboard/note/getNotes/usecase.go b/application/dashboard/note/getNotes/usecase.go new file mode 100644 index 00000000..b1b86718 --- /dev/null +++ b/application/dashboard/note/getNotes/usecase.go @@ -0,0 +1,85 @@ +package getnotes + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +const limit = 20 + +type UseCase struct { + noteRepository note.Repository + userRepository user.Repository + languageRepository language.Repository +} + +func NewUseCase( + noteRepository note.Repository, + userRepository user.Repository, + languageRepository language.Repository, +) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + userRepository: userRepository, + languageRepository: languageRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + totalNotes, err := uc.noteRepository.CountByCorrelation(ctx, request.AuthorUUID) + if err != nil { + return nil, err + } + + currentPage := request.Page + if currentPage == 0 { + currentPage = 1 + } + + var offset uint = 0 + if currentPage > 0 { + offset = (currentPage - 1) * limit + } + + totalPages := totalNotes / limit + + if (totalPages * limit) != totalNotes { + totalPages++ + } + + correlationUUIDs, err := uc.noteRepository.GetCorrelationUUIDs(ctx, request.AuthorUUID, offset, limit) + if err != nil { + return nil, err + } + + if len(correlationUUIDs) == 0 { + return NewResponse(correlationUUIDs, nil, nil, nil, totalPages, currentPage), nil + } + + notes, err := uc.noteRepository.GetByCorrelationUUIDs(ctx, correlationUUIDs, "") + if err != nil { + return nil, err + } + + userUUIDs := make([]string, len(notes)) + languageCodes := make([]string, len(notes)) + for i := range notes { + userUUIDs[i] = notes[i].AuthorUUID + languageCodes[i] = notes[i].LanguageCode + } + + authors, err := uc.userRepository.GetByUUIDs(ctx, userUUIDs) + if err != nil { + return nil, err + } + + languages, err := uc.languageRepository.GetByCodes(ctx, languageCodes) + if err != nil { + return nil, err + } + + return NewResponse(correlationUUIDs, notes, authors, languages, totalPages, currentPage), nil +} diff --git a/application/dashboard/note/getNotes/usecase_test.go b/application/dashboard/note/getNotes/usecase_test.go new file mode 100644 index 00000000..08eb7878 --- /dev/null +++ b/application/dashboard/note/getNotes/usecase_test.go @@ -0,0 +1,107 @@ +package getnotes + +import ( + "context" + "errors" + "testing" + + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("lists every author's notes grouped by correlation uuid", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + + correlationUUIDs = []string{"correlation-1"} + n = []note.Note{ + {UUID: "note-uuid-1", CorrelationUUID: "correlation-1", Body: "body-en", LanguageCode: "EN", AuthorUUID: "author-uuid-1"}, + {UUID: "note-uuid-2", CorrelationUUID: "correlation-1", Body: "body-fa", LanguageCode: "FA", AuthorUUID: "author-uuid-1"}, + } + u = []user.User{{UUID: "author-uuid-1", Name: "author-name-1"}} + l = []language.Language{{Code: "EN", Name: "English"}, {Code: "FA", Name: "Persian"}} + request = Request{Page: 1} + ) + + notesRepository.On("CountByCorrelation", mock.Anything, "").Once().Return(uint(1), nil) + notesRepository.On("GetCorrelationUUIDs", mock.Anything, "", uint(0), uint(20)).Once().Return(correlationUUIDs, nil) + notesRepository.On("GetByCorrelationUUIDs", mock.Anything, correlationUUIDs, "").Once().Return(n, nil) + defer notesRepository.AssertExpectations(t) + + userRepository.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1", "author-uuid-1"}).Once().Return(u, nil) + defer userRepository.AssertExpectations(t) + + languagesRepository.On("GetByCodes", mock.Anything, []string{"EN", "FA"}).Once().Return(l, nil) + defer languagesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository) + response, err := usecase.Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Len(t, response.Items, 1) + assert.Len(t, response.Items[0].CorrolatedItems, 2) + assert.Equal(t, "English", response.Items[0].CorrolatedItems[0].Language.Name) + }) + + t.Run("scopes the listing to one author's own notes", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + + request = Request{Page: 1, AuthorUUID: "author-uuid-1"} + ) + + notesRepository.On("CountByCorrelation", mock.Anything, "author-uuid-1").Once().Return(uint(0), nil) + notesRepository.On("GetCorrelationUUIDs", mock.Anything, "author-uuid-1", uint(0), uint(20)).Once().Return([]string{}, nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository) + response, err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "GetByCorrelationUUIDs") + userRepository.AssertNotCalled(t, "GetByUUIDs") + + assert.NoError(t, err) + assert.Empty(t, response.Items) + }) + + t.Run("returns an error on counting notes", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + + expectedErr = errors.New("test error") + request = Request{Page: 1} + ) + + notesRepository.On("CountByCorrelation", mock.Anything, "").Once().Return(uint(0), expectedErr) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository) + response, err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "GetCorrelationUUIDs") + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/application/dashboard/note/updateNote/request.go b/application/dashboard/note/updateNote/request.go new file mode 100644 index 00000000..a677aa40 --- /dev/null +++ b/application/dashboard/note/updateNote/request.go @@ -0,0 +1,43 @@ +package updatenote + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain" +) + +type Request struct { + CorrelationUUID string `json:"correlation_uuid"` + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` + AuthorUUID string `json:"-"` + Tags []string `json:"tags"` + LanguageCode string `json:"language_code"` + // OwnerUUID scopes the update to a single author's own notes. It is empty on + // the routes guarded by the global notes permissions. + OwnerUUID string `json:"-"` +} + +var _ domain.Validatable = &Request{} + +func (r *Request) Validate() domain.ValidationErrors { + validationErrors := make(domain.ValidationErrors) + + if len(r.CorrelationUUID) == 0 { + validationErrors["correlation_uuid"] = "required_field" + } + + if len(r.Body) == 0 { + validationErrors["body"] = "required_field" + } + + if len(r.AuthorUUID) == 0 { + validationErrors["author"] = "required_field" + } + + if len(r.LanguageCode) == 0 { + validationErrors["language_code"] = "required_field" + } + + return validationErrors +} diff --git a/application/dashboard/note/updateNote/response.go b/application/dashboard/note/updateNote/response.go new file mode 100644 index 00000000..71e0e45b --- /dev/null +++ b/application/dashboard/note/updateNote/response.go @@ -0,0 +1,7 @@ +package updatenote + +import "github.com/khanzadimahdi/testproject/domain" + +type Response struct { + ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` +} diff --git a/application/dashboard/note/updateNote/usecase.go b/application/dashboard/note/updateNote/usecase.go new file mode 100644 index 00000000..5d047cca --- /dev/null +++ b/application/dashboard/note/updateNote/usecase.go @@ -0,0 +1,74 @@ +package updatenote + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/translator" +) + +type UseCase struct { + noteRepository note.Repository + languageRepository language.Repository + validator domain.Validator + translator translator.Translator +} + +func NewUseCase( + noteRepository note.Repository, + languageRepository language.Repository, + validator domain.Validator, + translator translator.Translator, +) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + languageRepository: languageRepository, + validator: validator, + translator: translator, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + if validationErrors := uc.validator.Validate(request); len(validationErrors) > 0 { + return &Response{ + ValidationErrors: validationErrors, + }, nil + } + + if !uc.languageRepository.Exists(ctx, request.LanguageCode) { + return &Response{ + ValidationErrors: domain.ValidationErrors{ + "language_code": uc.translator.Translate("invalid_value"), + }, + }, nil + } + + existing, err := uc.noteRepository.GetByCorrelationUUIDAndLanguage(ctx, request.CorrelationUUID, request.LanguageCode) + if err != nil { + return nil, err + } + + // Scoped to the caller's own notes: someone else's note is treated as if it + // doesn't exist. + if len(request.OwnerUUID) > 0 && existing.AuthorUUID != request.OwnerUUID { + return nil, domain.ErrNotExists + } + + n := note.Note{ + UUID: existing.UUID, + Body: request.Body, + PublishedAt: request.PublishedAt, + AuthorUUID: request.AuthorUUID, + Tags: request.Tags, + LanguageCode: request.LanguageCode, + CorrelationUUID: request.CorrelationUUID, + } + + if _, err := uc.noteRepository.Save(ctx, &n); err != nil { + return nil, err + } + + return nil, nil +} diff --git a/application/dashboard/note/updateNote/usecase_test.go b/application/dashboard/note/updateNote/usecase_test.go new file mode 100644 index 00000000..5de1e622 --- /dev/null +++ b/application/dashboard/note/updateNote/usecase_test.go @@ -0,0 +1,121 @@ +package updatenote + +import ( + "context" + "testing" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/khanzadimahdi/testproject/infrastructure/translator" + "github.com/khanzadimahdi/testproject/infrastructure/validator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("updates a note", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + languagesRepository languages.MockLanguagesRepository + validator validator.MockValidator + translator translator.TranslatorMock + + existing = note.Note{UUID: "note-uuid-1", CorrelationUUID: "correlation-1", AuthorUUID: "author-uuid-1"} + request = Request{ + CorrelationUUID: "correlation-1", + Body: "updated body", + AuthorUUID: "author-uuid-1", + LanguageCode: "EN", + } + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languagesRepository.On("Exists", mock.Anything, "EN").Once().Return(true) + defer languagesRepository.AssertExpectations(t) + + notesRepository.On("GetByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(existing, nil) + notesRepository.On("Save", mock.Anything, mock.Anything).Once().Return("note-uuid-1", nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &languagesRepository, &validator, &translator) + response, err := usecase.Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Nil(t, response) + }) + + t.Run("refuses to update another author's note when scoped to the owner", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + languagesRepository languages.MockLanguagesRepository + validator validator.MockValidator + translator translator.TranslatorMock + + existing = note.Note{UUID: "note-uuid-1", CorrelationUUID: "correlation-1", AuthorUUID: "someone-else"} + request = Request{ + CorrelationUUID: "correlation-1", + Body: "updated body", + AuthorUUID: "author-uuid-1", + OwnerUUID: "author-uuid-1", + LanguageCode: "EN", + } + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languagesRepository.On("Exists", mock.Anything, "EN").Once().Return(true) + defer languagesRepository.AssertExpectations(t) + + notesRepository.On("GetByCorrelationUUIDAndLanguage", mock.Anything, "correlation-1", "EN").Once().Return(existing, nil) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &languagesRepository, &validator, &translator) + response, err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "Save") + + assert.ErrorIs(t, err, domain.ErrNotExists) + assert.Nil(t, response) + }) + + t.Run("validation failed", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + languagesRepository languages.MockLanguagesRepository + validator validator.MockValidator + translator translator.TranslatorMock + + request = Request{} + expectedResponse = Response{ + ValidationErrors: domain.ValidationErrors{ + "body": "this field is required", + }, + } + ) + + validator.On("Validate", &request).Once().Return(expectedResponse.ValidationErrors) + defer validator.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &languagesRepository, &validator, &translator) + response, err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "GetByCorrelationUUIDAndLanguage") + notesRepository.AssertNotCalled(t, "Save") + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) +} diff --git a/application/article/getArticlesByHashtag/request.go b/application/hashtag/getContentsByHashtag/request.go similarity index 57% rename from application/article/getArticlesByHashtag/request.go rename to application/hashtag/getContentsByHashtag/request.go index 3cf592b5..537028be 100644 --- a/application/article/getArticlesByHashtag/request.go +++ b/application/hashtag/getContentsByHashtag/request.go @@ -1,4 +1,4 @@ -package getArticlesByHashtag +package getContentsByHashtag import ( "github.com/khanzadimahdi/testproject/domain" @@ -8,6 +8,9 @@ type Request struct { Hashtag string LanguageCode string Page uint + // Type selects which kind of content to list: TypeArticle or TypeNote. Empty + // lets the use case pick — see Response.Type. + Type string } var _ domain.Validatable = &Request{} @@ -19,5 +22,9 @@ func (r *Request) Validate() domain.ValidationErrors { validationErrors["hashtag"] = "required_field" } + if len(r.Type) > 0 && r.Type != TypeArticle && r.Type != TypeNote { + validationErrors["type"] = "invalid_value" + } + return validationErrors } diff --git a/application/article/getArticlesByHashtag/request_test.go b/application/hashtag/getContentsByHashtag/request_test.go similarity index 64% rename from application/article/getArticlesByHashtag/request_test.go rename to application/hashtag/getContentsByHashtag/request_test.go index 47f17a61..888e67df 100644 --- a/application/article/getArticlesByHashtag/request_test.go +++ b/application/hashtag/getContentsByHashtag/request_test.go @@ -1,4 +1,4 @@ -package getArticlesByHashtag +package getContentsByHashtag import ( "testing" @@ -47,6 +47,32 @@ func TestRequest_Validate(t *testing.T) { }, want: domain.ValidationErrors{}, }, + { + name: "valid request asking for the articles tab", + request: Request{ + Hashtag: "golang", + Type: TypeArticle, + }, + want: domain.ValidationErrors{}, + }, + { + name: "valid request asking for the notes tab", + request: Request{ + Hashtag: "golang", + Type: TypeNote, + }, + want: domain.ValidationErrors{}, + }, + { + name: "invalid request with an unknown type", + request: Request{ + Hashtag: "golang", + Type: "video", + }, + want: domain.ValidationErrors{ + "type": "invalid_value", + }, + }, } for _, tt := range tests { diff --git a/application/hashtag/getContentsByHashtag/response.go b/application/hashtag/getContentsByHashtag/response.go new file mode 100644 index 00000000..5e20ab2d --- /dev/null +++ b/application/hashtag/getContentsByHashtag/response.go @@ -0,0 +1,185 @@ +package getContentsByHashtag + +import ( + "time" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/article" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +// The kinds of content a hashtag page can list. +const ( + TypeArticle = "article" + TypeNote = "note" +) + +type Response struct { + ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` + + // Type is the kind of content Items holds, and the tab the page should show + // as selected — the requested one, or the one picked on the caller's behalf. + Type string `json:"type"` + // How much of each kind the hashtag has, so the page can label both tabs + // without asking twice. + Totals totalsResponse `json:"totals"` + Items []contentResponse `json:"items"` + LanguageCode languageResponse `json:"language_code"` + Elements []element.Response `json:"elements"` + Pagination paginationResponse `json:"pagination"` +} + +type totalsResponse struct { + Articles uint `json:"articles"` + Notes uint `json:"notes"` +} + +// contentResponse carries either an article or a note; Type says which, and the +// fields that don't apply to that type are omitted. +type contentResponse struct { + Type string `json:"type"` + CorrelationUUID string `json:"correlation_uuid"` + PublishedAt string `json:"published_at"` + Author authorResponse `json:"author"` + AvailableLanguages []languageResponse `json:"available_languages"` + + // articles only + Cover string `json:"cover,omitempty"` + Video string `json:"video,omitempty"` + Title string `json:"title,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + + // notes only + Body string `json:"body,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type authorResponse struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Username string `json:"username"` +} + +type languageResponse struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type paginationResponse struct { + TotalPages uint `json:"total_pages"` + CurrentPage uint `json:"current_page"` +} + +// Content is an article or a note, reduced to what the listing needs: the +// fields to render, plus the storage uuid and author to resolve afterwards. It +// lets one code path serve both tabs. +type Content struct { + UUID string + AuthorUUID string + CorrelationUUID string + + item contentResponse +} + +func NewArticleContent(a article.Article) Content { + return Content{ + UUID: a.UUID, + AuthorUUID: a.AuthorUUID, + CorrelationUUID: a.CorrelationUUID, + item: contentResponse{ + Type: TypeArticle, + CorrelationUUID: a.CorrelationUUID, + PublishedAt: a.PublishedAt.Format(time.RFC3339), + Cover: a.Cover, + Video: a.Video, + Title: a.Title, + Excerpt: a.Excerpt, + }, + } +} + +func NewNoteContent(n note.Note) Content { + tags := make([]string, len(n.Tags)) + copy(tags, n.Tags) + + return Content{ + UUID: n.UUID, + AuthorUUID: n.AuthorUUID, + CorrelationUUID: n.CorrelationUUID, + item: contentResponse{ + Type: TypeNote, + CorrelationUUID: n.CorrelationUUID, + PublishedAt: n.PublishedAt.Format(time.RFC3339), + Body: n.Body, + Tags: tags, + }, + } +} + +// IsNote reports whether the content is a note, so callers can look its +// published languages up in the right repository. +func (c Content) IsNote() bool { + return c.item.Type == TypeNote +} + +func NewResponse( + selectedType string, + contents []Content, + authors []user.User, + publishedLanguages map[string][]language.Language, + requestedLanguage language.Language, + elementsResponse []element.Response, + totalArticles, totalNotes uint, + totalPages, currentPage uint, +) *Response { + authorByUUID := make(map[string]user.User, len(authors)) + for i := range authors { + authorByUUID[authors[i].UUID] = authors[i] + } + + items := make([]contentResponse, len(contents)) + + for i := range contents { + items[i] = contents[i].item + + if u, ok := authorByUUID[contents[i].AuthorUUID]; ok { + items[i].Author = authorResponse{ + UUID: u.UUID, + Name: u.Name, + Avatar: u.Avatar, + Username: u.Username, + } + } + + if cl, ok := publishedLanguages[contents[i].UUID]; ok { + for l := range cl { + items[i].AvailableLanguages = append(items[i].AvailableLanguages, languageResponse{ + Code: cl[l].Code, + Name: cl[l].Name, + }) + } + } + } + + return &Response{ + Type: selectedType, + Totals: totalsResponse{ + Articles: totalArticles, + Notes: totalNotes, + }, + Items: items, + LanguageCode: languageResponse{ + Code: requestedLanguage.Code, + Name: requestedLanguage.Name, + }, + Elements: elementsResponse, + Pagination: paginationResponse{ + TotalPages: totalPages, + CurrentPage: currentPage, + }, + } +} diff --git a/application/hashtag/getContentsByHashtag/usecase.go b/application/hashtag/getContentsByHashtag/usecase.go new file mode 100644 index 00000000..034a451b --- /dev/null +++ b/application/hashtag/getContentsByHashtag/usecase.go @@ -0,0 +1,179 @@ +package getContentsByHashtag + +import ( + "context" + "fmt" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/application/language/resolver" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/article" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +const limit = 10 + +type UseCase struct { + articleRepository article.Repository + noteRepository note.Repository + userRepository user.Repository + languageRepository language.Repository + languageResolver resolver.Resolver + elementRetriever *element.Retriever + validator domain.Validator +} + +func NewUseCase( + articleRepository article.Repository, + noteRepository note.Repository, + userRepository user.Repository, + languageRepository language.Repository, + languageResolver resolver.Resolver, + elementRetriever *element.Retriever, + validator domain.Validator, +) *UseCase { + return &UseCase{ + articleRepository: articleRepository, + noteRepository: noteRepository, + userRepository: userRepository, + languageRepository: languageRepository, + languageResolver: languageResolver, + elementRetriever: elementRetriever, + validator: validator, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + if validationErrors := uc.validator.Validate(request); len(validationErrors) > 0 { + return &Response{ + ValidationErrors: validationErrors, + }, nil + } + + hashtags := []string{request.Hashtag} + + languageCode := request.LanguageCode + if len(languageCode) == 0 { + code, err := uc.languageResolver.DefaultCode(ctx) + if err != nil { + return nil, err + } + + languageCode = code + } + + l, err := uc.languageResolver.Resolve(ctx, languageCode) + if err != nil { + return nil, err + } + + totalArticles, err := uc.articleRepository.CountPublishedByHashtags(ctx, hashtags, languageCode) + if err != nil { + return nil, err + } + + totalNotes, err := uc.noteRepository.CountPublishedByHashtags(ctx, hashtags, languageCode) + if err != nil { + return nil, err + } + + // Each tab paginates on its own count. With no tab asked for, prefer + // articles, falling back to notes when the hashtag has none — so a hashtag + // used only on notes still opens on something. + selectedType := request.Type + if len(selectedType) == 0 { + if totalArticles == 0 && totalNotes > 0 { + selectedType = TypeNote + } else { + selectedType = TypeArticle + } + } + + total := totalArticles + if selectedType == TypeNote { + total = totalNotes + } + + currentPage := request.Page + if currentPage == 0 { + currentPage = 1 + } + + var offset uint = 0 + if currentPage > 0 { + offset = (currentPage - 1) * limit + } + + totalPages := total / limit + + if (totalPages * limit) != total { + totalPages++ + } + + var contents []Content + if selectedType == TypeNote { + n, err := uc.noteRepository.GetPublishedByHashtags(ctx, hashtags, languageCode, offset, limit) + if err != nil { + return nil, err + } + + contents = make([]Content, len(n)) + for i := range n { + contents[i] = NewNoteContent(n[i]) + } + } else { + a, err := uc.articleRepository.GetPublishedByHashtags(ctx, hashtags, languageCode, offset, limit) + if err != nil { + return nil, err + } + + contents = make([]Content, len(a)) + for i := range a { + contents[i] = NewArticleContent(a[i]) + } + } + + userUUIDs := make([]string, len(contents)) + for i := range contents { + userUUIDs[i] = contents[i].AuthorUUID + } + + authors, err := uc.userRepository.GetByUUIDs(ctx, userUUIDs) + if err != nil { + return nil, err + } + + elementsResponse, err := uc.elementRetriever.RetrieveByVenues( + ctx, + []string{fmt.Sprintf("/%s/hashtags/%s", languageCode, request.Hashtag)}, + languageCode, + ) + if err != nil { + return nil, err + } + + publishedLanguages := make(map[string][]language.Language, len(contents)) + for i := range contents { + var codes []string + var err error + + if contents[i].IsNote() { + codes, err = uc.noteRepository.GetPublishedLanguageCodes(ctx, contents[i].CorrelationUUID) + } else { + codes, err = uc.articleRepository.GetPublishedLanguageCodes(ctx, contents[i].CorrelationUUID) + } + if err != nil { + return nil, err + } + + cl, err := uc.languageRepository.GetByCodes(ctx, codes) + if err != nil { + return nil, err + } + publishedLanguages[contents[i].UUID] = cl + } + + return NewResponse(selectedType, contents, authors, publishedLanguages, l, elementsResponse, totalArticles, totalNotes, totalPages, currentPage), nil +} diff --git a/application/hashtag/getContentsByHashtag/usecase_test.go b/application/hashtag/getContentsByHashtag/usecase_test.go new file mode 100644 index 00000000..06a5e114 --- /dev/null +++ b/application/hashtag/getContentsByHashtag/usecase_test.go @@ -0,0 +1,301 @@ +package getContentsByHashtag + +import ( + "context" + "errors" + "testing" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/application/language/resolver" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/article" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/matcher" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" + "github.com/khanzadimahdi/testproject/infrastructure/validator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// mocks bundles the collaborators every subtest wires up. +type mocks struct { + articles articles.MockArticlesRepository + notes notes.MockNotesRepository + elements elements.MockElementsRepository + users users.MockUsersRepository + languages languages.MockLanguagesRepository + resolver resolver.MockResolver + validator validator.MockValidator +} + +func (m *mocks) useCase() *UseCase { + return NewUseCase( + &m.articles, + &m.notes, + &m.users, + &m.languages, + &m.resolver, + element.NewRetriever(&m.articles, &m.elements, &m.users, matcher.New()), + &m.validator, + ) +} + +// expectLanguage sets up the language resolution every successful run performs. +func (m *mocks) expectLanguage() { + m.resolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + m.resolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) +} + +// expectCounts sets up the per-tab totals, which every successful run reads to +// label the tabs and size the selected tab's pagination. +func (m *mocks) expectCounts(hashtag string, totalArticles, totalNotes uint) { + m.articles.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(totalArticles, nil) + m.notes.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(totalNotes, nil) +} + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + const hashtag = "test-hashtag" + + t.Run("defaults to the articles tab", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + a = []article.Article{ + {UUID: "test-article-1", AuthorUUID: "author-uuid-1"}, + {UUID: "test-article-2", AuthorUUID: "author-uuid-2"}, + } + u = []user.User{{UUID: "author-uuid-1"}, {UUID: "author-uuid-2"}} + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, uint(len(a)), 3) + + m.articles.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(a, nil) + m.articles.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) + m.elements.On("Count", mock.Anything).Once().Return(uint(0), nil) + m.users.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1", "author-uuid-2"}).Once().Return(u, nil) + m.languages.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) + + response, err := m.useCase().Execute(context.Background(), &request) + + // The notes tab is only counted, never fetched. + m.notes.AssertNotCalled(t, "GetPublishedByHashtags") + + assert.NoError(t, err) + assert.Equal(t, TypeArticle, response.Type) + assert.Equal(t, totalsResponse{Articles: 2, Notes: 3}, response.Totals) + assert.Len(t, response.Items, 2) + assert.Equal(t, TypeArticle, response.Items[0].Type) + }) + + t.Run("returns notes when the notes tab is requested", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + n = []note.Note{ + {UUID: "test-note-1", AuthorUUID: "author-uuid-1", Body: "note-body-1", Tags: []string{"tag-1"}}, + } + u = []user.User{{UUID: "author-uuid-1"}} + request = Request{Page: 1, Hashtag: hashtag, Type: TypeNote} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 5, uint(len(n))) + + m.notes.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(n, nil) + m.notes.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) + m.elements.On("Count", mock.Anything).Once().Return(uint(0), nil) + m.users.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1"}).Once().Return(u, nil) + m.languages.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) + + response, err := m.useCase().Execute(context.Background(), &request) + + m.articles.AssertNotCalled(t, "GetPublishedByHashtags") + + assert.NoError(t, err) + assert.Equal(t, TypeNote, response.Type) + assert.Equal(t, "note-body-1", response.Items[0].Body) + assert.Equal(t, []string{"tag-1"}, response.Items[0].Tags) + }) + + t.Run("falls back to notes when the hashtag has no articles", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + n = []note.Note{{UUID: "test-note-1", AuthorUUID: "author-uuid-1"}} + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 0, uint(len(n))) + + m.notes.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(n, nil) + m.notes.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) + m.elements.On("Count", mock.Anything).Once().Return(uint(0), nil) + m.users.On("GetByUUIDs", mock.Anything, mock.Anything).Once().Return([]user.User{}, nil) + m.languages.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) + + response, err := m.useCase().Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Equal(t, TypeNote, response.Type) + assert.Equal(t, uint(1), response.Pagination.TotalPages) + }) + + t.Run("stays on articles when the hashtag has neither", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 0, 0) + + m.articles.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return([]article.Article{}, nil) + m.elements.On("Count", mock.Anything).Once().Return(uint(0), nil) + m.users.On("GetByUUIDs", mock.Anything, []string{}).Once().Return([]user.User{}, nil) + + response, err := m.useCase().Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Equal(t, TypeArticle, response.Type) + assert.Empty(t, response.Items) + assert.Equal(t, uint(0), response.Pagination.TotalPages) + }) + + t.Run("paginates the selected tab on its own count", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + // 25 notes over a page size of 10 is three pages, regardless of how + // many articles carry the same hashtag. + request = Request{Page: 3, Hashtag: hashtag, Type: TypeNote} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 99, 25) + + m.notes.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(20), uint(10)).Once().Return([]note.Note{}, nil) + m.elements.On("Count", mock.Anything).Once().Return(uint(0), nil) + m.users.On("GetByUUIDs", mock.Anything, []string{}).Once().Return([]user.User{}, nil) + + response, err := m.useCase().Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Equal(t, uint(3), response.Pagination.TotalPages) + assert.Equal(t, uint(3), response.Pagination.CurrentPage) + assert.Equal(t, totalsResponse{Articles: 99, Notes: 25}, response.Totals) + }) + + t.Run("validation failed", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + request = Request{Page: 1, Hashtag: hashtag} + + expectedResponse = Response{ + ValidationErrors: domain.ValidationErrors{ + "hashtag": "this field is required", + }, + } + ) + + m.validator.On("Validate", &request).Once().Return(expectedResponse.ValidationErrors) + + response, err := m.useCase().Execute(context.Background(), &request) + + m.articles.AssertNotCalled(t, "CountPublishedByHashtags") + m.notes.AssertNotCalled(t, "CountPublishedByHashtags") + m.users.AssertNotCalled(t, "GetByUUIDs") + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("returns an error on counting items", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + expectedErr = errors.New("test error") + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.articles.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(0), expectedErr) + + response, err := m.useCase().Execute(context.Background(), &request) + + m.articles.AssertNotCalled(t, "GetPublishedByHashtags") + m.users.AssertNotCalled(t, "GetByUUIDs") + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) + + t.Run("returns an error on getting items", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + expectedErr = errors.New("test error") + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 5, 0) + m.articles.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(nil, expectedErr) + + response, err := m.useCase().Execute(context.Background(), &request) + + m.users.AssertNotCalled(t, "GetByUUIDs") + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) + + t.Run("returns an error on getting authors", func(t *testing.T) { + t.Parallel() + + var ( + m = mocks{} + expectedErr = errors.New("test error") + a = []article.Article{{UUID: "test-article-1", AuthorUUID: "author-uuid-1"}} + request = Request{Page: 1, Hashtag: hashtag} + ) + + m.validator.On("Validate", &request).Once().Return(nil) + m.expectLanguage() + m.expectCounts(hashtag, 1, 0) + m.articles.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(a, nil) + m.users.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1"}).Once().Return(nil, expectedErr) + + response, err := m.useCase().Execute(context.Background(), &request) + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/application/note/getNote/request.go b/application/note/getNote/request.go new file mode 100644 index 00000000..728a4aeb --- /dev/null +++ b/application/note/getNote/request.go @@ -0,0 +1,27 @@ +package getnote + +import ( + "github.com/gofrs/uuid/v5" + "github.com/khanzadimahdi/testproject/domain" +) + +type Request struct { + CorrelationUUID string + LanguageCode string +} + +var _ domain.Validatable = &Request{} + +func (r *Request) Validate() domain.ValidationErrors { + validationErrors := make(domain.ValidationErrors) + + if len(r.CorrelationUUID) == 0 { + validationErrors["correlation_uuid"] = "required_field" + } else { + if _, err := uuid.FromString(r.CorrelationUUID); err != nil { + validationErrors["correlation_uuid"] = "invalid_value" + } + } + + return validationErrors +} diff --git a/application/note/getNote/response.go b/application/note/getNote/response.go new file mode 100644 index 00000000..dc0cf903 --- /dev/null +++ b/application/note/getNote/response.go @@ -0,0 +1,67 @@ +package getnote + +import ( + "time" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type Response struct { + ValidationErrors map[string]string `json:"validation_errors,omitempty"` + + CorrelationUUID string `json:"correlation_uuid"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + Author authorResponse `json:"author"` + Tags []string `json:"tags"` + LanguageCode languageResponse `json:"language_code"` + AvailableLanguages []languageResponse `json:"available_languages"` + Elements []element.Response `json:"elements"` +} + +type authorResponse struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Username string `json:"username"` +} + +type languageResponse struct { + Code string `json:"code"` + Name string `json:"name"` +} + +func NewResponse(n note.Note, language language.Language, author user.User, availableLanguages []language.Language, elementsResponse []element.Response) *Response { + tags := make([]string, len(n.Tags)) + copy(tags, n.Tags) + + languages := make([]languageResponse, len(availableLanguages)) + for i, l := range availableLanguages { + languages[i] = languageResponse{ + Code: l.Code, + Name: l.Name, + } + } + + return &Response{ + CorrelationUUID: n.CorrelationUUID, + Body: n.Body, + PublishedAt: n.PublishedAt.Format(time.RFC3339), + Author: authorResponse{ + UUID: author.UUID, + Name: author.Name, + Avatar: author.Avatar, + Username: author.Username, + }, + Tags: tags, + LanguageCode: languageResponse{ + Code: language.Code, + Name: language.Name, + }, + AvailableLanguages: languages, + Elements: elementsResponse, + } +} diff --git a/application/note/getNote/usecase.go b/application/note/getNote/usecase.go new file mode 100644 index 00000000..cc94b96f --- /dev/null +++ b/application/note/getNote/usecase.go @@ -0,0 +1,95 @@ +package getnote + +import ( + "context" + "errors" + "fmt" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/application/language/resolver" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type UseCase struct { + noteRepository note.Repository + userRepository user.Repository + languageRepository language.Repository + languageResolver resolver.Resolver + elementRetriever *element.Retriever + validator domain.Validator +} + +func NewUseCase( + noteRepository note.Repository, + userRepository user.Repository, + languageRepository language.Repository, + languageResolver resolver.Resolver, + elementRetriever *element.Retriever, + validator domain.Validator, +) *UseCase { + return &UseCase{ + noteRepository: noteRepository, + userRepository: userRepository, + languageRepository: languageRepository, + languageResolver: languageResolver, + elementRetriever: elementRetriever, + validator: validator, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + if validationErrors := uc.validator.Validate(request); len(validationErrors) > 0 { + return &Response{ + ValidationErrors: validationErrors, + }, nil + } + + languageCode := request.LanguageCode + if len(languageCode) == 0 { + code, err := uc.languageResolver.DefaultCode(ctx) + if err != nil { + return nil, err + } + + languageCode = code + } + + l, err := uc.languageResolver.Resolve(ctx, languageCode) + if err != nil { + return nil, err + } + + n, err := uc.noteRepository.GetOnePublished(ctx, request.CorrelationUUID, languageCode) + if err != nil { + return nil, err + } + + author, err := uc.userRepository.GetOne(ctx, n.AuthorUUID) + if err != nil && !errors.Is(err, domain.ErrNotExists) { + return nil, err + } + + availableLanguageCodes, err := uc.noteRepository.GetPublishedLanguageCodes(ctx, n.CorrelationUUID) + if err != nil { + return nil, err + } + + availableLanguages, err := uc.languageRepository.GetByCodes(ctx, availableLanguageCodes) + if err != nil { + return nil, err + } + + elementsResponse, err := uc.elementRetriever.RetrieveByVenues( + ctx, + []string{fmt.Sprintf("/%s/notes/%s", languageCode, n.CorrelationUUID)}, + languageCode, + ) + if err != nil { + return nil, err + } + + return NewResponse(n, l, author, availableLanguages, elementsResponse), nil +} diff --git a/application/note/getNote/usecase_test.go b/application/note/getNote/usecase_test.go new file mode 100644 index 00000000..bfb5df2c --- /dev/null +++ b/application/note/getNote/usecase_test.go @@ -0,0 +1,145 @@ +package getnote + +import ( + "context" + "errors" + "testing" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/application/language/resolver" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/matcher" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" + "github.com/khanzadimahdi/testproject/infrastructure/validator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const correlationUUID = "3d6b0d70-2f7a-4c5f-9a51-1c48f2a4c2b1" + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("returns a published note", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + author = user.User{UUID: "author-uuid-1", Name: "author-name-1"} + n = note.Note{ + UUID: "note-uuid-1", + CorrelationUUID: correlationUUID, + Body: "note-body-1", + Tags: []string{"tag-1", "tag-2"}, + AuthorUUID: "author-uuid-1", + } + request = Request{CorrelationUUID: correlationUUID} + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) + defer languageResolver.AssertExpectations(t) + + notesRepository.On("GetOnePublished", mock.Anything, correlationUUID, "EN").Once().Return(n, nil) + notesRepository.On("GetPublishedLanguageCodes", mock.Anything, correlationUUID).Once().Return([]string{}, nil) + defer notesRepository.AssertExpectations(t) + + userRepository.On("GetOne", mock.Anything, "author-uuid-1").Once().Return(author, nil) + defer userRepository.AssertExpectations(t) + + languagesRepository.On("GetByCodes", mock.Anything, []string{}).Once().Return([]language.Language{}, nil) + elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Equal(t, "note-body-1", response.Body) + assert.Equal(t, []string{"tag-1", "tag-2"}, response.Tags) + assert.Equal(t, "author-uuid-1", response.Author.UUID) + }) + + t.Run("validation failed", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + request = Request{} + expectedResponse = Response{ + ValidationErrors: map[string]string{ + "correlation_uuid": "this field is required", + }, + } + ) + + validator.On("Validate", &request).Once().Return(domain.ValidationErrors(expectedResponse.ValidationErrors)) + defer validator.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + notesRepository.AssertNotCalled(t, "GetOnePublished") + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("returns an error on getting the note", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + expectedErr = errors.New("test error") + request = Request{CorrelationUUID: correlationUUID} + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) + defer languageResolver.AssertExpectations(t) + + notesRepository.On("GetOnePublished", mock.Anything, correlationUUID, "EN").Once().Return(note.Note{}, expectedErr) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + userRepository.AssertNotCalled(t, "GetOne") + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/application/note/getNotesByAuthor/request.go b/application/note/getNotesByAuthor/request.go new file mode 100644 index 00000000..97d2ce50 --- /dev/null +++ b/application/note/getNotesByAuthor/request.go @@ -0,0 +1,37 @@ +package getNotesByAuthor + +import ( + "github.com/gofrs/uuid/v5" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type Request struct { + AuthorUUID string + Username string + Page uint + LanguageCode string +} + +var _ domain.Validatable = &Request{} + +func (r *Request) Validate() domain.ValidationErrors { + validationErrors := make(domain.ValidationErrors) + + if len(r.AuthorUUID) == 0 && len(r.Username) == 0 { + validationErrors["author"] = "required_field" + } + + if len(r.AuthorUUID) > 0 { + if _, err := uuid.FromString(r.AuthorUUID); err != nil { + validationErrors["author_uuid"] = "invalid_value" + } + } + + if len(r.Username) > 0 && !user.IsValidUsername(r.Username) { + validationErrors["username"] = "invalid_value" + } + + return validationErrors +} diff --git a/application/note/getNotesByAuthor/response.go b/application/note/getNotesByAuthor/response.go new file mode 100644 index 00000000..e7863437 --- /dev/null +++ b/application/note/getNotesByAuthor/response.go @@ -0,0 +1,101 @@ +package getNotesByAuthor + +import ( + "time" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" +) + +type Response struct { + ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` + + Author authorResponse `json:"author"` + // How much of each kind the author has published, so the page can label both + // content tabs without asking twice. + Totals totalsResponse `json:"totals"` + LanguageCode languageResponse `json:"language_code"` + Items []noteResponse `json:"items"` + Elements []element.Response `json:"elements"` + Pagination paginationResponse `json:"pagination"` +} + +type noteResponse struct { + CorrelationUUID string `json:"correlation_uuid"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + Tags []string `json:"tags"` + AvailableLanguages []languageResponse `json:"available_languages"` +} + +type totalsResponse struct { + Articles uint `json:"articles"` + Notes uint `json:"notes"` +} + +type authorResponse struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Username string `json:"username"` + CreatedAt string `json:"created_at"` +} + +type languageResponse struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type paginationResponse struct { + TotalPages uint `json:"total_pages"` + CurrentPage uint `json:"current_page"` +} + +func NewResponse(author user.User, n []note.Note, notesPublishedLanguages map[string][]language.Language, requestedLanguage language.Language, elementsResponse []element.Response, totalArticles, totalNotes uint, totalPages, currentPage uint) *Response { + items := make([]noteResponse, len(n)) + + for i := range n { + items[i].CorrelationUUID = n[i].CorrelationUUID + items[i].Body = n[i].Body + items[i].PublishedAt = n[i].PublishedAt.Format(time.RFC3339) + + items[i].Tags = make([]string, len(n[i].Tags)) + copy(items[i].Tags, n[i].Tags) + + if nl, ok := notesPublishedLanguages[n[i].UUID]; ok { + for l := range nl { + items[i].AvailableLanguages = append(items[i].AvailableLanguages, languageResponse{ + Code: nl[l].Code, + Name: nl[l].Name, + }) + } + } + } + + return &Response{ + Author: authorResponse{ + UUID: author.UUID, + Name: author.Name, + Avatar: author.Avatar, + Username: author.Username, + CreatedAt: author.CreatedAt.Format(time.RFC3339), + }, + Totals: totalsResponse{ + Articles: totalArticles, + Notes: totalNotes, + }, + LanguageCode: languageResponse{ + Code: requestedLanguage.Code, + Name: requestedLanguage.Name, + }, + Items: items, + Elements: elementsResponse, + Pagination: paginationResponse{ + TotalPages: totalPages, + CurrentPage: currentPage, + }, + } +} diff --git a/application/article/getArticlesByHashtag/usecase.go b/application/note/getNotesByAuthor/usecase.go similarity index 62% rename from application/article/getArticlesByHashtag/usecase.go rename to application/note/getNotesByAuthor/usecase.go index 0512b717..19c7db65 100644 --- a/application/article/getArticlesByHashtag/usecase.go +++ b/application/note/getNotesByAuthor/usecase.go @@ -1,4 +1,4 @@ -package getArticlesByHashtag +package getNotesByAuthor import ( "context" @@ -9,12 +9,14 @@ import ( "github.com/khanzadimahdi/testproject/domain" "github.com/khanzadimahdi/testproject/domain/article" "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" "github.com/khanzadimahdi/testproject/domain/user" ) const limit = 10 type UseCase struct { + noteRepository note.Repository articleRepository article.Repository userRepository user.Repository languageRepository language.Repository @@ -24,6 +26,7 @@ type UseCase struct { } func NewUseCase( + noteRepository note.Repository, articleRepository article.Repository, userRepository user.Repository, languageRepository language.Repository, @@ -32,6 +35,7 @@ func NewUseCase( validator domain.Validator, ) *UseCase { return &UseCase{ + noteRepository: noteRepository, articleRepository: articleRepository, userRepository: userRepository, languageRepository: languageRepository, @@ -48,8 +52,6 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er }, nil } - hashtags := []string{request.Hashtag} - languageCode := request.LanguageCode if len(languageCode) == 0 { code, err := uc.languageResolver.DefaultCode(ctx) @@ -65,7 +67,18 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er return nil, err } - totalArticles, err := uc.articleRepository.CountPublishedByHashtags(ctx, hashtags, languageCode) + author, err := uc.resolveAuthor(ctx, request) + if err != nil { + return nil, err + } + + totalNotes, err := uc.noteRepository.CountPublishedByAuthor(ctx, author.UUID, languageCode) + if err != nil { + return nil, err + } + + // Only for the articles tab's label. + totalArticles, err := uc.articleRepository.CountPublishedByAuthor(ctx, author.UUID, languageCode) if err != nil { return nil, err } @@ -80,49 +93,47 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er offset = (currentPage - 1) * limit } - totalPages := totalArticles / limit + totalPages := totalNotes / limit - if (totalPages * limit) != totalArticles { + if (totalPages * limit) != totalNotes { totalPages++ } - a, err := uc.articleRepository.GetPublishedByHashtags(ctx, hashtags, languageCode, offset, limit) - if err != nil { - return nil, err - } - - userUUIDs := make([]string, len(a)) - for i := range a { - userUUIDs[i] = a[i].AuthorUUID - } - - authors, err := uc.userRepository.GetByUUIDs(ctx, userUUIDs) + n, err := uc.noteRepository.GetPublishedByAuthor(ctx, author.UUID, languageCode, offset, limit) if err != nil { return nil, err } elementsResponse, err := uc.elementRetriever.RetrieveByVenues( ctx, - []string{fmt.Sprintf("/%s/hashtags/%s", languageCode, request.Hashtag)}, + []string{fmt.Sprintf("/%s/authors/%s/notes", languageCode, author.UUID)}, languageCode, ) if err != nil { return nil, err } - publishedLanguages := make(map[string][]language.Language, len(a)) - for i := range a { - codes, err := uc.articleRepository.GetPublishedLanguageCodes(ctx, a[i].CorrelationUUID) + publishedLanguages := make(map[string][]language.Language, len(n)) + for i := range n { + codes, err := uc.noteRepository.GetPublishedLanguageCodes(ctx, n[i].CorrelationUUID) if err != nil { return nil, err } - al, err := uc.languageRepository.GetByCodes(ctx, codes) + nl, err := uc.languageRepository.GetByCodes(ctx, codes) if err != nil { return nil, err } - publishedLanguages[a[i].UUID] = al + publishedLanguages[n[i].UUID] = nl + } + + return NewResponse(author, n, publishedLanguages, l, elementsResponse, totalArticles, totalNotes, totalPages, currentPage), nil +} + +func (uc *UseCase) resolveAuthor(ctx context.Context, request *Request) (user.User, error) { + if len(request.AuthorUUID) > 0 { + return uc.userRepository.GetOne(ctx, request.AuthorUUID) } - return NewResponse(a, authors, publishedLanguages, l, elementsResponse, totalPages, currentPage), nil + return uc.userRepository.GetOneByIdentity(ctx, request.Username) } diff --git a/application/note/getNotesByAuthor/usecase_test.go b/application/note/getNotesByAuthor/usecase_test.go new file mode 100644 index 00000000..4c1443fa --- /dev/null +++ b/application/note/getNotesByAuthor/usecase_test.go @@ -0,0 +1,148 @@ +package getNotesByAuthor + +import ( + "context" + "errors" + "testing" + + "github.com/khanzadimahdi/testproject/application/element" + "github.com/khanzadimahdi/testproject/application/language/resolver" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/matcher" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" + "github.com/khanzadimahdi/testproject/infrastructure/validator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("returns an author's published notes", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + author = user.User{UUID: "author-uuid-1", Name: "author-name-1", Username: "author-1"} + n = []note.Note{ + {UUID: "note-uuid-1", CorrelationUUID: "note-correlation-1", Body: "note-body-1", Tags: []string{"tag-1"}}, + {UUID: "note-uuid-2", CorrelationUUID: "note-correlation-2", Body: "note-body-2"}, + } + request = Request{Page: 1, Username: "author-1"} + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) + defer languageResolver.AssertExpectations(t) + + userRepository.On("GetOneByIdentity", mock.Anything, "author-1").Once().Return(author, nil) + defer userRepository.AssertExpectations(t) + + notesRepository.On("CountPublishedByAuthor", mock.Anything, author.UUID, "EN").Once().Return(uint(len(n)), nil) + articlesRepository.On("CountPublishedByAuthor", mock.Anything, author.UUID, "EN").Once().Return(uint(0), nil) + notesRepository.On("GetPublishedByAuthor", mock.Anything, author.UUID, "EN", uint(0), uint(10)).Once().Return(n, nil) + notesRepository.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) + defer notesRepository.AssertExpectations(t) + + elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) + languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) + + usecase := NewUseCase(¬esRepository, &articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + assert.NoError(t, err) + assert.Len(t, response.Items, 2) + assert.Equal(t, "note-body-1", response.Items[0].Body) + assert.Equal(t, []string{"tag-1"}, response.Items[0].Tags) + assert.Equal(t, author.UUID, response.Author.UUID) + }) + + t.Run("validation failed", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + request = Request{Page: 1} + expectedResponse = Response{ + ValidationErrors: domain.ValidationErrors{ + "author": "this field is required", + }, + } + ) + + validator.On("Validate", &request).Once().Return(expectedResponse.ValidationErrors) + defer validator.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + userRepository.AssertNotCalled(t, "GetOneByIdentity") + notesRepository.AssertNotCalled(t, "GetPublishedByAuthor") + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("returns an error on getting notes", func(t *testing.T) { + t.Parallel() + + var ( + notesRepository notes.MockNotesRepository + articlesRepository articles.MockArticlesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + + author = user.User{UUID: "author-uuid-1", Username: "author-1"} + expectedErr = errors.New("test error") + request = Request{Page: 1, Username: "author-1"} + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN"}, nil) + defer languageResolver.AssertExpectations(t) + + userRepository.On("GetOneByIdentity", mock.Anything, "author-1").Once().Return(author, nil) + defer userRepository.AssertExpectations(t) + + notesRepository.On("CountPublishedByAuthor", mock.Anything, author.UUID, "EN").Once().Return(uint(1), nil) + articlesRepository.On("CountPublishedByAuthor", mock.Anything, author.UUID, "EN").Once().Return(uint(0), nil) + notesRepository.On("GetPublishedByAuthor", mock.Anything, author.UUID, "EN", uint(0), uint(10)).Once().Return(nil, expectedErr) + defer notesRepository.AssertExpectations(t) + + usecase := NewUseCase(¬esRepository, &articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &validator) + response, err := usecase.Execute(context.Background(), &request) + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/domain/bookmark/bookmark.go b/domain/bookmark/bookmark.go index 358dab58..861696e3 100644 --- a/domain/bookmark/bookmark.go +++ b/domain/bookmark/bookmark.go @@ -7,8 +7,14 @@ import ( const ( ObjectTypeArticle = "article" + ObjectTypeNote = "note" ) +// IsValidObjectType reports whether s names something that can be bookmarked. +func IsValidObjectType(s string) bool { + return s == ObjectTypeArticle || s == ObjectTypeNote +} + type Bookmark struct { UUID string Title string diff --git a/domain/comment/comment.go b/domain/comment/comment.go index 9181973a..bf6bcfcd 100644 --- a/domain/comment/comment.go +++ b/domain/comment/comment.go @@ -8,8 +8,15 @@ import ( const ( ObjectTypeArticle = "article" + ObjectTypeNote = "note" ) +// IsValidObjectType reports whether s names something comments can be attached +// to. +func IsValidObjectType(s string) bool { + return s == ObjectTypeArticle || s == ObjectTypeNote +} + type Comment struct { UUID string Body string diff --git a/domain/note/note.go b/domain/note/note.go new file mode 100644 index 00000000..b8b61584 --- /dev/null +++ b/domain/note/note.go @@ -0,0 +1,43 @@ +package note + +import ( + "context" + "time" +) + +// Note is a short, body-only piece of content published by a user. Like an +// article, a note's identity is its CorrelationUUID and each language version +// is a separate document keyed by (correlationUUID, languageCode). +type Note struct { + UUID string + Body string + PublishedAt time.Time + AuthorUUID string + Tags []string + LanguageCode string + CorrelationUUID string +} + +type Repository interface { + // An empty authorUUID means "every author"; a non-empty one scopes the + // query to that author's own notes (the dashboard's "my notes" listing). + GetCorrelationUUIDs(ctx context.Context, authorUUID string, offset uint, limit uint) ([]string, error) + CountByCorrelation(ctx context.Context, authorUUID string) (uint, error) + + GetByCorrelationUUIDs(ctx context.Context, correlationUUIDs []string, languageCode string) ([]Note, error) + GetByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) (Note, error) + GetOnePublished(ctx context.Context, correlationUUID string, languageCode string) (Note, error) + GetPublishedLanguageCodes(ctx context.Context, correlationUUID string) ([]string, error) + + // excludedAuthorUUIDs drops notes whose author keeps their notes private, so + // the count and the page stay consistent with each other. + CountPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string) (uint, error) + GetPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string, offset uint, limit uint) ([]Note, error) + + CountPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string) (uint, error) + GetPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string, offset uint, limit uint) ([]Note, error) + + CorrelationExist(ctx context.Context, correlationUUID string) (bool, error) + Save(ctx context.Context, n *Note) (string, error) + DeleteByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) error +} diff --git a/domain/permission/permission.go b/domain/permission/permission.go index 33c72967..bb5ac013 100644 --- a/domain/permission/permission.go +++ b/domain/permission/permission.go @@ -20,6 +20,12 @@ const ( ArticlesUpdate = "articles.update" ArticlesDelete = "articles.delete" + NotesIndex = "notes.index" + NotesCreate = "notes.create" + NotesShow = "notes.show" + NotesUpdate = "notes.update" + NotesDelete = "notes.delete" + CommentsIndex = "comments.index" CommentsCreate = "comments.create" CommentsShow = "comments.show" @@ -74,4 +80,10 @@ const ( SelfFilesIndex = "self.files.index" SelfFilesDelete = "self.files.delete" + + SelfNotesIndex = "self.notes.index" + SelfNotesCreate = "self.notes.create" + SelfNotesShow = "self.notes.show" + SelfNotesUpdate = "self.notes.update" + SelfNotesDelete = "self.notes.delete" ) diff --git a/infrastructure/ioc/providers/blog.go b/infrastructure/ioc/providers/blog.go index b51ac2a5..7a878063 100644 --- a/infrastructure/ioc/providers/blog.go +++ b/infrastructure/ioc/providers/blog.go @@ -15,7 +15,6 @@ import ( getArticle "github.com/khanzadimahdi/testproject/application/article/getArticle" getArticles "github.com/khanzadimahdi/testproject/application/article/getArticles" "github.com/khanzadimahdi/testproject/application/article/getArticlesByAuthor" - "github.com/khanzadimahdi/testproject/application/article/getArticlesByHashtag" "github.com/khanzadimahdi/testproject/application/auth" "github.com/khanzadimahdi/testproject/application/auth/forgetpassword" "github.com/khanzadimahdi/testproject/application/auth/login" @@ -63,6 +62,11 @@ import ( dashboardGetLanguage "github.com/khanzadimahdi/testproject/application/dashboard/language/getLanguage" dashboardGetLanguages "github.com/khanzadimahdi/testproject/application/dashboard/language/getLanguages" dashboardUpdateLanguage "github.com/khanzadimahdi/testproject/application/dashboard/language/updateLanguage" + dashboardCreateNote "github.com/khanzadimahdi/testproject/application/dashboard/note/createNote" + dashboardDeleteNote "github.com/khanzadimahdi/testproject/application/dashboard/note/deleteNote" + dashboardGetNote "github.com/khanzadimahdi/testproject/application/dashboard/note/getNote" + dashboardGetNotes "github.com/khanzadimahdi/testproject/application/dashboard/note/getNotes" + dashboardUpdateNote "github.com/khanzadimahdi/testproject/application/dashboard/note/updateNote" dashboardGetPermissions "github.com/khanzadimahdi/testproject/application/dashboard/permission/getPermissions" "github.com/khanzadimahdi/testproject/application/dashboard/profile/changepassword" "github.com/khanzadimahdi/testproject/application/dashboard/profile/getRoles" @@ -81,10 +85,13 @@ import ( "github.com/khanzadimahdi/testproject/application/dashboard/user/userchangepassword" "github.com/khanzadimahdi/testproject/application/element" getFile "github.com/khanzadimahdi/testproject/application/file/getFile" + "github.com/khanzadimahdi/testproject/application/hashtag/getContentsByHashtag" "github.com/khanzadimahdi/testproject/application/home" getLanguages "github.com/khanzadimahdi/testproject/application/language/getLanguages" languageresolver "github.com/khanzadimahdi/testproject/application/language/resolver" "github.com/khanzadimahdi/testproject/application/localize" + getNote "github.com/khanzadimahdi/testproject/application/note/getNote" + "github.com/khanzadimahdi/testproject/application/note/getNotesByAuthor" "github.com/khanzadimahdi/testproject/domain" "github.com/khanzadimahdi/testproject/domain/file" "github.com/khanzadimahdi/testproject/domain/password" @@ -103,6 +110,7 @@ import ( elementsrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/elements" filesrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/files" languagesrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/languages" + notesrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/notes" permissionsrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/permissions" rolesrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/roles" userrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/users" @@ -111,6 +119,7 @@ import ( articleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/article" authAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/auth" authorArticleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/author/article" + authorNoteAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/author/note" bookmarkAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/bookmark" commentAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/comment" dashboardArticleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/article" @@ -120,6 +129,7 @@ import ( dashboardElementAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/element" dashboardFileAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/file" dashboardLanguageAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/language" + dashboardNoteAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/note" dashboardPermissionAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/permission" "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/profile" dashboardRoleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/role" @@ -128,6 +138,7 @@ import ( hashtagAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/hashtag" homeapi "github.com/khanzadimahdi/testproject/presentation/http/blog/api/home" languageAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/language" + noteAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/note" "github.com/khanzadimahdi/testproject/presentation/http/blog/openapi" "github.com/khanzadimahdi/testproject/presentation/http/middleware" "github.com/nats-io/nats.go" @@ -300,6 +311,7 @@ func blog( } articlesRepository := articlesrepository.NewRepository(database) + notesRepository := notesrepository.NewRepository(database) commentsRepository := commentsrepository.NewRepository(database) filesRepository := filesrepository.NewRepository(database) elementsRepository := elementsrepository.NewRepository(database) @@ -363,6 +375,10 @@ func blog( dashboardGetArticleUsecase := dashboardGetArticle.NewUseCase(articlesRepository, userRepository) dashboardGetArticlesUsecase := dashboardGetArticles.NewUseCase(articlesRepository, userRepository, languageRepository) + dashboardDeleteNoteUsecase := dashboardDeleteNote.NewUseCase(notesRepository) + dashboardGetNoteUsecase := dashboardGetNote.NewUseCase(notesRepository, userRepository) + dashboardGetNotesUsecase := dashboardGetNotes.NewUseCase(notesRepository, userRepository, languageRepository) + dashboardDeleteCommentUsecase := dashboardDeleteComment.NewUseCase(commentsRepository) dashboardGetCommentUsecase := dashboardGetComment.NewUseCase(commentsRepository, userRepository) dashboardGetCommentsUsecase := dashboardGetComments.NewUseCase(commentsRepository, userRepository) @@ -437,6 +453,11 @@ func blog( return articleAPI.NewShowHandler(getArticle.NewUseCase(articlesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) }), httpCache)) + // notes + mux.Handle("GET /api/notes/{uuid}", middleware.NewCacheMiddleware(scoped(func(c provider.Container) http.Handler { + return noteAPI.NewShowHandler(getNote.NewUseCase(notesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) + }), httpCache)) + // comments mux.Handle("POST /api/comments", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return commentAPI.NewCreateHandler(createComment.NewUseCase(commentsRepository, va(c))) @@ -458,12 +479,15 @@ func blog( // hashtags mux.Handle("GET /api/hashtags/{hashtag}", middleware.NewCacheMiddleware(scoped(func(c provider.Container) http.Handler { - return hashtagAPI.NewShowHandler(getArticlesByHashtag.NewUseCase(articlesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) + return hashtagAPI.NewShowHandler(getContentsByHashtag.NewUseCase(articlesRepository, notesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) }), httpCache)) // authors mux.Handle("GET /api/authors/{identity}/articles", middleware.NewCacheMiddleware(scoped(func(c provider.Container) http.Handler { - return authorArticleAPI.NewIndexHandler(getArticlesByAuthor.NewUseCase(articlesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) + return authorArticleAPI.NewIndexHandler(getArticlesByAuthor.NewUseCase(articlesRepository, notesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) + }), httpCache)) + mux.Handle("GET /api/authors/{identity}/notes", middleware.NewCacheMiddleware(scoped(func(c provider.Container) http.Handler { + return authorNoteAPI.NewIndexHandler(getNotesByAuthor.NewUseCase(notesRepository, articlesRepository, userRepository, languageRepository, languageResolver, elementRetriever, va(c))) }), httpCache)) // files @@ -531,6 +555,28 @@ func blog( return dashboardArticleAPI.NewUpdateHandler(dashboardUpdateArticle.NewUseCase(articlesRepository, languageRepository, va(c), tr(c))) }), authorizer, permission.ArticlesUpdate), jwt, userRepository)) + // notes + mux.Handle("POST /api/dashboard/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { + return dashboardNoteAPI.NewCreateHandler(dashboardCreateNote.NewUseCase(notesRepository, languageRepository, va(c), tr(c))) + }), authorizer, permission.NotesCreate), jwt, userRepository)) + mux.Handle("DELETE /api/dashboard/notes/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewDeleteHandler(dashboardDeleteNoteUsecase), authorizer, permission.NotesDelete), jwt, userRepository)) + mux.Handle("GET /api/dashboard/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewIndexHandler(dashboardGetNotesUsecase), authorizer, permission.NotesIndex), jwt, userRepository)) + mux.Handle("GET /api/dashboard/notes/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewShowHandler(dashboardGetNoteUsecase), authorizer, permission.NotesShow), jwt, userRepository)) + mux.Handle("PUT /api/dashboard/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { + return dashboardNoteAPI.NewUpdateHandler(dashboardUpdateNote.NewUseCase(notesRepository, languageRepository, va(c), tr(c))) + }), authorizer, permission.NotesUpdate), jwt, userRepository)) + + // self notes: same operations, scoped to the current user's own notes + mux.Handle("POST /api/dashboard/my/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { + return dashboardNoteAPI.NewCreateHandler(dashboardCreateNote.NewUseCase(notesRepository, languageRepository, va(c), tr(c))) + }), authorizer, permission.SelfNotesCreate), jwt, userRepository)) + mux.Handle("DELETE /api/dashboard/my/notes/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewDeleteUserNoteHandler(dashboardDeleteNoteUsecase), authorizer, permission.SelfNotesDelete), jwt, userRepository)) + mux.Handle("GET /api/dashboard/my/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewIndexUserNotesHandler(dashboardGetNotesUsecase), authorizer, permission.SelfNotesIndex), jwt, userRepository)) + mux.Handle("GET /api/dashboard/my/notes/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardNoteAPI.NewShowUserNoteHandler(dashboardGetNoteUsecase), authorizer, permission.SelfNotesShow), jwt, userRepository)) + mux.Handle("PUT /api/dashboard/my/notes", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { + return dashboardNoteAPI.NewUpdateUserNoteHandler(dashboardUpdateNote.NewUseCase(notesRepository, languageRepository, va(c), tr(c))) + }), authorizer, permission.SelfNotesUpdate), jwt, userRepository)) + // comments mux.Handle("POST /api/dashboard/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardCommentAPI.NewCreateHandler(dashboardCreateComment.NewUseCase(commentsRepository, va(c))) diff --git a/infrastructure/repository/mocks/notes/repository.go b/infrastructure/repository/mocks/notes/repository.go new file mode 100644 index 00000000..19bc8bf1 --- /dev/null +++ b/infrastructure/repository/mocks/notes/repository.go @@ -0,0 +1,113 @@ +package notes + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain/note" +) + +type MockNotesRepository struct { + mock.Mock +} + +var _ note.Repository = &MockNotesRepository{} + +func (r *MockNotesRepository) GetCorrelationUUIDs(ctx context.Context, authorUUID string, offset uint, limit uint) ([]string, error) { + args := r.Mock.Called(ctx, authorUUID, offset, limit) + + if n, ok := args.Get(0).([]string); ok { + return n, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockNotesRepository) CountByCorrelation(ctx context.Context, authorUUID string) (uint, error) { + args := r.Mock.Called(ctx, authorUUID) + + return args.Get(0).(uint), args.Error(1) +} + +func (r *MockNotesRepository) GetByCorrelationUUIDs(ctx context.Context, correlationUUIDs []string, languageCode string) ([]note.Note, error) { + args := r.Mock.Called(ctx, correlationUUIDs, languageCode) + + if n, ok := args.Get(0).([]note.Note); ok { + return n, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockNotesRepository) GetByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) (note.Note, error) { + args := r.Mock.Called(ctx, correlationUUID, languageCode) + + return args.Get(0).(note.Note), args.Error(1) +} + +func (r *MockNotesRepository) GetOnePublished(ctx context.Context, correlationUUID string, languageCode string) (note.Note, error) { + args := r.Mock.Called(ctx, correlationUUID, languageCode) + + return args.Get(0).(note.Note), args.Error(1) +} + +func (r *MockNotesRepository) GetPublishedLanguageCodes(ctx context.Context, correlationUUID string) ([]string, error) { + args := r.Mock.Called(ctx, correlationUUID) + + if c, ok := args.Get(0).([]string); ok { + return c, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockNotesRepository) CountPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string) (uint, error) { + args := r.Mock.Called(ctx, hashtags, languageCode) + + return args.Get(0).(uint), args.Error(1) +} + +func (r *MockNotesRepository) GetPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string, offset uint, limit uint) ([]note.Note, error) { + args := r.Mock.Called(ctx, hashtags, languageCode, offset, limit) + + if n, ok := args.Get(0).([]note.Note); ok { + return n, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockNotesRepository) CountPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string) (uint, error) { + args := r.Mock.Called(ctx, authorUUID, languageCode) + + return args.Get(0).(uint), args.Error(1) +} + +func (r *MockNotesRepository) GetPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string, offset uint, limit uint) ([]note.Note, error) { + args := r.Mock.Called(ctx, authorUUID, languageCode, offset, limit) + + if n, ok := args.Get(0).([]note.Note); ok { + return n, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockNotesRepository) CorrelationExist(ctx context.Context, correlationUUID string) (bool, error) { + args := r.Mock.Called(ctx, correlationUUID) + + return args.Bool(0), args.Error(1) +} + +func (r *MockNotesRepository) Save(ctx context.Context, n *note.Note) (string, error) { + args := r.Mock.Called(ctx, n) + + return args.Get(0).(string), args.Error(1) +} + +func (r *MockNotesRepository) DeleteByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) error { + args := r.Mock.Called(ctx, correlationUUID, languageCode) + + return args.Error(0) +} diff --git a/infrastructure/repository/mongodb/notes/model.go b/infrastructure/repository/mongodb/notes/model.go new file mode 100644 index 00000000..90a4e264 --- /dev/null +++ b/infrastructure/repository/mongodb/notes/model.go @@ -0,0 +1,17 @@ +package notes + +import ( + "time" +) + +type NoteBson struct { + UUID string `bson:"_id,omitempty"` + Body string `bson:"body"` + PublishedAt time.Time `bson:"published_at"` + AuthorUUID string `bson:"author_uuid"` + Tags []string `bson:"tags"` + LanguageCode string `bson:"language_code"` + CorrelationUUID string `bson:"correlation_uuid"` + CreatedAt time.Time `bson:"created_at,omitempty"` + UpdatedAt time.Time `bson:"updated_at,omitempty"` +} diff --git a/infrastructure/repository/mongodb/notes/repository.go b/infrastructure/repository/mongodb/notes/repository.go new file mode 100644 index 00000000..cd5ae5ff --- /dev/null +++ b/infrastructure/repository/mongodb/notes/repository.go @@ -0,0 +1,427 @@ +package notes + +import ( + "context" + "errors" + "time" + + "github.com/gofrs/uuid/v5" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/note" +) + +const ( + collectionName = "notes" + queryTimeout = 3 * time.Second +) + +type NotesRepository struct { + collection *mongo.Collection +} + +var _ note.Repository = &NotesRepository{} + +func NewRepository(database *mongo.Database) *NotesRepository { + if database == nil { + panic("database should not be nil") + } + + return &NotesRepository{ + collection: database.Collection(collectionName), + } +} + +func toDomain(n NoteBson) note.Note { + return note.Note{ + UUID: n.UUID, + Body: n.Body, + PublishedAt: n.PublishedAt, + AuthorUUID: n.AuthorUUID, + Tags: n.Tags, + LanguageCode: n.LanguageCode, + CorrelationUUID: n.CorrelationUUID, + } +} + +func (r *NotesRepository) GetCorrelationUUIDs(ctx context.Context, authorUUID string, offset uint, limit uint) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + // One entry per correlation group, ordered by the newest note in the group + // (its max _id, which is time-ordered) so paging is deterministic. + // "$gt: \"\"" keeps only non-empty strings (excluding "", null and missing) + // and, being a range predicate, can use an index unlike $ne/$nin/$expr. + match := bson.M{"correlation_uuid": bson.M{"$gt": ""}} + if len(authorUUID) > 0 { + match["author_uuid"] = authorUUID + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: match}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "_id", Value: -1}}}}, + bson.D{{Key: "$group", Value: bson.M{ + "_id": "$correlation_uuid", + "max_id": bson.M{"$first": "$_id"}, + }}}, + bson.D{{Key: "$sort", Value: bson.D{{Key: "max_id", Value: -1}}}}, + bson.D{{Key: "$skip", Value: int64(offset)}}, + bson.D{{Key: "$limit", Value: int64(limit)}}, + } + + cur, err := r.collection.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + correlationUUIDs := make([]string, 0, limit) + for cur.Next(ctx) { + var doc struct { + CorrelationUUID string `bson:"_id"` + } + + if err := cur.Decode(&doc); err != nil { + return nil, err + } + correlationUUIDs = append(correlationUUIDs, doc.CorrelationUUID) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + return correlationUUIDs, nil +} + +func (r *NotesRepository) CountByCorrelation(ctx context.Context, authorUUID string) (uint, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + match := bson.M{"correlation_uuid": bson.M{"$gt": ""}} + if len(authorUUID) > 0 { + match["author_uuid"] = authorUUID + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: match}}, + bson.D{{Key: "$group", Value: bson.M{"_id": "$correlation_uuid"}}}, + bson.D{{Key: "$count", Value: "count"}}, + } + + cur, err := r.collection.Aggregate(ctx, pipeline) + if err != nil { + return 0, err + } + defer cur.Close(ctx) + + var result struct { + Count uint `bson:"count"` + } + if cur.Next(ctx) { + if err := cur.Decode(&result); err != nil { + return 0, err + } + } + + if err := cur.Err(); err != nil { + return 0, err + } + + return result.Count, nil +} + +func (r *NotesRepository) GetByCorrelationUUIDs(ctx context.Context, correlationUUIDs []string, languageCode string) ([]note.Note, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + desc := bson.D{{Key: "published_at", Value: -1}} + filter := bson.M{"correlation_uuid": bson.M{"$in": correlationUUIDs}} + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + cur, err := r.collection.Find(ctx, filter, options.Find().SetSort(desc)) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + items := make([]note.Note, 0, len(correlationUUIDs)) + for cur.Next(ctx) { + var n NoteBson + + if err := cur.Decode(&n); err != nil { + return nil, err + } + items = append(items, toDomain(n)) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func (r *NotesRepository) GetByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) (note.Note, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + filter := bson.M{"correlation_uuid": correlationUUID} + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + var n NoteBson + if err := r.collection.FindOne(ctx, filter).Decode(&n); err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + err = domain.ErrNotExists + } + return note.Note{}, err + } + + return toDomain(n), nil +} + +func (r *NotesRepository) GetOnePublished(ctx context.Context, correlationUUID string, languageCode string) (note.Note, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + filter := bson.M{ + "correlation_uuid": correlationUUID, + "published_at": publishedFilter(), + } + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + var n NoteBson + if err := r.collection.FindOne(ctx, filter).Decode(&n); err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + err = domain.ErrNotExists + } + return note.Note{}, err + } + + return toDomain(n), nil +} + +func (r *NotesRepository) GetPublishedLanguageCodes(ctx context.Context, correlationUUID string) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + if len(correlationUUID) == 0 { + return []string{}, nil + } + + filter := bson.M{ + "correlation_uuid": correlationUUID, + "published_at": publishedFilter(), + } + + languageCodes := make([]string, 0, 2) + if err := r.collection.Distinct(ctx, "language_code", filter).Decode(&languageCodes); err != nil { + return nil, err + } + + return languageCodes, nil +} + +func (r *NotesRepository) CountPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string) (uint, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + c, err := r.collection.CountDocuments(ctx, publishedByHashtagsFilter(hashtags, languageCode)) + if err != nil { + return uint(c), err + } + + return uint(c), nil +} + +func (r *NotesRepository) GetPublishedByHashtags(ctx context.Context, hashtags []string, languageCode string, offset uint, limit uint) ([]note.Note, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + o := int64(offset) + l := int64(limit) + desc := bson.D{{Key: "published_at", Value: -1}} + + cur, err := r.collection.Find( + ctx, + publishedByHashtagsFilter(hashtags, languageCode), + options.Find().SetLimit(l).SetSkip(o).SetSort(desc), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + items := make([]note.Note, 0, limit) + for cur.Next(ctx) { + var n NoteBson + + if err := cur.Decode(&n); err != nil { + return nil, err + } + items = append(items, toDomain(n)) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func (r *NotesRepository) CountPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string) (uint, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + filter := bson.M{ + "author_uuid": authorUUID, + "published_at": publishedFilter(), + } + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + c, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + return uint(c), err + } + + return uint(c), nil +} + +func (r *NotesRepository) GetPublishedByAuthor(ctx context.Context, authorUUID string, languageCode string, offset uint, limit uint) ([]note.Note, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + o := int64(offset) + l := int64(limit) + desc := bson.D{{Key: "published_at", Value: -1}} + filter := bson.M{ + "author_uuid": authorUUID, + "published_at": publishedFilter(), + } + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + cur, err := r.collection.Find( + ctx, + filter, + options.Find().SetLimit(l).SetSkip(o).SetSort(desc), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + items := make([]note.Note, 0, limit) + for cur.Next(ctx) { + var n NoteBson + + if err := cur.Decode(&n); err != nil { + return nil, err + } + items = append(items, toDomain(n)) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func (r *NotesRepository) CorrelationExist(ctx context.Context, correlationUUID string) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + if len(correlationUUID) == 0 { + return false, nil + } + + filter := bson.M{"correlation_uuid": correlationUUID} + + c, err := r.collection.CountDocuments(ctx, filter, options.Count().SetLimit(1)) + if err != nil { + return false, err + } + + return c > 0, nil +} + +func (r *NotesRepository) Save(ctx context.Context, n *note.Note) (string, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + if len(n.UUID) == 0 { + UUID, err := uuid.NewV7() + if err != nil { + return "", err + } + n.UUID = UUID.String() + } + + update := NoteBson{ + UUID: n.UUID, + Body: n.Body, + PublishedAt: n.PublishedAt, + AuthorUUID: n.AuthorUUID, + Tags: n.Tags, + LanguageCode: n.LanguageCode, + CorrelationUUID: n.CorrelationUUID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if _, err := r.collection.UpdateOne( + ctx, + bson.D{{Key: "_id", Value: n.UUID}}, + bson.M{"$set": update}, + options.UpdateOne().SetUpsert(true), + ); err != nil { + return "", err + } + + return n.UUID, nil +} + +func (r *NotesRepository) DeleteByCorrelationUUIDAndLanguage(ctx context.Context, correlationUUID string, languageCode string) error { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + filter := bson.M{"correlation_uuid": correlationUUID} + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + + _, err := r.collection.DeleteOne(ctx, filter) + + return err +} + +func publishedByHashtagsFilter(hashtags []string, languageCode string) bson.M { + filter := bson.M{ + "tags": bson.M{"$in": hashtags}, + "published_at": publishedFilter(), + } + if len(languageCode) > 0 { + filter["language_code"] = languageCode + } + return filter +} + +func publishedFilter() bson.M { + return bson.M{ + "$lte": bson.NewDateTimeFromTime(time.Now()), + "$ne": time.Time{}, + } +} diff --git a/infrastructure/repository/mongodb/permissions/collection.go b/infrastructure/repository/mongodb/permissions/collection.go index b92a6e00..a91ffb31 100644 --- a/infrastructure/repository/mongodb/permissions/collection.go +++ b/infrastructure/repository/mongodb/permissions/collection.go @@ -10,6 +10,13 @@ var collection []permission.Permission = []permission.Permission{ {Name: "update an article", Value: permission.ArticlesUpdate}, {Name: "delete an article", Value: permission.ArticlesDelete}, + // notes + {Name: "list of notes", Value: permission.NotesIndex}, + {Name: "create a note", Value: permission.NotesCreate}, + {Name: "show a note", Value: permission.NotesShow}, + {Name: "update a note", Value: permission.NotesUpdate}, + {Name: "delete a note", Value: permission.NotesDelete}, + // comments {Name: "list of comments", Value: permission.CommentsIndex}, {Name: "create an comment", Value: permission.CommentsCreate}, @@ -72,4 +79,11 @@ var collection []permission.Permission = []permission.Permission{ // self files {Name: "list of self files", Value: permission.SelfFilesIndex}, {Name: "delete a self file", Value: permission.SelfFilesDelete}, + + // self notes + {Name: "list of self notes", Value: permission.SelfNotesIndex}, + {Name: "create a self note", Value: permission.SelfNotesCreate}, + {Name: "show a self note", Value: permission.SelfNotesShow}, + {Name: "update a self note", Value: permission.SelfNotesUpdate}, + {Name: "delete a self note", Value: permission.SelfNotesDelete}, } diff --git a/presentation/http/blog/api/author/article/index_test.go b/presentation/http/blog/api/author/article/index_test.go index e21beb2c..1c1f8066 100644 --- a/presentation/http/blog/api/author/article/index_test.go +++ b/presentation/http/blog/api/author/article/index_test.go @@ -22,6 +22,7 @@ import ( "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" "github.com/khanzadimahdi/testproject/infrastructure/validator" ) @@ -39,6 +40,7 @@ func TestIndexHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -85,13 +87,14 @@ func TestIndexHandler(t *testing.T) { defer userRepository.AssertExpectations(t) articlesRepository.On("CountPublishedByAuthor", mock.Anything, testAuthorUUID, "EN").Once().Return(uint(len(fetched)), nil) + notesRepository.On("CountPublishedByAuthor", mock.Anything, testAuthorUUID, "EN").Once().Return(uint(2), nil) articlesRepository.On("GetPublishedByAuthor", mock.Anything, testAuthorUUID, "EN", uint(0), uint(10)).Once().Return(fetched, nil) elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) articlesRepository.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) defer articlesRepository.AssertExpectations(t) - useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewIndexHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/?page=1", nil) @@ -114,6 +117,7 @@ func TestIndexHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -140,11 +144,12 @@ func TestIndexHandler(t *testing.T) { defer userRepository.AssertExpectations(t) articlesRepository.On("CountPublishedByAuthor", mock.Anything, testAuthorUUID, "EN").Once().Return(uint(0), nil) + notesRepository.On("CountPublishedByAuthor", mock.Anything, testAuthorUUID, "EN").Once().Return(uint(2), nil) articlesRepository.On("GetPublishedByAuthor", mock.Anything, testAuthorUUID, "EN", uint(0), uint(10)).Once().Return(nil, nil) elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) defer articlesRepository.AssertExpectations(t) - useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewIndexHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/?page=1", nil) @@ -163,6 +168,7 @@ func TestIndexHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -185,7 +191,7 @@ func TestIndexHandler(t *testing.T) { userRepository.On("GetOneByIdentity", mock.Anything, "ghost").Once().Return(user.User{}, domain.ErrNotExists) defer userRepository.AssertExpectations(t) - useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewIndexHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/?page=1", nil) @@ -207,6 +213,7 @@ func TestIndexHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -229,7 +236,7 @@ func TestIndexHandler(t *testing.T) { userRepository.On("GetOneByIdentity", mock.Anything, testUsername).Once().Return(user.User{}, errors.New("boom")) defer userRepository.AssertExpectations(t) - useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getArticlesByAuthor.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewIndexHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/?page=1", nil) diff --git a/presentation/http/blog/api/author/article/testdata/response-01.json b/presentation/http/blog/api/author/article/testdata/response-01.json index 2a8157b9..d2dd243b 100644 --- a/presentation/http/blog/api/author/article/testdata/response-01.json +++ b/presentation/http/blog/api/author/article/testdata/response-01.json @@ -6,6 +6,10 @@ "username": "author-username", "created_at": "0001-01-01T00:00:00Z" }, + "totals": { + "articles": 1, + "notes": 2 + }, "items": [ { "correlation_uuid": "article-correlation-uuid-1", diff --git a/presentation/http/blog/api/author/note/index.go b/presentation/http/blog/api/author/note/index.go new file mode 100644 index 00000000..51d91f21 --- /dev/null +++ b/presentation/http/blog/api/author/note/index.go @@ -0,0 +1,79 @@ +package note + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "unsafe" + + "github.com/gofrs/uuid/v5" + + "github.com/khanzadimahdi/testproject/application/localize" + getNotesByAuthor "github.com/khanzadimahdi/testproject/application/note/getNotesByAuthor" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type indexHandler struct { + useCase *getNotesByAuthor.UseCase +} + +func NewIndexHandler(useCase *getNotesByAuthor.UseCase) *indexHandler { + return &indexHandler{ + useCase: useCase, + } +} + +// @Summary List notes by author +// @Description return a page of the most recent published notes for the given author identity (UUID or username). Responds 404 when the author keeps their notes private. +// @Tags authors +// @Accept json +// @Produce json +// @Param identity path string true "Author UUID or username" +// @Param page query int false "Page number" default(1) +// @Success 200 {object} getNotesByAuthor.Response +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /authors/{identity}/notes [get] +func (h *indexHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var page uint = 1 + if r.URL.Query().Has("page") { + parsedPage, err := strconv.ParseUint(r.URL.Query().Get("page"), 10, int(unsafe.Sizeof(page))) + if err == nil { + page = uint(parsedPage) + } + } + + request := &getNotesByAuthor.Request{ + Page: page, + LanguageCode: localize.FromContext(r.Context()), + } + + identity := r.PathValue("identity") + if _, err := uuid.FromString(identity); err == nil { + request.AuthorUUID = identity + } else { + request.Username = identity + } + + response, err := h.useCase.Execute(r.Context(), request) + + switch { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + case len(response.ValidationErrors) > 0: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(response) + } +} diff --git a/presentation/http/blog/api/dashboard/note/create.go b/presentation/http/blog/api/dashboard/note/create.go new file mode 100644 index 00000000..25c9809b --- /dev/null +++ b/presentation/http/blog/api/dashboard/note/create.go @@ -0,0 +1,59 @@ +package note + +import ( + "encoding/json" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth" + createnote "github.com/khanzadimahdi/testproject/application/dashboard/note/createNote" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type createHandler struct { + useCase *createnote.UseCase +} + +func NewCreateHandler(useCase *createnote.UseCase) *createHandler { + return &createHandler{ + useCase: useCase, + } +} + +// @Summary Create dashboard note +// @Description create a new note in dashboard as current user +// @Tags dashboard notes +// @Accept json +// @Produce json +// @Param note body createnote.Request true "Note data" +// @Success 201 {object} createnote.Response +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/notes [post] +// @Router /dashboard/my/notes [post] +func (h *createHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + userUUID := auth.FromContext(r.Context()).UUID + + var request createnote.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + request.AuthorUUID = userUUID + + response, err := h.useCase.Execute(r.Context(), &request) + + switch { + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + case response != nil && len(response.ValidationErrors) > 0: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + json.NewEncoder(rw).Encode(response) + } +} diff --git a/presentation/http/blog/api/dashboard/note/delete.go b/presentation/http/blog/api/dashboard/note/delete.go new file mode 100644 index 00000000..b9aa4764 --- /dev/null +++ b/presentation/http/blog/api/dashboard/note/delete.go @@ -0,0 +1,65 @@ +package note + +import ( + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth" + deletenote "github.com/khanzadimahdi/testproject/application/dashboard/note/deleteNote" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type deleteHandler struct { + useCase *deletenote.UseCase + // ownedOnly narrows the deletion to the current user's own notes, for the + // route guarded by the self notes permission. + ownedOnly bool +} + +func NewDeleteHandler(useCase *deletenote.UseCase) *deleteHandler { + return &deleteHandler{ + useCase: useCase, + } +} + +func NewDeleteUserNoteHandler(useCase *deletenote.UseCase) *deleteHandler { + return &deleteHandler{ + useCase: useCase, + ownedOnly: true, + } +} + +// @Summary Delete dashboard note +// @Description remove a note by correlation uuid and language +// @Tags dashboard notes +// @Accept json +// @Produce json +// @Param correlationUUID path string true "Note correlation UUID" +// @Param language_code path string true "Language code" +// @Success 204 +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/notes/{correlationUUID}/{language_code} [delete] +// @Router /dashboard/my/notes/{correlationUUID}/{language_code} [delete] +func (h *deleteHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + request := &deletenote.Request{ + CorrelationUUID: r.PathValue("correlationUUID"), + LanguageCode: r.PathValue("language_code"), + } + if h.ownedOnly { + request.OwnerUUID = auth.FromContext(r.Context()).UUID + } + + err := h.useCase.Execute(r.Context(), request) + switch { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + default: + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/presentation/http/blog/api/dashboard/note/index.go b/presentation/http/blog/api/dashboard/note/index.go new file mode 100644 index 00000000..2a5f14af --- /dev/null +++ b/presentation/http/blog/api/dashboard/note/index.go @@ -0,0 +1,71 @@ +package note + +import ( + "encoding/json" + "net/http" + "strconv" + "unsafe" + + "github.com/khanzadimahdi/testproject/application/auth" + getnotes "github.com/khanzadimahdi/testproject/application/dashboard/note/getNotes" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type indexHandler struct { + useCase *getnotes.UseCase + // ownedOnly narrows the listing to the current user's own notes, for the + // route guarded by the self notes permission. + ownedOnly bool +} + +func NewIndexHandler(useCase *getnotes.UseCase) *indexHandler { + return &indexHandler{ + useCase: useCase, + } +} + +func NewIndexUserNotesHandler(useCase *getnotes.UseCase) *indexHandler { + return &indexHandler{ + useCase: useCase, + ownedOnly: true, + } +} + +// @Summary List dashboard notes +// @Description page through notes in dashboard, grouped by correlation uuid +// @Tags dashboard notes +// @Accept json +// @Produce json +// @Param page query int false "Page" default(1) +// @Success 200 {object} getnotes.Response +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/notes [get] +// @Router /dashboard/my/notes [get] +func (h *indexHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var page uint = 1 + if r.URL.Query().Has("page") { + parsedPage, err := strconv.ParseUint(r.URL.Query().Get("page"), 10, int(unsafe.Sizeof(page))) + if err == nil { + page = uint(parsedPage) + } + } + + request := &getnotes.Request{ + Page: page, + } + if h.ownedOnly { + request.AuthorUUID = auth.FromContext(r.Context()).UUID + } + + response, err := h.useCase.Execute(r.Context(), request) + switch { + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(response) + } +} diff --git a/presentation/http/blog/api/dashboard/note/show.go b/presentation/http/blog/api/dashboard/note/show.go new file mode 100644 index 00000000..f9a7f4d7 --- /dev/null +++ b/presentation/http/blog/api/dashboard/note/show.go @@ -0,0 +1,69 @@ +package note + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth" + getnote "github.com/khanzadimahdi/testproject/application/dashboard/note/getNote" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type showHandler struct { + useCase *getnote.UseCase + // ownedOnly narrows the lookup to the current user's own notes, for the + // route guarded by the self notes permission. + ownedOnly bool +} + +func NewShowHandler(useCase *getnote.UseCase) *showHandler { + return &showHandler{ + useCase: useCase, + } +} + +func NewShowUserNoteHandler(useCase *getnote.UseCase) *showHandler { + return &showHandler{ + useCase: useCase, + ownedOnly: true, + } +} + +// @Summary Get dashboard note +// @Description retrieve one note by correlation uuid and language +// @Tags dashboard notes +// @Accept json +// @Produce json +// @Param correlationUUID path string true "Note correlation UUID" +// @Param language_code path string true "Language code" +// @Success 200 {object} getnote.Response +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/notes/{correlationUUID}/{language_code} [get] +// @Router /dashboard/my/notes/{correlationUUID}/{language_code} [get] +func (h *showHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + request := &getnote.Request{ + CorrelationUUID: r.PathValue("correlationUUID"), + LanguageCode: r.PathValue("language_code"), + } + if h.ownedOnly { + request.OwnerUUID = auth.FromContext(r.Context()).UUID + } + + response, err := h.useCase.Execute(r.Context(), request) + + switch { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(response) + } +} diff --git a/presentation/http/blog/api/dashboard/note/update.go b/presentation/http/blog/api/dashboard/note/update.go new file mode 100644 index 00000000..c63fd5e5 --- /dev/null +++ b/presentation/http/blog/api/dashboard/note/update.go @@ -0,0 +1,74 @@ +package note + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth" + updatenote "github.com/khanzadimahdi/testproject/application/dashboard/note/updateNote" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type updateHandler struct { + useCase *updatenote.UseCase + // ownedOnly narrows the update to the current user's own notes, for the + // route guarded by the self notes permission. + ownedOnly bool +} + +func NewUpdateHandler(useCase *updatenote.UseCase) *updateHandler { + return &updateHandler{ + useCase: useCase, + } +} + +func NewUpdateUserNoteHandler(useCase *updatenote.UseCase) *updateHandler { + return &updateHandler{ + useCase: useCase, + ownedOnly: true, + } +} + +// @Summary Update a dashboard note +// @Description update existing note fields, current user becomes the author +// @Tags dashboard notes +// @Accept json +// @Produce json +// @Param note body updatenote.Request true "Note update payload" +// @Success 204 +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/notes [put] +// @Router /dashboard/my/notes [put] +func (h *updateHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + userUUID := auth.FromContext(r.Context()).UUID + + var request updatenote.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + request.AuthorUUID = userUUID + if h.ownedOnly { + request.OwnerUUID = userUUID + } + + response, err := h.useCase.Execute(r.Context(), &request) + switch { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + case response != nil && len(response.ValidationErrors) > 0: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/presentation/http/blog/api/hashtag/show.go b/presentation/http/blog/api/hashtag/show.go index 30d6eefb..356b6055 100644 --- a/presentation/http/blog/api/hashtag/show.go +++ b/presentation/http/blog/api/hashtag/show.go @@ -6,30 +6,31 @@ import ( "strconv" "unsafe" - getArticlesByHashtag "github.com/khanzadimahdi/testproject/application/article/getArticlesByHashtag" + getContentsByHashtag "github.com/khanzadimahdi/testproject/application/hashtag/getContentsByHashtag" "github.com/khanzadimahdi/testproject/application/localize" infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" "go.opentelemetry.io/otel/trace" ) type showHandler struct { - useCase *getArticlesByHashtag.UseCase + useCase *getContentsByHashtag.UseCase } -func NewShowHandler(useCase *getArticlesByHashtag.UseCase) *showHandler { +func NewShowHandler(useCase *getContentsByHashtag.UseCase) *showHandler { return &showHandler{ useCase: useCase, } } -// @Summary List articles by hashtag -// @Description return a page of the most recent published articles with the given hashtag +// @Summary List contents by hashtag +// @Description return a page of the most recent published articles or notes with the given hashtag. Each type is paginated on its own count; omitting `type` selects articles, or notes when the hashtag has no articles. // @Tags hashtags // @Accept json // @Produce json // @Param hashtag path string true "Hashtag" +// @Param type query string false "Content type" Enums(article, note) // @Param page query int false "Page number" default(1) -// @Success 200 {object} getArticlesByHashtag.Response +// @Success 200 {object} getContentsByHashtag.Response // @Failure 400 {object} map[string]interface{} // @Failure 500 {object} map[string]interface{} // @Router /hashtags/{hashtag} [get] @@ -44,9 +45,10 @@ func (h *showHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { hashtag := r.PathValue("hashtag") - request := &getArticlesByHashtag.Request{ + request := &getContentsByHashtag.Request{ Page: page, Hashtag: hashtag, + Type: r.URL.Query().Get("type"), LanguageCode: localize.FromContext(r.Context()), } diff --git a/presentation/http/blog/api/hashtag/show_test.go b/presentation/http/blog/api/hashtag/show_test.go index 21d5c898..5c792a29 100644 --- a/presentation/http/blog/api/hashtag/show_test.go +++ b/presentation/http/blog/api/hashtag/show_test.go @@ -11,17 +11,19 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - getArticlesByHashtag "github.com/khanzadimahdi/testproject/application/article/getArticlesByHashtag" "github.com/khanzadimahdi/testproject/application/element" + getContentsByHashtag "github.com/khanzadimahdi/testproject/application/hashtag/getContentsByHashtag" "github.com/khanzadimahdi/testproject/application/language/resolver" "github.com/khanzadimahdi/testproject/domain" "github.com/khanzadimahdi/testproject/domain/article" "github.com/khanzadimahdi/testproject/domain/language" + "github.com/khanzadimahdi/testproject/domain/note" "github.com/khanzadimahdi/testproject/domain/user" "github.com/khanzadimahdi/testproject/infrastructure/matcher" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/articles" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/elements" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/languages" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/notes" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" "github.com/khanzadimahdi/testproject/infrastructure/validator" ) @@ -29,11 +31,12 @@ import ( func TestShowHandler(t *testing.T) { t.Parallel() - t.Run("show home data", func(t *testing.T) { + t.Run("shows the articles tab by default", func(t *testing.T) { t.Parallel() var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -78,9 +81,20 @@ func TestShowHandler(t *testing.T) { }, } + notes := []note.Note{ + { + UUID: "note-uuid-1", + CorrelationUUID: "note-correlation-uuid-1", + Body: "note-body-1", + PublishedAt: publishedAt.Add(-24 * time.Hour), + AuthorUUID: "author-uuid-2", + Tags: []string{"tag-1"}, + }, + } + hashtag := "a-test-hashtag" - r := &getArticlesByHashtag.Request{ + r := &getContentsByHashtag.Request{ Page: 1, Hashtag: hashtag, } @@ -97,6 +111,9 @@ func TestShowHandler(t *testing.T) { elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) defer articlesRepository.AssertExpectations(t) + notesRepository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(len(notes)), nil) + defer notesRepository.AssertExpectations(t) + userRepository.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1", "author-uuid-1", "author-uuid-2"}).Once().Return(users, nil) defer userRepository.AssertExpectations(t) @@ -110,7 +127,7 @@ func TestShowHandler(t *testing.T) { languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN", Name: "English"}, nil) defer languageResolver.AssertExpectations(t) - useCase := getArticlesByHashtag.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getContentsByHashtag.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewShowHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/", nil) @@ -128,11 +145,81 @@ func TestShowHandler(t *testing.T) { assert.Equal(t, http.StatusOK, response.Code) }) + t.Run("serves the notes tab from the type query parameter", func(t *testing.T) { + t.Parallel() + + var ( + articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository + elementsRepository elements.MockElementsRepository + userRepository users.MockUsersRepository + languagesRepository languages.MockLanguagesRepository + languageResolver resolver.MockResolver + requestValidator validator.MockValidator + ) + + hashtag := "a-test-hashtag" + + r := &getContentsByHashtag.Request{ + Page: 1, + Hashtag: hashtag, + Type: getContentsByHashtag.TypeNote, + } + + n := []note.Note{ + { + UUID: "note-uuid-1", + CorrelationUUID: "note-correlation-uuid-1", + Body: "note-body-1", + AuthorUUID: "author-uuid-1", + Tags: []string{"tag-1"}, + }, + } + + articlesRepository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(7), nil) + notesRepository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(len(n)), nil) + notesRepository.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(n, nil) + notesRepository.On("GetPublishedLanguageCodes", mock.Anything, mock.Anything).Return([]string{}, nil) + defer notesRepository.AssertExpectations(t) + + elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) + userRepository.On("GetByUUIDs", mock.Anything, []string{"author-uuid-1"}).Once().Return([]user.User{{UUID: "author-uuid-1"}}, nil) + languagesRepository.On("GetByCodes", mock.Anything, []string{}).Return([]language.Language{}, nil) + + requestValidator.On("Validate", r).Once().Return(nil) + defer requestValidator.AssertExpectations(t) + + languageResolver.On("DefaultCode", mock.Anything).Once().Return("EN", nil) + languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN", Name: "English"}, nil) + + useCase := getContentsByHashtag.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + handler := NewShowHandler(useCase) + + request := httptest.NewRequest(http.MethodGet, "/?type=note", nil) + request.SetPathValue("hashtag", hashtag) + + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + // The articles tab is only counted, so its label stays right without + // fetching a page nobody asked for. + articlesRepository.AssertNotCalled(t, "GetPublishedByHashtags") + + expected, err := os.ReadFile("testdata/response-notes-tab.txt") + assert.NoError(t, err) + + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.JSONEq(t, string(expected), response.Body.String()) + assert.Equal(t, http.StatusOK, response.Code) + }) + t.Run("validation failed", func(t *testing.T) { t.Parallel() var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -140,7 +227,7 @@ func TestShowHandler(t *testing.T) { requestValidator validator.MockValidator ) - r := &getArticlesByHashtag.Request{ + r := &getContentsByHashtag.Request{ Page: 1, } @@ -151,7 +238,7 @@ func TestShowHandler(t *testing.T) { requestValidator.On("Validate", r).Once().Return(validationErrors) defer requestValidator.AssertExpectations(t) - useCase := getArticlesByHashtag.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getContentsByHashtag.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewShowHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/", nil) @@ -176,6 +263,7 @@ func TestShowHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -185,7 +273,7 @@ func TestShowHandler(t *testing.T) { hashtag := "a-test-hashtag" - r := &getArticlesByHashtag.Request{ + r := &getContentsByHashtag.Request{ Page: 1, Hashtag: hashtag, } @@ -197,6 +285,9 @@ func TestShowHandler(t *testing.T) { elementsRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) defer articlesRepository.AssertExpectations(t) + notesRepository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(0), nil) + defer notesRepository.AssertExpectations(t) + userRepository.On("GetByUUIDs", mock.Anything, []string{}).Once().Return([]user.User{}, nil) defer userRepository.AssertExpectations(t) @@ -207,7 +298,7 @@ func TestShowHandler(t *testing.T) { languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN", Name: "English"}, nil) defer languageResolver.AssertExpectations(t) - useCase := getArticlesByHashtag.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getContentsByHashtag.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewShowHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/", nil) @@ -230,6 +321,7 @@ func TestShowHandler(t *testing.T) { var ( articlesRepository articles.MockArticlesRepository + notesRepository notes.MockNotesRepository elementsRepository elements.MockElementsRepository userRepository users.MockUsersRepository languagesRepository languages.MockLanguagesRepository @@ -239,7 +331,7 @@ func TestShowHandler(t *testing.T) { hashtag := "a-test-hashtag" - r := &getArticlesByHashtag.Request{ + r := &getContentsByHashtag.Request{ Page: 1, Hashtag: hashtag, } @@ -250,6 +342,9 @@ func TestShowHandler(t *testing.T) { articlesRepository.On("GetPublishedByHashtags", mock.Anything, []string{hashtag}, "EN", uint(0), uint(10)).Once().Return(nil, errors.New("some error happened")) defer articlesRepository.AssertExpectations(t) + notesRepository.On("CountPublishedByHashtags", mock.Anything, []string{hashtag}, "EN").Once().Return(uint(0), nil) + defer notesRepository.AssertExpectations(t) + requestValidator.On("Validate", r).Once().Return(nil) defer requestValidator.AssertExpectations(t) @@ -257,7 +352,7 @@ func TestShowHandler(t *testing.T) { languageResolver.On("Resolve", mock.Anything, "EN").Once().Return(language.Language{Code: "EN", Name: "English"}, nil) defer languageResolver.AssertExpectations(t) - useCase := getArticlesByHashtag.NewUseCase(&articlesRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) + useCase := getContentsByHashtag.NewUseCase(&articlesRepository, ¬esRepository, &userRepository, &languagesRepository, &languageResolver, element.NewRetriever(&articlesRepository, &elementsRepository, &userRepository, matcher.New()), &requestValidator) handler := NewShowHandler(useCase) request := httptest.NewRequest(http.MethodGet, "/", nil) diff --git a/presentation/http/blog/api/hashtag/testdata/response-01.txt b/presentation/http/blog/api/hashtag/testdata/response-01.txt index 326d7c51..05e01edf 100644 --- a/presentation/http/blog/api/hashtag/testdata/response-01.txt +++ b/presentation/http/blog/api/hashtag/testdata/response-01.txt @@ -1,58 +1 @@ -{ - "items": [ - { - "correlation_uuid": "article-correlation-uuid-1", - "cover": "article-cover-1", - "video": "article-video-1", - "title": "article-title-1", - "excerpt": "article-excerpt-1", - "published_at": "2024-09-29T15:56:25Z", - "author": { - "uuid": "author-uuid-1", - "name": "author-name", - "avatar": "author-avatar", - "username": "author-username-1" - }, - "available_languages": null - }, - { - "correlation_uuid": "article-correlation-uuid-2", - "cover": "article-cover-2", - "video": "", - "title": "article-title-2", - "excerpt": "article-excerpt-2", - "published_at": "0001-01-01T00:00:00Z", - "author": { - "uuid": "author-uuid-1", - "name": "author-name", - "avatar": "author-avatar", - "username": "author-username-1" - }, - "available_languages": null - }, - { - "correlation_uuid": "article-correlation-uuid-3", - "cover": "article-cover-3", - "video": "", - "title": "article-title-3", - "excerpt": "article-excerpt-3", - "published_at": "0001-01-01T00:00:00Z", - "author": { - "uuid": "author-uuid-2", - "name": "author-name", - "avatar": "author-avatar", - "username": "author-username-2" - }, - "available_languages": null - } - ], - "language_code": { - "code": "EN", - "name": "English" - }, - "elements": [], - "pagination": { - "total_pages": 1, - "current_page": 1 - } -} +{"type":"article","totals":{"articles":3,"notes":1},"items":[{"type":"article","correlation_uuid":"article-correlation-uuid-1","published_at":"2024-09-29T15:56:25Z","author":{"uuid":"author-uuid-1","name":"author-name","avatar":"author-avatar","username":"author-username-1"},"available_languages":null,"cover":"article-cover-1","video":"article-video-1","title":"article-title-1","excerpt":"article-excerpt-1"},{"type":"article","correlation_uuid":"article-correlation-uuid-2","published_at":"0001-01-01T00:00:00Z","author":{"uuid":"author-uuid-1","name":"author-name","avatar":"author-avatar","username":"author-username-1"},"available_languages":null,"cover":"article-cover-2","title":"article-title-2","excerpt":"article-excerpt-2"},{"type":"article","correlation_uuid":"article-correlation-uuid-3","published_at":"0001-01-01T00:00:00Z","author":{"uuid":"author-uuid-2","name":"author-name","avatar":"author-avatar","username":"author-username-2"},"available_languages":null,"cover":"article-cover-3","title":"article-title-3","excerpt":"article-excerpt-3"}],"language_code":{"code":"EN","name":"English"},"elements":[],"pagination":{"total_pages":1,"current_page":1}} diff --git a/presentation/http/blog/api/hashtag/testdata/response-02.txt b/presentation/http/blog/api/hashtag/testdata/response-02.txt index 773c4c7b..238cfcc2 100644 --- a/presentation/http/blog/api/hashtag/testdata/response-02.txt +++ b/presentation/http/blog/api/hashtag/testdata/response-02.txt @@ -1,12 +1 @@ -{ - "items": [], - "language_code": { - "code": "EN", - "name": "English" - }, - "elements": [], - "pagination": { - "total_pages": 0, - "current_page": 1 - } -} +{"type":"article","totals":{"articles":0,"notes":0},"items":[],"language_code":{"code":"EN","name":"English"},"elements":[],"pagination":{"total_pages":0,"current_page":1}} diff --git a/presentation/http/blog/api/hashtag/testdata/response-notes-tab.txt b/presentation/http/blog/api/hashtag/testdata/response-notes-tab.txt new file mode 100644 index 00000000..e277698f --- /dev/null +++ b/presentation/http/blog/api/hashtag/testdata/response-notes-tab.txt @@ -0,0 +1 @@ +{"type":"note","totals":{"articles":7,"notes":1},"items":[{"type":"note","correlation_uuid":"note-correlation-uuid-1","published_at":"0001-01-01T00:00:00Z","author":{"uuid":"author-uuid-1","name":"","avatar":"","username":""},"available_languages":null,"body":"note-body-1","tags":["tag-1"]}],"language_code":{"code":"EN","name":"English"},"elements":[],"pagination":{"total_pages":1,"current_page":1}} diff --git a/presentation/http/blog/api/hashtag/testdata/response-validation-failed.txt b/presentation/http/blog/api/hashtag/testdata/response-validation-failed.txt index defbf52e..af278c8f 100755 --- a/presentation/http/blog/api/hashtag/testdata/response-validation-failed.txt +++ b/presentation/http/blog/api/hashtag/testdata/response-validation-failed.txt @@ -1,15 +1 @@ -{ - "errors": { - "hashtag": "this field is required" - }, - "items": null, - "language_code": { - "code": "", - "name": "" - }, - "elements": null, - "pagination": { - "total_pages": 0, - "current_page": 0 - } -} +{"errors":{"hashtag":"this field is required"},"type":"","totals":{"articles":0,"notes":0},"items":null,"language_code":{"code":"","name":""},"elements":null,"pagination":{"total_pages":0,"current_page":0}} diff --git a/presentation/http/blog/api/note/show.go b/presentation/http/blog/api/note/show.go new file mode 100644 index 00000000..95798cd6 --- /dev/null +++ b/presentation/http/blog/api/note/show.go @@ -0,0 +1,59 @@ +package note + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/localize" + getnote "github.com/khanzadimahdi/testproject/application/note/getNote" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type showHandler struct { + useCase *getnote.UseCase +} + +func NewShowHandler(useCase *getnote.UseCase) *showHandler { + return &showHandler{ + useCase: useCase, + } +} + +// @Summary Show a published note +// @Description return a published note by its correlation uuid, in the requested language +// @Tags notes +// @Accept json +// @Produce json +// @Param uuid path string true "Note correlation UUID" +// @Success 200 {object} getnote.Response +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /notes/{uuid} [get] +func (h *showHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + request := &getnote.Request{ + CorrelationUUID: r.PathValue("uuid"), + LanguageCode: localize.FromContext(r.Context()), + } + + response, err := h.useCase.Execute(r.Context(), request) + + switch { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + infraTrace.RecordError(trace.SpanFromContext(r.Context()), err) + rw.WriteHeader(http.StatusInternalServerError) + case len(response.ValidationErrors) > 0: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(response) + } +} diff --git a/resources/docs/blog/openapi/docs.go b/resources/docs/blog/openapi/docs.go index 2fcc82ec..284d9e07 100644 --- a/resources/docs/blog/openapi/docs.go +++ b/resources/docs/blog/openapi/docs.go @@ -457,6 +457,66 @@ const docTemplate = `{ } } }, + "/authors/{identity}/notes": { + "get": { + "description": "return a page of the most recent published notes for the given author identity (UUID or username). Responds 404 when the author keeps their notes private.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authors" + ], + "summary": "List notes by author", + "parameters": [ + { + "type": "string", + "description": "Author UUID or username", + "name": "identity", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getNotesByAuthor.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/bookmarks": { "put": { "description": "create or update an article bookmark for authenticated user", @@ -2145,9 +2205,9 @@ const docTemplate = `{ } } }, - "/dashboard/permissions": { + "/dashboard/my/notes": { "get": { - "description": "retrieve all permissions", + "description": "page through notes in dashboard, grouped by correlation uuid", "consumes": [ "application/json" ], @@ -2155,14 +2215,23 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard permissions" + "dashboard notes" + ], + "summary": "List dashboard notes", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } ], - "summary": "List permissions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getpermissions.Response" + "$ref": "#/definitions/getnotes.Response" } }, "500": { @@ -2173,11 +2242,9 @@ const docTemplate = `{ } } } - } - }, - "/dashboard/profile": { - "get": { - "description": "retrieve profile for current user", + }, + "put": { + "description": "update existing note fields, current user becomes the author", "consumes": [ "application/json" ], @@ -2185,14 +2252,29 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" + ], + "summary": "Update a dashboard note", + "parameters": [ + { + "description": "Note update payload", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updatenote.Request" + } + } ], - "summary": "Get profile", "responses": { - "200": { - "description": "OK", + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", "schema": { - "$ref": "#/definitions/getprofile.Response" + "type": "object", + "additionalProperties": true } }, "404": { @@ -2211,8 +2293,8 @@ const docTemplate = `{ } } }, - "put": { - "description": "update profile data for current user", + "post": { + "description": "create a new note in dashboard as current user", "consumes": [ "application/json" ], @@ -2220,33 +2302,29 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" ], - "summary": "Update profile", + "summary": "Create dashboard note", "parameters": [ { - "description": "Profile update", - "name": "body", + "description": "Note data", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/updateprofile.Request" + "$ref": "#/definitions/createnote.Request" } } ], "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "201": { + "description": "Created", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/createnote.Response" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "type": "object", "additionalProperties": true @@ -2262,9 +2340,9 @@ const docTemplate = `{ } } }, - "/dashboard/profile/password": { - "put": { - "description": "update password for current authenticated user", + "/dashboard/my/notes/{correlationUUID}/{language_code}": { + "get": { + "description": "retrieve one note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2272,29 +2350,30 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" ], - "summary": "Change user password", + "summary": "Get dashboard note", "parameters": [ { - "description": "Password change", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/changepassword.Request" - } + "type": "string", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", + "in": "path", + "required": true } ], "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response" } }, "404": { @@ -2312,11 +2391,9 @@ const docTemplate = `{ } } } - } - }, - "/dashboard/profile/roles": { - "get": { - "description": "returns roles for current user", + }, + "delete": { + "description": "remove a note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2324,14 +2401,34 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" + ], + "summary": "Delete dashboard note", + "parameters": [ + { + "type": "string", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", + "in": "path", + "required": true + } ], - "summary": "Get user roles", "responses": { - "200": { - "description": "OK", + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", "schema": { - "$ref": "#/definitions/getroles.Response" + "type": "object", + "additionalProperties": true } }, "500": { @@ -2344,9 +2441,9 @@ const docTemplate = `{ } } }, - "/dashboard/roles": { + "/dashboard/notes": { "get": { - "description": "paginated list of roles", + "description": "page through notes in dashboard, grouped by correlation uuid", "consumes": [ "application/json" ], @@ -2354,9 +2451,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "List roles", + "summary": "List dashboard notes", "parameters": [ { "type": "integer", @@ -2370,7 +2467,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getroles.Response" + "$ref": "#/definitions/getnotes.Response" } }, "500": { @@ -2383,7 +2480,7 @@ const docTemplate = `{ } }, "put": { - "description": "modify an existing role", + "description": "update existing note fields, current user becomes the author", "consumes": [ "application/json" ], @@ -2391,17 +2488,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Update role", + "summary": "Update a dashboard note", "parameters": [ { - "description": "Role update", - "name": "body", + "description": "Note update payload", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/updaterole.Request" + "$ref": "#/definitions/updatenote.Request" } } ], @@ -2433,7 +2530,7 @@ const docTemplate = `{ } }, "post": { - "description": "add a new role via dashboard", + "description": "create a new note in dashboard as current user", "consumes": [ "application/json" ], @@ -2441,17 +2538,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Create role", + "summary": "Create dashboard note", "parameters": [ { - "description": "Role data", - "name": "body", + "description": "Note data", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/createrole.Request" + "$ref": "#/definitions/createnote.Request" } } ], @@ -2459,7 +2556,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/createrole.Response" + "$ref": "#/definitions/createnote.Response" } }, "400": { @@ -2469,13 +2566,6 @@ const docTemplate = `{ "additionalProperties": true } }, - "404": { - "description": "Not Found", - "schema": { - "type": "object", - "additionalProperties": true - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2486,9 +2576,9 @@ const docTemplate = `{ } } }, - "/dashboard/roles/{uuid}": { + "/dashboard/notes/{correlationUUID}/{language_code}": { "get": { - "description": "fetch role by UUID", + "description": "retrieve one note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2496,14 +2586,21 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Get role", + "summary": "Get dashboard note", "parameters": [ { "type": "string", - "description": "Role UUID", - "name": "uuid", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", "in": "path", "required": true } @@ -2512,7 +2609,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getrole.Response" + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response" } }, "404": { @@ -2532,7 +2629,7 @@ const docTemplate = `{ } }, "delete": { - "description": "remove a role by UUID", + "description": "remove a note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2540,14 +2637,21 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Delete role", + "summary": "Delete dashboard note", "parameters": [ { "type": "string", - "description": "Role UUID", - "name": "uuid", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", "in": "path", "required": true } @@ -2573,9 +2677,9 @@ const docTemplate = `{ } } }, - "/dashboard/users": { + "/dashboard/permissions": { "get": { - "description": "paginated list of users", + "description": "retrieve all permissions", "consumes": [ "application/json" ], @@ -2583,23 +2687,451 @@ const docTemplate = `{ "application/json" ], "tags": [ - "dashboard users" - ], - "summary": "List users", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - } + "dashboard permissions" ], + "summary": "List permissions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getusers.Response" + "$ref": "#/definitions/getpermissions.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile": { + "get": { + "description": "retrieve profile for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Get profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getprofile.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "update profile data for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updateprofile.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile/password": { + "put": { + "description": "update password for current authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Change user password", + "parameters": [ + { + "description": "Password change", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/changepassword.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile/roles": { + "get": { + "description": "returns roles for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Get user roles", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getroles.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/roles": { + "get": { + "description": "paginated list of roles", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "List roles", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getroles.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "modify an existing role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Update role", + "parameters": [ + { + "description": "Role update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updaterole.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "add a new role via dashboard", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Create role", + "parameters": [ + { + "description": "Role data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createrole.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/createrole.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/roles/{uuid}": { + "get": { + "description": "fetch role by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Get role", + "parameters": [ + { + "type": "string", + "description": "Role UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getrole.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "remove a role by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Delete role", + "parameters": [ + { + "type": "string", + "description": "Role UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/users": { + "get": { + "description": "paginated list of users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard users" + ], + "summary": "List users", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getusers.Response" } }, "500": { @@ -2902,7 +3434,7 @@ const docTemplate = `{ }, "/hashtags/{hashtag}": { "get": { - "description": "return a page of the most recent published articles with the given hashtag", + "description": "return a page of the most recent published articles or notes with the given hashtag. Each type is paginated on its own count; omitting ` + "`" + `type` + "`" + ` selects articles, or notes when the hashtag has no articles.", "consumes": [ "application/json" ], @@ -2912,7 +3444,7 @@ const docTemplate = `{ "tags": [ "hashtags" ], - "summary": "List articles by hashtag", + "summary": "List contents by hashtag", "parameters": [ { "type": "string", @@ -2921,6 +3453,16 @@ const docTemplate = `{ "in": "path", "required": true }, + { + "enum": [ + "article", + "note" + ], + "type": "string", + "description": "Content type", + "name": "type", + "in": "query" + }, { "type": "integer", "default": 1, @@ -2933,7 +3475,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getArticlesByHashtag.Response" + "$ref": "#/definitions/getContentsByHashtag.Response" } }, "400": { @@ -3098,6 +3640,59 @@ const docTemplate = `{ } } }, + "/notes/{uuid}": { + "get": { + "description": "return a published note by its correlation uuid, in the requested language", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Show a published note", + "parameters": [ + { + "type": "string", + "description": "Note correlation UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_note_getNote.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/tasks": { "get": { "description": "return tasks scheduled for this worker", @@ -3440,19 +4035,56 @@ const docTemplate = `{ "code": { "type": "string" }, - "name": { + "name": { + "type": "string" + } + } + }, + "createlanguage.Response": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + } + } + }, + "createnote.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, - "createlanguage.Response": { + "createnote.Response": { "type": "object", "properties": { - "code": { + "correlation_uuid": { "type": "string" }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" + }, + "language_code": { + "type": "string" } } }, @@ -3598,6 +4230,9 @@ const docTemplate = `{ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -3635,6 +4270,14 @@ const docTemplate = `{ }, "pagination": { "$ref": "#/definitions/getArticlesByAuthor.paginationResponse" + }, + "totals": { + "description": "How much of each kind the author has published, so the page can label both\ncontent tabs without asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getArticlesByAuthor.totalsResponse" + } + ] } } }, @@ -3709,65 +4352,50 @@ const docTemplate = `{ } } }, - "getArticlesByHashtag.Response": { + "getArticlesByAuthor.totalsResponse": { "type": "object", "properties": { - "elements": { - "type": "array", - "items": { - "$ref": "#/definitions/element.Response" - } - }, - "errors": { - "$ref": "#/definitions/domain.ValidationErrors" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/getArticlesByHashtag.articleResponse" - } - }, - "language_code": { - "$ref": "#/definitions/getArticlesByHashtag.languageResponse" + "articles": { + "type": "integer" }, - "pagination": { - "$ref": "#/definitions/getArticlesByHashtag.paginationResponse" + "notes": { + "type": "integer" } } }, - "getArticlesByHashtag.articleResponse": { + "getComment.Response": { "type": "object", "properties": { + "approved_at": { + "type": "string" + }, "author": { - "$ref": "#/definitions/getArticlesByHashtag.authorResponse" + "$ref": "#/definitions/getComment.author" }, - "available_languages": { - "type": "array", - "items": { - "$ref": "#/definitions/getArticlesByHashtag.languageResponse" - } + "body": { + "type": "string" }, - "correlation_uuid": { + "created_at": { "type": "string" }, - "cover": { + "language_code": { "type": "string" }, - "excerpt": { + "object_type": { "type": "string" }, - "published_at": { + "object_uuid": { "type": "string" }, - "title": { + "parent_uuid": { "type": "string" }, - "video": { + "uuid": { "type": "string" } } }, - "getArticlesByHashtag.authorResponse": { + "getComment.author": { "type": "object", "properties": { "avatar": { @@ -3784,91 +4412,154 @@ const docTemplate = `{ } } }, - "getArticlesByHashtag.languageResponse": { + "getConfig.Response": { "type": "object", "properties": { - "code": { + "default_language_code": { "type": "string" }, - "name": { - "type": "string" + "revision": { + "type": "integer" + }, + "user_default_roles": { + "type": "array", + "items": { + "type": "string" + } } } }, - "getArticlesByHashtag.paginationResponse": { + "getContentsByHashtag.Response": { "type": "object", "properties": { - "current_page": { - "type": "integer" + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } }, - "total_pages": { - "type": "integer" + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getContentsByHashtag.contentResponse" + } + }, + "language_code": { + "$ref": "#/definitions/getContentsByHashtag.languageResponse" + }, + "pagination": { + "$ref": "#/definitions/getContentsByHashtag.paginationResponse" + }, + "totals": { + "description": "How much of each kind the hashtag has, so the page can label both tabs\nwithout asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getContentsByHashtag.totalsResponse" + } + ] + }, + "type": { + "description": "Type is the kind of content Items holds, and the tab the page should show\nas selected — the requested one, or the one picked on the caller's behalf.", + "type": "string" } } }, - "getComment.Response": { + "getContentsByHashtag.authorResponse": { "type": "object", "properties": { - "approved_at": { + "avatar": { "type": "string" }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getContentsByHashtag.contentResponse": { + "type": "object", + "properties": { "author": { - "$ref": "#/definitions/getComment.author" + "$ref": "#/definitions/getContentsByHashtag.authorResponse" + }, + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getContentsByHashtag.languageResponse" + } }, "body": { + "description": "notes only", "type": "string" }, - "created_at": { + "correlation_uuid": { "type": "string" }, - "language_code": { + "cover": { + "description": "articles only", "type": "string" }, - "object_type": { + "excerpt": { "type": "string" }, - "object_uuid": { + "published_at": { "type": "string" }, - "parent_uuid": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { "type": "string" }, - "uuid": { + "type": { + "type": "string" + }, + "video": { "type": "string" } } }, - "getComment.author": { + "getContentsByHashtag.languageResponse": { "type": "object", "properties": { - "avatar": { + "code": { "type": "string" }, "name": { "type": "string" - }, - "username": { - "type": "string" - }, - "uuid": { - "type": "string" } } }, - "getConfig.Response": { + "getContentsByHashtag.paginationResponse": { "type": "object", "properties": { - "default_language_code": { - "type": "string" + "current_page": { + "type": "integer" }, - "revision": { + "total_pages": { + "type": "integer" + } + } + }, + "getContentsByHashtag.totalsResponse": { + "type": "object", + "properties": { + "articles": { "type": "integer" }, - "user_default_roles": { - "type": "array", - "items": { - "type": "string" - } + "notes": { + "type": "integer" } } }, @@ -3907,35 +4598,154 @@ const docTemplate = `{ "memory_percent": { "type": "number" }, - "memory_usage": { - "type": "integer" + "memory_usage": { + "type": "integer" + }, + "network_input": { + "type": "integer" + }, + "network_output": { + "type": "integer" + }, + "pids": { + "type": "integer" + } + } + }, + "getNodes.NodeResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "last_heartbeat_at": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getNodes.Pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "getNodes.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getNodes.NodeResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getNodes.Pagination" + } + } + }, + "getNotesByAuthor.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getNotesByAuthor.authorResponse" + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } + }, + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getNotesByAuthor.noteResponse" + } + }, + "language_code": { + "$ref": "#/definitions/getNotesByAuthor.languageResponse" + }, + "pagination": { + "$ref": "#/definitions/getNotesByAuthor.paginationResponse" + }, + "totals": { + "description": "How much of each kind the author has published, so the page can label both\ncontent tabs without asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getNotesByAuthor.totalsResponse" + } + ] + } + } + }, + "getNotesByAuthor.authorResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "created_at": { + "type": "string" }, - "network_input": { - "type": "integer" + "name": { + "type": "string" }, - "network_output": { - "type": "integer" + "username": { + "type": "string" }, - "pids": { - "type": "integer" + "uuid": { + "type": "string" } } }, - "getNodes.NodeResponse": { + "getNotesByAuthor.languageResponse": { "type": "object", "properties": { - "created_at": { + "code": { "type": "string" }, - "last_heartbeat_at": { + "name": { "type": "string" + } + } + }, + "getNotesByAuthor.noteResponse": { + "type": "object", + "properties": { + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getNotesByAuthor.languageResponse" + } }, - "name": { + "body": { "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, - "getNodes.Pagination": { + "getNotesByAuthor.paginationResponse": { "type": "object", "properties": { "current_page": { @@ -3946,17 +4756,14 @@ const docTemplate = `{ } } }, - "getNodes.Response": { + "getNotesByAuthor.totalsResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/getNodes.NodeResponse" - } + "articles": { + "type": "integer" }, - "pagination": { - "$ref": "#/definitions/getNodes.Pagination" + "notes": { + "type": "integer" } } }, @@ -4343,6 +5150,135 @@ const docTemplate = `{ } } }, + "getnote.author": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnote.authorResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnote.languageResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getnotes.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getnotes.noteResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getnotes.pagination" + } + } + }, + "getnotes.author": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnotes.corrolatedNoteResponse": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnotes.author" + }, + "body": { + "type": "string" + }, + "language": { + "$ref": "#/definitions/getnotes.languageResponse" + }, + "published_at": { + "type": "string" + } + } + }, + "getnotes.languageResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getnotes.noteResponse": { + "type": "object", + "properties": { + "correlation_uuid": { + "type": "string" + }, + "corrolated_items": { + "type": "array", + "items": { + "$ref": "#/definitions/getnotes.corrolatedNoteResponse" + } + } + } + }, + "getnotes.pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, "getpermissions.Response": { "type": "object", "properties": { @@ -5072,6 +6008,32 @@ const docTemplate = `{ } } }, + "github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnote.author" + }, + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "github_com_khanzadimahdi_testproject_application_language_getLanguages.Response": { "type": "object", "properties": { @@ -5097,6 +6059,50 @@ const docTemplate = `{ } } }, + "github_com_khanzadimahdi_testproject_application_note_getNote.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnote.authorResponse" + }, + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getnote.languageResponse" + } + }, + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } + }, + "language_code": { + "$ref": "#/definitions/getnote.languageResponse" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "validation_errors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "github_com_khanzadimahdi_testproject_application_runner_manager_task_getTasks.Response": { "type": "object", "properties": { @@ -5677,6 +6683,29 @@ const docTemplate = `{ } } }, + "updatenote.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "updateprofile.Request": { "type": "object", "properties": { diff --git a/resources/docs/blog/openapi/swagger.json b/resources/docs/blog/openapi/swagger.json index f8cc3243..9c04b277 100644 --- a/resources/docs/blog/openapi/swagger.json +++ b/resources/docs/blog/openapi/swagger.json @@ -454,6 +454,66 @@ } } }, + "/authors/{identity}/notes": { + "get": { + "description": "return a page of the most recent published notes for the given author identity (UUID or username). Responds 404 when the author keeps their notes private.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authors" + ], + "summary": "List notes by author", + "parameters": [ + { + "type": "string", + "description": "Author UUID or username", + "name": "identity", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getNotesByAuthor.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/bookmarks": { "put": { "description": "create or update an article bookmark for authenticated user", @@ -2142,9 +2202,9 @@ } } }, - "/dashboard/permissions": { + "/dashboard/my/notes": { "get": { - "description": "retrieve all permissions", + "description": "page through notes in dashboard, grouped by correlation uuid", "consumes": [ "application/json" ], @@ -2152,14 +2212,23 @@ "application/json" ], "tags": [ - "dashboard permissions" + "dashboard notes" + ], + "summary": "List dashboard notes", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } ], - "summary": "List permissions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getpermissions.Response" + "$ref": "#/definitions/getnotes.Response" } }, "500": { @@ -2170,11 +2239,9 @@ } } } - } - }, - "/dashboard/profile": { - "get": { - "description": "retrieve profile for current user", + }, + "put": { + "description": "update existing note fields, current user becomes the author", "consumes": [ "application/json" ], @@ -2182,14 +2249,29 @@ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" + ], + "summary": "Update a dashboard note", + "parameters": [ + { + "description": "Note update payload", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updatenote.Request" + } + } ], - "summary": "Get profile", "responses": { - "200": { - "description": "OK", + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", "schema": { - "$ref": "#/definitions/getprofile.Response" + "type": "object", + "additionalProperties": true } }, "404": { @@ -2208,8 +2290,8 @@ } } }, - "put": { - "description": "update profile data for current user", + "post": { + "description": "create a new note in dashboard as current user", "consumes": [ "application/json" ], @@ -2217,33 +2299,29 @@ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" ], - "summary": "Update profile", + "summary": "Create dashboard note", "parameters": [ { - "description": "Profile update", - "name": "body", + "description": "Note data", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/updateprofile.Request" + "$ref": "#/definitions/createnote.Request" } } ], "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "201": { + "description": "Created", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/createnote.Response" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "type": "object", "additionalProperties": true @@ -2259,9 +2337,9 @@ } } }, - "/dashboard/profile/password": { - "put": { - "description": "update password for current authenticated user", + "/dashboard/my/notes/{correlationUUID}/{language_code}": { + "get": { + "description": "retrieve one note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2269,29 +2347,30 @@ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" ], - "summary": "Change user password", + "summary": "Get dashboard note", "parameters": [ { - "description": "Password change", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/changepassword.Request" - } + "type": "string", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", + "in": "path", + "required": true } ], "responses": { - "204": { - "description": "No Content" - }, - "400": { - "description": "Bad Request", + "200": { + "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response" } }, "404": { @@ -2309,11 +2388,9 @@ } } } - } - }, - "/dashboard/profile/roles": { - "get": { - "description": "returns roles for current user", + }, + "delete": { + "description": "remove a note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2321,14 +2398,34 @@ "application/json" ], "tags": [ - "dashboard profile" + "dashboard notes" + ], + "summary": "Delete dashboard note", + "parameters": [ + { + "type": "string", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", + "in": "path", + "required": true + } ], - "summary": "Get user roles", "responses": { - "200": { - "description": "OK", + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", "schema": { - "$ref": "#/definitions/getroles.Response" + "type": "object", + "additionalProperties": true } }, "500": { @@ -2341,9 +2438,9 @@ } } }, - "/dashboard/roles": { + "/dashboard/notes": { "get": { - "description": "paginated list of roles", + "description": "page through notes in dashboard, grouped by correlation uuid", "consumes": [ "application/json" ], @@ -2351,9 +2448,9 @@ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "List roles", + "summary": "List dashboard notes", "parameters": [ { "type": "integer", @@ -2367,7 +2464,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getroles.Response" + "$ref": "#/definitions/getnotes.Response" } }, "500": { @@ -2380,7 +2477,7 @@ } }, "put": { - "description": "modify an existing role", + "description": "update existing note fields, current user becomes the author", "consumes": [ "application/json" ], @@ -2388,17 +2485,17 @@ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Update role", + "summary": "Update a dashboard note", "parameters": [ { - "description": "Role update", - "name": "body", + "description": "Note update payload", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/updaterole.Request" + "$ref": "#/definitions/updatenote.Request" } } ], @@ -2430,7 +2527,7 @@ } }, "post": { - "description": "add a new role via dashboard", + "description": "create a new note in dashboard as current user", "consumes": [ "application/json" ], @@ -2438,17 +2535,17 @@ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Create role", + "summary": "Create dashboard note", "parameters": [ { - "description": "Role data", - "name": "body", + "description": "Note data", + "name": "note", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/createrole.Request" + "$ref": "#/definitions/createnote.Request" } } ], @@ -2456,7 +2553,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/createrole.Response" + "$ref": "#/definitions/createnote.Response" } }, "400": { @@ -2466,13 +2563,6 @@ "additionalProperties": true } }, - "404": { - "description": "Not Found", - "schema": { - "type": "object", - "additionalProperties": true - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -2483,9 +2573,9 @@ } } }, - "/dashboard/roles/{uuid}": { + "/dashboard/notes/{correlationUUID}/{language_code}": { "get": { - "description": "fetch role by UUID", + "description": "retrieve one note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2493,14 +2583,21 @@ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Get role", + "summary": "Get dashboard note", "parameters": [ { "type": "string", - "description": "Role UUID", - "name": "uuid", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", "in": "path", "required": true } @@ -2509,7 +2606,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getrole.Response" + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response" } }, "404": { @@ -2529,7 +2626,7 @@ } }, "delete": { - "description": "remove a role by UUID", + "description": "remove a note by correlation uuid and language", "consumes": [ "application/json" ], @@ -2537,14 +2634,21 @@ "application/json" ], "tags": [ - "dashboard roles" + "dashboard notes" ], - "summary": "Delete role", + "summary": "Delete dashboard note", "parameters": [ { "type": "string", - "description": "Role UUID", - "name": "uuid", + "description": "Note correlation UUID", + "name": "correlationUUID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Language code", + "name": "language_code", "in": "path", "required": true } @@ -2570,9 +2674,9 @@ } } }, - "/dashboard/users": { + "/dashboard/permissions": { "get": { - "description": "paginated list of users", + "description": "retrieve all permissions", "consumes": [ "application/json" ], @@ -2580,23 +2684,451 @@ "application/json" ], "tags": [ - "dashboard users" - ], - "summary": "List users", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - } + "dashboard permissions" ], + "summary": "List permissions", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getusers.Response" + "$ref": "#/definitions/getpermissions.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile": { + "get": { + "description": "retrieve profile for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Get profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getprofile.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "update profile data for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updateprofile.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile/password": { + "put": { + "description": "update password for current authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Change user password", + "parameters": [ + { + "description": "Password change", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/changepassword.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/profile/roles": { + "get": { + "description": "returns roles for current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard profile" + ], + "summary": "Get user roles", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getroles.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/roles": { + "get": { + "description": "paginated list of roles", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "List roles", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getroles.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "modify an existing role", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Update role", + "parameters": [ + { + "description": "Role update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updaterole.Request" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "add a new role via dashboard", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Create role", + "parameters": [ + { + "description": "Role data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createrole.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/createrole.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/roles/{uuid}": { + "get": { + "description": "fetch role by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Get role", + "parameters": [ + { + "type": "string", + "description": "Role UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getrole.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "remove a role by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard roles" + ], + "summary": "Delete role", + "parameters": [ + { + "type": "string", + "description": "Role UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/users": { + "get": { + "description": "paginated list of users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard users" + ], + "summary": "List users", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getusers.Response" } }, "500": { @@ -2899,7 +3431,7 @@ }, "/hashtags/{hashtag}": { "get": { - "description": "return a page of the most recent published articles with the given hashtag", + "description": "return a page of the most recent published articles or notes with the given hashtag. Each type is paginated on its own count; omitting `type` selects articles, or notes when the hashtag has no articles.", "consumes": [ "application/json" ], @@ -2909,7 +3441,7 @@ "tags": [ "hashtags" ], - "summary": "List articles by hashtag", + "summary": "List contents by hashtag", "parameters": [ { "type": "string", @@ -2918,6 +3450,16 @@ "in": "path", "required": true }, + { + "enum": [ + "article", + "note" + ], + "type": "string", + "description": "Content type", + "name": "type", + "in": "query" + }, { "type": "integer", "default": 1, @@ -2930,7 +3472,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/getArticlesByHashtag.Response" + "$ref": "#/definitions/getContentsByHashtag.Response" } }, "400": { @@ -3095,6 +3637,59 @@ } } }, + "/notes/{uuid}": { + "get": { + "description": "return a published note by its correlation uuid, in the requested language", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Show a published note", + "parameters": [ + { + "type": "string", + "description": "Note correlation UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_khanzadimahdi_testproject_application_note_getNote.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/tasks": { "get": { "description": "return tasks scheduled for this worker", @@ -3437,19 +4032,56 @@ "code": { "type": "string" }, - "name": { + "name": { + "type": "string" + } + } + }, + "createlanguage.Response": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + } + } + }, + "createnote.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, - "createlanguage.Response": { + "createnote.Response": { "type": "object", "properties": { - "code": { + "correlation_uuid": { "type": "string" }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" + }, + "language_code": { + "type": "string" } } }, @@ -3595,6 +4227,9 @@ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -3632,6 +4267,14 @@ }, "pagination": { "$ref": "#/definitions/getArticlesByAuthor.paginationResponse" + }, + "totals": { + "description": "How much of each kind the author has published, so the page can label both\ncontent tabs without asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getArticlesByAuthor.totalsResponse" + } + ] } } }, @@ -3706,65 +4349,50 @@ } } }, - "getArticlesByHashtag.Response": { + "getArticlesByAuthor.totalsResponse": { "type": "object", "properties": { - "elements": { - "type": "array", - "items": { - "$ref": "#/definitions/element.Response" - } - }, - "errors": { - "$ref": "#/definitions/domain.ValidationErrors" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/getArticlesByHashtag.articleResponse" - } - }, - "language_code": { - "$ref": "#/definitions/getArticlesByHashtag.languageResponse" + "articles": { + "type": "integer" }, - "pagination": { - "$ref": "#/definitions/getArticlesByHashtag.paginationResponse" + "notes": { + "type": "integer" } } }, - "getArticlesByHashtag.articleResponse": { + "getComment.Response": { "type": "object", "properties": { + "approved_at": { + "type": "string" + }, "author": { - "$ref": "#/definitions/getArticlesByHashtag.authorResponse" + "$ref": "#/definitions/getComment.author" }, - "available_languages": { - "type": "array", - "items": { - "$ref": "#/definitions/getArticlesByHashtag.languageResponse" - } + "body": { + "type": "string" }, - "correlation_uuid": { + "created_at": { "type": "string" }, - "cover": { + "language_code": { "type": "string" }, - "excerpt": { + "object_type": { "type": "string" }, - "published_at": { + "object_uuid": { "type": "string" }, - "title": { + "parent_uuid": { "type": "string" }, - "video": { + "uuid": { "type": "string" } } }, - "getArticlesByHashtag.authorResponse": { + "getComment.author": { "type": "object", "properties": { "avatar": { @@ -3781,91 +4409,154 @@ } } }, - "getArticlesByHashtag.languageResponse": { + "getConfig.Response": { "type": "object", "properties": { - "code": { + "default_language_code": { "type": "string" }, - "name": { - "type": "string" + "revision": { + "type": "integer" + }, + "user_default_roles": { + "type": "array", + "items": { + "type": "string" + } } } }, - "getArticlesByHashtag.paginationResponse": { + "getContentsByHashtag.Response": { "type": "object", "properties": { - "current_page": { - "type": "integer" + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } }, - "total_pages": { - "type": "integer" + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getContentsByHashtag.contentResponse" + } + }, + "language_code": { + "$ref": "#/definitions/getContentsByHashtag.languageResponse" + }, + "pagination": { + "$ref": "#/definitions/getContentsByHashtag.paginationResponse" + }, + "totals": { + "description": "How much of each kind the hashtag has, so the page can label both tabs\nwithout asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getContentsByHashtag.totalsResponse" + } + ] + }, + "type": { + "description": "Type is the kind of content Items holds, and the tab the page should show\nas selected — the requested one, or the one picked on the caller's behalf.", + "type": "string" } } }, - "getComment.Response": { + "getContentsByHashtag.authorResponse": { "type": "object", "properties": { - "approved_at": { + "avatar": { "type": "string" }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getContentsByHashtag.contentResponse": { + "type": "object", + "properties": { "author": { - "$ref": "#/definitions/getComment.author" + "$ref": "#/definitions/getContentsByHashtag.authorResponse" + }, + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getContentsByHashtag.languageResponse" + } }, "body": { + "description": "notes only", "type": "string" }, - "created_at": { + "correlation_uuid": { "type": "string" }, - "language_code": { + "cover": { + "description": "articles only", "type": "string" }, - "object_type": { + "excerpt": { "type": "string" }, - "object_uuid": { + "published_at": { "type": "string" }, - "parent_uuid": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { "type": "string" }, - "uuid": { + "type": { + "type": "string" + }, + "video": { "type": "string" } } }, - "getComment.author": { + "getContentsByHashtag.languageResponse": { "type": "object", "properties": { - "avatar": { + "code": { "type": "string" }, "name": { "type": "string" - }, - "username": { - "type": "string" - }, - "uuid": { - "type": "string" } } }, - "getConfig.Response": { + "getContentsByHashtag.paginationResponse": { "type": "object", "properties": { - "default_language_code": { - "type": "string" + "current_page": { + "type": "integer" }, - "revision": { + "total_pages": { + "type": "integer" + } + } + }, + "getContentsByHashtag.totalsResponse": { + "type": "object", + "properties": { + "articles": { "type": "integer" }, - "user_default_roles": { - "type": "array", - "items": { - "type": "string" - } + "notes": { + "type": "integer" } } }, @@ -3904,35 +4595,154 @@ "memory_percent": { "type": "number" }, - "memory_usage": { - "type": "integer" + "memory_usage": { + "type": "integer" + }, + "network_input": { + "type": "integer" + }, + "network_output": { + "type": "integer" + }, + "pids": { + "type": "integer" + } + } + }, + "getNodes.NodeResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "last_heartbeat_at": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getNodes.Pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "getNodes.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getNodes.NodeResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getNodes.Pagination" + } + } + }, + "getNotesByAuthor.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getNotesByAuthor.authorResponse" + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } + }, + "errors": { + "$ref": "#/definitions/domain.ValidationErrors" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getNotesByAuthor.noteResponse" + } + }, + "language_code": { + "$ref": "#/definitions/getNotesByAuthor.languageResponse" + }, + "pagination": { + "$ref": "#/definitions/getNotesByAuthor.paginationResponse" + }, + "totals": { + "description": "How much of each kind the author has published, so the page can label both\ncontent tabs without asking twice.", + "allOf": [ + { + "$ref": "#/definitions/getNotesByAuthor.totalsResponse" + } + ] + } + } + }, + "getNotesByAuthor.authorResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "created_at": { + "type": "string" }, - "network_input": { - "type": "integer" + "name": { + "type": "string" }, - "network_output": { - "type": "integer" + "username": { + "type": "string" }, - "pids": { - "type": "integer" + "uuid": { + "type": "string" } } }, - "getNodes.NodeResponse": { + "getNotesByAuthor.languageResponse": { "type": "object", "properties": { - "created_at": { + "code": { "type": "string" }, - "last_heartbeat_at": { + "name": { "type": "string" + } + } + }, + "getNotesByAuthor.noteResponse": { + "type": "object", + "properties": { + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getNotesByAuthor.languageResponse" + } }, - "name": { + "body": { "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, - "getNodes.Pagination": { + "getNotesByAuthor.paginationResponse": { "type": "object", "properties": { "current_page": { @@ -3943,17 +4753,14 @@ } } }, - "getNodes.Response": { + "getNotesByAuthor.totalsResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/getNodes.NodeResponse" - } + "articles": { + "type": "integer" }, - "pagination": { - "$ref": "#/definitions/getNodes.Pagination" + "notes": { + "type": "integer" } } }, @@ -4340,6 +5147,135 @@ } } }, + "getnote.author": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnote.authorResponse": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnote.languageResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getnotes.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getnotes.noteResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getnotes.pagination" + } + } + }, + "getnotes.author": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getnotes.corrolatedNoteResponse": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnotes.author" + }, + "body": { + "type": "string" + }, + "language": { + "$ref": "#/definitions/getnotes.languageResponse" + }, + "published_at": { + "type": "string" + } + } + }, + "getnotes.languageResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "getnotes.noteResponse": { + "type": "object", + "properties": { + "correlation_uuid": { + "type": "string" + }, + "corrolated_items": { + "type": "array", + "items": { + "$ref": "#/definitions/getnotes.corrolatedNoteResponse" + } + } + } + }, + "getnotes.pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, "getpermissions.Response": { "type": "object", "properties": { @@ -5069,6 +6005,32 @@ } } }, + "github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnote.author" + }, + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "github_com_khanzadimahdi_testproject_application_language_getLanguages.Response": { "type": "object", "properties": { @@ -5094,6 +6056,50 @@ } } }, + "github_com_khanzadimahdi_testproject_application_note_getNote.Response": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/getnote.authorResponse" + }, + "available_languages": { + "type": "array", + "items": { + "$ref": "#/definitions/getnote.languageResponse" + } + }, + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/definitions/element.Response" + } + }, + "language_code": { + "$ref": "#/definitions/getnote.languageResponse" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "validation_errors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, "github_com_khanzadimahdi_testproject_application_runner_manager_task_getTasks.Response": { "type": "object", "properties": { @@ -5674,6 +6680,29 @@ } } }, + "updatenote.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "correlation_uuid": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "updateprofile.Request": { "type": "object", "properties": { diff --git a/resources/docs/blog/openapi/swagger.yaml b/resources/docs/blog/openapi/swagger.yaml index 9b0392f4..9939b95e 100644 --- a/resources/docs/blog/openapi/swagger.yaml +++ b/resources/docs/blog/openapi/swagger.yaml @@ -85,6 +85,30 @@ definitions: errors: $ref: '#/definitions/domain.ValidationErrors' type: object + createnote.Request: + properties: + body: + type: string + correlation_uuid: + type: string + language_code: + type: string + published_at: + type: string + tags: + items: + type: string + type: array + type: object + createnote.Response: + properties: + correlation_uuid: + type: string + errors: + $ref: '#/definitions/domain.ValidationErrors' + language_code: + type: string + type: object createrole.Request: properties: description: @@ -178,6 +202,8 @@ definitions: body: {} type: type: string + uuid: + type: string type: object forgetpassword.Request: properties: @@ -202,6 +228,12 @@ definitions: $ref: '#/definitions/getArticlesByAuthor.languageResponse' pagination: $ref: '#/definitions/getArticlesByAuthor.paginationResponse' + totals: + allOf: + - $ref: '#/definitions/getArticlesByAuthor.totalsResponse' + description: |- + How much of each kind the author has published, so the page can label both + content tabs without asking twice. type: object getArticlesByAuthor.articleResponse: properties: @@ -249,67 +281,11 @@ definitions: total_pages: type: integer type: object - getArticlesByHashtag.Response: - properties: - elements: - items: - $ref: '#/definitions/element.Response' - type: array - errors: - $ref: '#/definitions/domain.ValidationErrors' - items: - items: - $ref: '#/definitions/getArticlesByHashtag.articleResponse' - type: array - language_code: - $ref: '#/definitions/getArticlesByHashtag.languageResponse' - pagination: - $ref: '#/definitions/getArticlesByHashtag.paginationResponse' - type: object - getArticlesByHashtag.articleResponse: - properties: - author: - $ref: '#/definitions/getArticlesByHashtag.authorResponse' - available_languages: - items: - $ref: '#/definitions/getArticlesByHashtag.languageResponse' - type: array - correlation_uuid: - type: string - cover: - type: string - excerpt: - type: string - published_at: - type: string - title: - type: string - video: - type: string - type: object - getArticlesByHashtag.authorResponse: - properties: - avatar: - type: string - name: - type: string - username: - type: string - uuid: - type: string - type: object - getArticlesByHashtag.languageResponse: - properties: - code: - type: string - name: - type: string - type: object - getArticlesByHashtag.paginationResponse: + getArticlesByAuthor.totalsResponse: properties: - current_page: + articles: type: integer - total_pages: + notes: type: integer type: object getComment.Response: @@ -355,6 +331,97 @@ definitions: type: string type: array type: object + getContentsByHashtag.Response: + properties: + elements: + items: + $ref: '#/definitions/element.Response' + type: array + errors: + $ref: '#/definitions/domain.ValidationErrors' + items: + items: + $ref: '#/definitions/getContentsByHashtag.contentResponse' + type: array + language_code: + $ref: '#/definitions/getContentsByHashtag.languageResponse' + pagination: + $ref: '#/definitions/getContentsByHashtag.paginationResponse' + totals: + allOf: + - $ref: '#/definitions/getContentsByHashtag.totalsResponse' + description: |- + How much of each kind the hashtag has, so the page can label both tabs + without asking twice. + type: + description: |- + Type is the kind of content Items holds, and the tab the page should show + as selected — the requested one, or the one picked on the caller's behalf. + type: string + type: object + getContentsByHashtag.authorResponse: + properties: + avatar: + type: string + name: + type: string + username: + type: string + uuid: + type: string + type: object + getContentsByHashtag.contentResponse: + properties: + author: + $ref: '#/definitions/getContentsByHashtag.authorResponse' + available_languages: + items: + $ref: '#/definitions/getContentsByHashtag.languageResponse' + type: array + body: + description: notes only + type: string + correlation_uuid: + type: string + cover: + description: articles only + type: string + excerpt: + type: string + published_at: + type: string + tags: + items: + type: string + type: array + title: + type: string + type: + type: string + video: + type: string + type: object + getContentsByHashtag.languageResponse: + properties: + code: + type: string + name: + type: string + type: object + getContentsByHashtag.paginationResponse: + properties: + current_page: + type: integer + total_pages: + type: integer + type: object + getContentsByHashtag.totalsResponse: + properties: + articles: + type: integer + notes: + type: integer + type: object getNode.Response: properties: created_at: @@ -412,6 +479,82 @@ definitions: pagination: $ref: '#/definitions/getNodes.Pagination' type: object + getNotesByAuthor.Response: + properties: + author: + $ref: '#/definitions/getNotesByAuthor.authorResponse' + elements: + items: + $ref: '#/definitions/element.Response' + type: array + errors: + $ref: '#/definitions/domain.ValidationErrors' + items: + items: + $ref: '#/definitions/getNotesByAuthor.noteResponse' + type: array + language_code: + $ref: '#/definitions/getNotesByAuthor.languageResponse' + pagination: + $ref: '#/definitions/getNotesByAuthor.paginationResponse' + totals: + allOf: + - $ref: '#/definitions/getNotesByAuthor.totalsResponse' + description: |- + How much of each kind the author has published, so the page can label both + content tabs without asking twice. + type: object + getNotesByAuthor.authorResponse: + properties: + avatar: + type: string + created_at: + type: string + name: + type: string + username: + type: string + uuid: + type: string + type: object + getNotesByAuthor.languageResponse: + properties: + code: + type: string + name: + type: string + type: object + getNotesByAuthor.noteResponse: + properties: + available_languages: + items: + $ref: '#/definitions/getNotesByAuthor.languageResponse' + type: array + body: + type: string + correlation_uuid: + type: string + published_at: + type: string + tags: + items: + type: string + type: array + type: object + getNotesByAuthor.paginationResponse: + properties: + current_page: + type: integer + total_pages: + type: integer + type: object + getNotesByAuthor.totalsResponse: + properties: + articles: + type: integer + notes: + type: integer + type: object getUserBookmarks.Response: properties: errors: @@ -660,28 +803,21 @@ definitions: total_pages: type: integer type: object - getpermissions.Response: - properties: - items: - items: - $ref: '#/definitions/getpermissions.permissionResponse' - type: array - type: object - getpermissions.permissionResponse: + getnote.author: properties: + avatar: + type: string name: type: string - value: + username: + type: string + uuid: type: string type: object - getprofile.Response: + getnote.authorResponse: properties: avatar: type: string - email: - type: string - language_code: - type: string name: type: string username: @@ -689,61 +825,151 @@ definitions: uuid: type: string type: object - getrole.Response: + getnote.languageResponse: properties: - description: + code: type: string name: type: string - permissions: - items: - type: string - type: array - user_uuids: - items: - type: string - type: array - uuid: - type: string type: object - getroles.Response: + getnotes.Response: properties: items: items: - $ref: '#/definitions/getroles.roleResponse' + $ref: '#/definitions/getnotes.noteResponse' type: array pagination: - $ref: '#/definitions/getroles.pagination' + $ref: '#/definitions/getnotes.pagination' type: object - getroles.pagination: - properties: - current_page: - type: integer - total_pages: - type: integer - type: object - getroles.roleResponse: + getnotes.author: properties: - description: + avatar: type: string name: type: string + username: + type: string uuid: type: string type: object - gettask.Response: + getnotes.corrolatedNoteResponse: properties: - attach_stderr: - type: boolean - attach_stdin: - type: boolean - attach_stdout: - type: boolean - auto_remove: - type: boolean - command: - items: - type: string + author: + $ref: '#/definitions/getnotes.author' + body: + type: string + language: + $ref: '#/definitions/getnotes.languageResponse' + published_at: + type: string + type: object + getnotes.languageResponse: + properties: + code: + type: string + name: + type: string + type: object + getnotes.noteResponse: + properties: + correlation_uuid: + type: string + corrolated_items: + items: + $ref: '#/definitions/getnotes.corrolatedNoteResponse' + type: array + type: object + getnotes.pagination: + properties: + current_page: + type: integer + total_pages: + type: integer + type: object + getpermissions.Response: + properties: + items: + items: + $ref: '#/definitions/getpermissions.permissionResponse' + type: array + type: object + getpermissions.permissionResponse: + properties: + name: + type: string + value: + type: string + type: object + getprofile.Response: + properties: + avatar: + type: string + email: + type: string + language_code: + type: string + name: + type: string + username: + type: string + uuid: + type: string + type: object + getrole.Response: + properties: + description: + type: string + name: + type: string + permissions: + items: + type: string + type: array + user_uuids: + items: + type: string + type: array + uuid: + type: string + type: object + getroles.Response: + properties: + items: + items: + $ref: '#/definitions/getroles.roleResponse' + type: array + pagination: + $ref: '#/definitions/getroles.pagination' + type: object + getroles.pagination: + properties: + current_page: + type: integer + total_pages: + type: integer + type: object + getroles.roleResponse: + properties: + description: + type: string + name: + type: string + uuid: + type: string + type: object + gettask.Response: + properties: + attach_stderr: + type: boolean + attach_stdin: + type: boolean + attach_stdout: + type: boolean + auto_remove: + type: boolean + command: + items: + type: string type: array container_id: type: string @@ -1134,6 +1360,23 @@ definitions: name: type: string type: object + github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response: + properties: + author: + $ref: '#/definitions/getnote.author' + body: + type: string + correlation_uuid: + type: string + language_code: + type: string + published_at: + type: string + tags: + items: + type: string + type: array + type: object github_com_khanzadimahdi_testproject_application_language_getLanguages.Response: properties: default_language: @@ -1150,6 +1393,35 @@ definitions: name: type: string type: object + github_com_khanzadimahdi_testproject_application_note_getNote.Response: + properties: + author: + $ref: '#/definitions/getnote.authorResponse' + available_languages: + items: + $ref: '#/definitions/getnote.languageResponse' + type: array + body: + type: string + correlation_uuid: + type: string + elements: + items: + $ref: '#/definitions/element.Response' + type: array + language_code: + $ref: '#/definitions/getnote.languageResponse' + published_at: + type: string + tags: + items: + type: string + type: array + validation_errors: + additionalProperties: + type: string + type: object + type: object github_com_khanzadimahdi_testproject_application_runner_manager_task_getTasks.Response: properties: items: @@ -1527,6 +1799,21 @@ definitions: name: type: string type: object + updatenote.Request: + properties: + body: + type: string + correlation_uuid: + type: string + language_code: + type: string + published_at: + type: string + tags: + items: + type: string + type: array + type: object updateprofile.Request: properties: avatar: @@ -1899,6 +2186,49 @@ paths: summary: List articles by author tags: - authors + /authors/{identity}/notes: + get: + consumes: + - application/json + description: return a page of the most recent published notes for the given + author identity (UUID or username). Responds 404 when the author keeps their + notes private. + parameters: + - description: Author UUID or username + in: path + name: identity + required: true + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getNotesByAuthor.Response' + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List notes by author + tags: + - authors /bookmarks: put: consumes: @@ -2630,13 +2960,390 @@ paths: type: object summary: Delete element tags: - - dashboard elements - get: + - dashboard elements + get: + consumes: + - application/json + description: fetch element by UUID + parameters: + - description: Element UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getelement.Response' + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Get element + tags: + - dashboard elements + /dashboard/files: + get: + consumes: + - application/json + description: paginated list of all files + parameters: + - default: 1 + description: Page + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getfiles.Response' + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List files + tags: + - dashboard files + post: + consumes: + - multipart/form-data + description: upload a file via multipart form + parameters: + - description: File to upload + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/createfile.Response' + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Upload file + tags: + - dashboard files + /dashboard/files/{uuid}: + delete: + consumes: + - application/json + description: remove file by UUID + parameters: + - description: File UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Delete file + tags: + - dashboard files + get: + consumes: + - application/json + description: retrieve file content by UUID + parameters: + - description: File UUID + in: path + name: uuid + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Download file + tags: + - dashboard files + /dashboard/files/user: + get: + consumes: + - application/json + description: paginated list of files owned by current user + parameters: + - default: 1 + description: Page + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getuserfiles.Response' + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List own files + tags: + - dashboard files + /dashboard/languages: + get: + consumes: + - application/json + description: paginated list of languages + parameters: + - default: 1 + description: Page + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_language_getLanguages.Response' + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List languages + tags: + - dashboard languages + post: + consumes: + - application/json + description: add a new language via dashboard + parameters: + - description: Language data + in: body + name: body + required: true + schema: + $ref: '#/definitions/createlanguage.Request' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/createlanguage.Response' + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Create language + tags: + - dashboard languages + put: + consumes: + - application/json + description: modify an existing language + parameters: + - description: Language update + in: body + name: body + required: true + schema: + $ref: '#/definitions/updatelanguage.Request' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Update language + tags: + - dashboard languages + /dashboard/languages/{code}: + delete: + consumes: + - application/json + description: remove a language by code + parameters: + - description: Language code + in: path + name: code + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Delete language + tags: + - dashboard languages + get: + consumes: + - application/json + description: fetch language by code + parameters: + - description: Language code + in: path + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getlanguage.Response' + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Get language + tags: + - dashboard languages + /dashboard/my/bookmarks: + delete: + consumes: + - application/json + description: delete a bookmark owned by current user + parameters: + - description: Bookmark to delete + in: body + name: body + required: true + schema: + $ref: '#/definitions/deleteUserBookmark.Request' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Delete my bookmark + tags: + - dashboard bookmarks + get: + consumes: + - application/json + description: paginated list of bookmarks for authenticated user + parameters: + - default: 1 + description: Page + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getUserBookmarks.Response' + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List my bookmarks + tags: + - dashboard bookmarks + /dashboard/my/files/{uuid}: + delete: consumes: - application/json - description: fetch element by UUID + description: remove a file owned by authenticated user parameters: - - description: Element UUID + - description: File UUID in: path name: uuid required: true @@ -2644,10 +3351,11 @@ paths: produces: - application/json responses: - "200": - description: OK + "204": + description: No Content schema: - $ref: '#/definitions/getelement.Response' + additionalProperties: true + type: object "404": description: Not Found schema: @@ -2658,14 +3366,14 @@ paths: schema: additionalProperties: true type: object - summary: Get element + summary: Delete own file tags: - - dashboard elements - /dashboard/files: + - dashboard files + /dashboard/my/notes: get: consumes: - application/json - description: paginated list of all files + description: page through notes in dashboard, grouped by correlation uuid parameters: - default: 1 description: Page @@ -2678,32 +3386,33 @@ paths: "200": description: OK schema: - $ref: '#/definitions/getfiles.Response' + $ref: '#/definitions/getnotes.Response' "500": description: Internal Server Error schema: additionalProperties: true type: object - summary: List files + summary: List dashboard notes tags: - - dashboard files + - dashboard notes post: consumes: - - multipart/form-data - description: upload a file via multipart form + - application/json + description: create a new note in dashboard as current user parameters: - - description: File to upload - in: formData - name: file + - description: Note data + in: body + name: note required: true - type: file + schema: + $ref: '#/definitions/createnote.Request' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/createfile.Response' + $ref: '#/definitions/createnote.Response' "400": description: Bad Request schema: @@ -2714,25 +3423,30 @@ paths: schema: additionalProperties: true type: object - summary: Upload file + summary: Create dashboard note tags: - - dashboard files - /dashboard/files/{uuid}: - delete: + - dashboard notes + put: consumes: - application/json - description: remove file by UUID + description: update existing note fields, current user becomes the author parameters: - - description: File UUID - in: path - name: uuid + - description: Note update payload + in: body + name: note required: true - type: string + schema: + $ref: '#/definitions/updatenote.Request' produces: - application/json responses: "204": description: No Content + "400": + description: Bad Request + schema: + additionalProperties: true + type: object "404": description: Not Found schema: @@ -2743,26 +3457,30 @@ paths: schema: additionalProperties: true type: object - summary: Delete file + summary: Update a dashboard note tags: - - dashboard files - get: + - dashboard notes + /dashboard/my/notes/{correlationUUID}/{language_code}: + delete: consumes: - application/json - description: retrieve file content by UUID + description: remove a note by correlation uuid and language parameters: - - description: File UUID + - description: Note correlation UUID in: path - name: uuid + name: correlationUUID + required: true + type: string + - description: Language code + in: path + name: language_code required: true type: string produces: - - application/octet-stream + - application/json responses: - "200": - description: OK - schema: - type: file + "204": + description: No Content "404": description: Not Found schema: @@ -2773,40 +3491,49 @@ paths: schema: additionalProperties: true type: object - summary: Download file + summary: Delete dashboard note tags: - - dashboard files - /dashboard/files/user: + - dashboard notes get: consumes: - application/json - description: paginated list of files owned by current user + description: retrieve one note by correlation uuid and language parameters: - - default: 1 - description: Page - in: query - name: page - type: integer + - description: Note correlation UUID + in: path + name: correlationUUID + required: true + type: string + - description: Language code + in: path + name: language_code + required: true + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/getuserfiles.Response' + $ref: '#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response' + "404": + description: Not Found + schema: + additionalProperties: true + type: object "500": description: Internal Server Error schema: additionalProperties: true type: object - summary: List own files + summary: Get dashboard note tags: - - dashboard files - /dashboard/languages: + - dashboard notes + /dashboard/notes: get: consumes: - application/json - description: paginated list of languages + description: page through notes in dashboard, grouped by correlation uuid parameters: - default: 1 description: Page @@ -2819,33 +3546,33 @@ paths: "200": description: OK schema: - $ref: '#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_language_getLanguages.Response' + $ref: '#/definitions/getnotes.Response' "500": description: Internal Server Error schema: additionalProperties: true type: object - summary: List languages + summary: List dashboard notes tags: - - dashboard languages + - dashboard notes post: consumes: - application/json - description: add a new language via dashboard + description: create a new note in dashboard as current user parameters: - - description: Language data + - description: Note data in: body - name: body + name: note required: true schema: - $ref: '#/definitions/createlanguage.Request' + $ref: '#/definitions/createnote.Request' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/createlanguage.Response' + $ref: '#/definitions/createnote.Response' "400": description: Bad Request schema: @@ -2856,20 +3583,20 @@ paths: schema: additionalProperties: true type: object - summary: Create language + summary: Create dashboard note tags: - - dashboard languages + - dashboard notes put: consumes: - application/json - description: modify an existing language + description: update existing note fields, current user becomes the author parameters: - - description: Language update + - description: Note update payload in: body - name: body + name: note required: true schema: - $ref: '#/definitions/updatelanguage.Request' + $ref: '#/definitions/updatenote.Request' produces: - application/json responses: @@ -2890,18 +3617,23 @@ paths: schema: additionalProperties: true type: object - summary: Update language + summary: Update a dashboard note tags: - - dashboard languages - /dashboard/languages/{code}: + - dashboard notes + /dashboard/notes/{correlationUUID}/{language_code}: delete: consumes: - application/json - description: remove a language by code + description: remove a note by correlation uuid and language parameters: + - description: Note correlation UUID + in: path + name: correlationUUID + required: true + type: string - description: Language code in: path - name: code + name: language_code required: true type: string produces: @@ -2919,113 +3651,31 @@ paths: schema: additionalProperties: true type: object - summary: Delete language + summary: Delete dashboard note tags: - - dashboard languages + - dashboard notes get: consumes: - application/json - description: fetch language by code + description: retrieve one note by correlation uuid and language parameters: - - description: Language code + - description: Note correlation UUID in: path - name: code + name: correlationUUID required: true type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/getlanguage.Response' - "404": - description: Not Found - schema: - additionalProperties: true - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: true - type: object - summary: Get language - tags: - - dashboard languages - /dashboard/my/bookmarks: - delete: - consumes: - - application/json - description: delete a bookmark owned by current user - parameters: - - description: Bookmark to delete - in: body - name: body - required: true - schema: - $ref: '#/definitions/deleteUserBookmark.Request' - produces: - - application/json - responses: - "204": - description: No Content - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: true - type: object - summary: Delete my bookmark - tags: - - dashboard bookmarks - get: - consumes: - - application/json - description: paginated list of bookmarks for authenticated user - parameters: - - default: 1 - description: Page - in: query - name: page - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/getUserBookmarks.Response' - "500": - description: Internal Server Error - schema: - additionalProperties: true - type: object - summary: List my bookmarks - tags: - - dashboard bookmarks - /dashboard/my/files/{uuid}: - delete: - consumes: - - application/json - description: remove a file owned by authenticated user - parameters: - - description: File UUID + - description: Language code in: path - name: uuid + name: language_code required: true type: string produces: - application/json responses: - "204": - description: No Content + "200": + description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/github_com_khanzadimahdi_testproject_application_dashboard_note_getNote.Response' "404": description: Not Found schema: @@ -3036,9 +3686,9 @@ paths: schema: additionalProperties: true type: object - summary: Delete own file + summary: Get dashboard note tags: - - dashboard files + - dashboard notes /dashboard/permissions: get: consumes: @@ -3553,14 +4203,22 @@ paths: get: consumes: - application/json - description: return a page of the most recent published articles with the given - hashtag + description: return a page of the most recent published articles or notes with + the given hashtag. Each type is paginated on its own count; omitting `type` + selects articles, or notes when the hashtag has no articles. parameters: - description: Hashtag in: path name: hashtag required: true type: string + - description: Content type + enum: + - article + - note + in: query + name: type + type: string - default: 1 description: Page number in: query @@ -3572,7 +4230,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/getArticlesByHashtag.Response' + $ref: '#/definitions/getContentsByHashtag.Response' "400": description: Bad Request schema: @@ -3583,7 +4241,7 @@ paths: schema: additionalProperties: true type: object - summary: List articles by hashtag + summary: List contents by hashtag tags: - hashtags /home: @@ -3683,6 +4341,43 @@ paths: summary: Get node tags: - runner nodes + /notes/{uuid}: + get: + consumes: + - application/json + description: return a published note by its correlation uuid, in the requested + language + parameters: + - description: Note correlation UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_khanzadimahdi_testproject_application_note_getNote.Response' + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Show a published note + tags: + - notes /tasks: get: consumes: