diff --git a/application/article/getArticlesByAuthor/request.go b/application/article/getArticlesByAuthor/request.go index dcc62ee9..ff5a724b 100644 --- a/application/article/getArticlesByAuthor/request.go +++ b/application/article/getArticlesByAuthor/request.go @@ -4,7 +4,7 @@ import ( "github.com/gofrs/uuid/v5" "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -29,7 +29,7 @@ func (r *Request) Validate() domain.ValidationErrors { } } - if len(r.Username) > 0 && !user.IsValidUsername(r.Username) { + if len(r.Username) > 0 && !rules.IsValidUsername(r.Username) { validationErrors["username"] = "invalid_value" } diff --git a/application/auth/register/request.go b/application/auth/register/request.go index 7ab8056b..0bf87a47 100644 --- a/application/auth/register/request.go +++ b/application/auth/register/request.go @@ -2,7 +2,7 @@ package register import ( "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -18,7 +18,7 @@ var _ domain.Validatable = &Request{} func (r *Request) Validate() domain.ValidationErrors { validationErrors := make(domain.ValidationErrors) - if !user.IsValidEmail(r.Identity) { + if !rules.IsValidEmail(r.Identity) { validationErrors["identity"] = "invalid_email" } diff --git a/application/auth/verify/request.go b/application/auth/verify/request.go index e9a641f3..2ecf3a78 100644 --- a/application/auth/verify/request.go +++ b/application/auth/verify/request.go @@ -2,7 +2,7 @@ package verify import ( "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -29,7 +29,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Username) == 0 { validationErrors["username"] = "required_field" - } else if !user.IsValidUsername(r.Username) { + } else if !rules.IsValidUsername(r.Username) { validationErrors["username"] = "invalid_value" } diff --git a/application/contact/createMessage/request.go b/application/contact/createMessage/request.go new file mode 100644 index 00000000..fb90e5c5 --- /dev/null +++ b/application/contact/createMessage/request.go @@ -0,0 +1,48 @@ +package createMessage + +import ( + "strings" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" +) + +type Request struct { + Subject string `json:"subject"` + Body string `json:"body"` + Email string `json:"email"` + Phone string `json:"phone"` +} + +var _ domain.Validatable = &Request{} + +func (r *Request) Validate() domain.ValidationErrors { + validationErrors := make(domain.ValidationErrors) + + if len(strings.TrimSpace(r.Subject)) == 0 { + validationErrors["subject"] = "required_field" + } + + if len(strings.TrimSpace(r.Body)) == 0 { + validationErrors["body"] = "required_field" + } + + // The sender is anonymous, so at least one way of reaching them back is + // required; whichever one they filled in still has to be well-formed. + if len(r.Email) == 0 && len(r.Phone) == 0 { + validationErrors["email"] = "email_or_phone_required" + validationErrors["phone"] = "email_or_phone_required" + + return validationErrors + } + + if len(r.Email) > 0 && !rules.IsValidEmail(r.Email) { + validationErrors["email"] = "invalid_email" + } + + if len(r.Phone) > 0 && !rules.IsValidPhoneNumber(r.Phone) { + validationErrors["phone"] = "invalid_phone_number" + } + + return validationErrors +} diff --git a/application/contact/createMessage/request_test.go b/application/contact/createMessage/request_test.go new file mode 100644 index 00000000..14375dad --- /dev/null +++ b/application/contact/createMessage/request_test.go @@ -0,0 +1,118 @@ +package createMessage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/khanzadimahdi/testproject/domain" +) + +func TestRequest_Validate(t *testing.T) { + tests := []struct { + name string + request Request + want domain.ValidationErrors + }{ + { + name: "valid request with an email", + request: Request{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + }, + want: domain.ValidationErrors{}, + }, + { + name: "valid request with a phone number", + request: Request{ + Subject: "a subject", + Body: "a body", + Phone: "09123456789", + }, + want: domain.ValidationErrors{}, + }, + { + name: "valid request with both", + request: Request{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + Phone: "1234", + }, + want: domain.ValidationErrors{}, + }, + { + name: "missing subject and body", + request: Request{ + Email: "user@example.com", + }, + want: domain.ValidationErrors{ + "subject": "required_field", + "body": "required_field", + }, + }, + { + name: "blank subject and body", + request: Request{ + Subject: " ", + Body: "\n\t", + Email: "user@example.com", + }, + want: domain.ValidationErrors{ + "subject": "required_field", + "body": "required_field", + }, + }, + { + name: "neither an email nor a phone number", + request: Request{ + Subject: "a subject", + Body: "a body", + }, + want: domain.ValidationErrors{ + "email": "email_or_phone_required", + "phone": "email_or_phone_required", + }, + }, + { + name: "malformed email", + request: Request{ + Subject: "a subject", + Body: "a body", + Email: "not-an-email", + }, + want: domain.ValidationErrors{ + "email": "invalid_email", + }, + }, + { + name: "malformed phone number", + request: Request{ + Subject: "a subject", + Body: "a body", + Phone: "12-3", + }, + want: domain.ValidationErrors{ + "phone": "invalid_phone_number", + }, + }, + { + name: "too short phone number", + request: Request{ + Subject: "a subject", + Body: "a body", + Phone: "123", + }, + want: domain.ValidationErrors{ + "phone": "invalid_phone_number", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.request.Validate()) + }) + } +} diff --git a/application/contact/createMessage/response.go b/application/contact/createMessage/response.go new file mode 100644 index 00000000..b7254749 --- /dev/null +++ b/application/contact/createMessage/response.go @@ -0,0 +1,7 @@ +package createMessage + +import "github.com/khanzadimahdi/testproject/domain" + +type Response struct { + ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` +} diff --git a/application/contact/createMessage/usecase.go b/application/contact/createMessage/usecase.go new file mode 100644 index 00000000..3e22f4e8 --- /dev/null +++ b/application/contact/createMessage/usecase.go @@ -0,0 +1,42 @@ +package createMessage + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type UseCase struct { + contactRepository contact.Repository + validator domain.Validator +} + +func NewUseCase( + contactRepository contact.Repository, + validator domain.Validator, +) *UseCase { + return &UseCase{ + contactRepository: contactRepository, + 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 + } + + m := contact.Message{ + Subject: request.Subject, + Body: request.Body, + Email: request.Email, + Phone: request.Phone, + } + + _, err := uc.contactRepository.Save(ctx, &m) + + return nil, err +} diff --git a/application/contact/createMessage/usecase_test.go b/application/contact/createMessage/usecase_test.go new file mode 100644 index 00000000..20dc6cb7 --- /dev/null +++ b/application/contact/createMessage/usecase_test.go @@ -0,0 +1,115 @@ +package createMessage + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" + "github.com/khanzadimahdi/testproject/infrastructure/validator" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("sends a message", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + validator validator.MockValidator + + r = Request{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + Phone: "09123456789", + } + + m = contact.Message{ + Subject: r.Subject, + Body: r.Body, + Email: r.Email, + Phone: r.Phone, + } + ) + + validator.On("Validate", &r).Once().Return(nil) + defer validator.AssertExpectations(t) + + contactRepository.On("Save", mock.Anything, &m).Once().Return("message-uuid", nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository, &validator).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Nil(t, response) + }) + + t.Run("validation fails", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + validator validator.MockValidator + + r = Request{} + expectedResponse = Response{ + ValidationErrors: map[string]string{ + "subject": "this field is required", + "body": "this field is required", + "email": "either an email address or a phone number is required", + "phone": "either an email address or a phone number is required", + }, + } + ) + + validator.On("Validate", &r).Once().Return(expectedResponse.ValidationErrors) + defer validator.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository, &validator).Execute(context.Background(), &r) + + contactRepository.AssertNotCalled(t, "Save") + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("saving the message fails", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + validator validator.MockValidator + + r = Request{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + } + + m = contact.Message{ + Subject: r.Subject, + Body: r.Body, + Email: r.Email, + } + + expectedErr = errors.New("saving the message failed") + ) + + validator.On("Validate", &r).Once().Return(nil) + defer validator.AssertExpectations(t) + + contactRepository.On("Save", mock.Anything, &m).Once().Return("", expectedErr) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository, &validator).Execute(context.Background(), &r) + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/application/dashboard/contact/deleteMessage/request.go b/application/dashboard/contact/deleteMessage/request.go new file mode 100644 index 00000000..0222f79f --- /dev/null +++ b/application/dashboard/contact/deleteMessage/request.go @@ -0,0 +1,5 @@ +package deleteMessage + +type Request struct { + MessageUUID string `json:"uuid"` +} diff --git a/application/dashboard/contact/deleteMessage/usecase.go b/application/dashboard/contact/deleteMessage/usecase.go new file mode 100644 index 00000000..4bd14e45 --- /dev/null +++ b/application/dashboard/contact/deleteMessage/usecase.go @@ -0,0 +1,21 @@ +package deleteMessage + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type UseCase struct { + contactRepository contact.Repository +} + +func NewUseCase(contactRepository contact.Repository) *UseCase { + return &UseCase{ + contactRepository: contactRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) error { + return uc.contactRepository.Delete(ctx, request.MessageUUID) +} diff --git a/application/dashboard/contact/deleteMessage/usecase_test.go b/application/dashboard/contact/deleteMessage/usecase_test.go new file mode 100644 index 00000000..efdcbfce --- /dev/null +++ b/application/dashboard/contact/deleteMessage/usecase_test.go @@ -0,0 +1,56 @@ +package deleteMessage + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("deleting a message succeeds", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + r = Request{ + MessageUUID: "message-uuid", + } + ) + + contactRepository.On("Delete", mock.Anything, r.MessageUUID).Once().Return(nil) + defer contactRepository.AssertExpectations(t) + + err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.NoError(t, err) + }) + + t.Run("deleting a message fails", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + r = Request{ + MessageUUID: "message-uuid", + } + + expectedErr = errors.New("deleting the message failed") + ) + + contactRepository.On("Delete", mock.Anything, r.MessageUUID).Once().Return(expectedErr) + defer contactRepository.AssertExpectations(t) + + err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.ErrorIs(t, err, expectedErr) + }) +} diff --git a/application/dashboard/contact/getMessage/response.go b/application/dashboard/contact/getMessage/response.go new file mode 100644 index 00000000..1ac5db4f --- /dev/null +++ b/application/dashboard/contact/getMessage/response.go @@ -0,0 +1,29 @@ +package getMessage + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type Response struct { + UUID string `json:"uuid"` + Subject string `json:"subject"` + Body string `json:"body"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + ReadAt string `json:"read_at"` + CreatedAt string `json:"created_at"` +} + +func NewResponse(m contact.Message) *Response { + return &Response{ + UUID: m.UUID, + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + ReadAt: m.ReadAt.Format(time.RFC3339), + CreatedAt: m.CreatedAt.Format(time.RFC3339), + } +} diff --git a/application/dashboard/contact/getMessage/usecase.go b/application/dashboard/contact/getMessage/usecase.go new file mode 100644 index 00000000..ca26452f --- /dev/null +++ b/application/dashboard/contact/getMessage/usecase.go @@ -0,0 +1,26 @@ +package getMessage + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type UseCase struct { + contactRepository contact.Repository +} + +func NewUseCase(contactRepository contact.Repository) *UseCase { + return &UseCase{ + contactRepository: contactRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, UUID string) (*Response, error) { + m, err := uc.contactRepository.GetOne(ctx, UUID) + if err != nil { + return nil, err + } + + return NewResponse(m), nil +} diff --git a/application/dashboard/contact/getMessage/usecase_test.go b/application/dashboard/contact/getMessage/usecase_test.go new file mode 100644 index 00000000..258c94cd --- /dev/null +++ b/application/dashboard/contact/getMessage/usecase_test.go @@ -0,0 +1,67 @@ +package getMessage + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("getting a message succeeds", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + m = contact.Message{ + UUID: "message-uuid", + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + Phone: "09123456789", + CreatedAt: time.Now(), + } + + expectedResponse = Response{ + UUID: m.UUID, + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + ReadAt: m.ReadAt.Format(time.RFC3339), + CreatedAt: m.CreatedAt.Format(time.RFC3339), + } + ) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), m.UUID) + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("message does not exist", func(t *testing.T) { + t.Parallel() + + var contactRepository contacts.MockContactsRepository + + contactRepository.On("GetOne", mock.Anything, "message-uuid").Once().Return(contact.Message{}, domain.ErrNotExists) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), "message-uuid") + + assert.ErrorIs(t, err, domain.ErrNotExists) + assert.Nil(t, response) + }) +} diff --git a/application/dashboard/contact/getMessages/request.go b/application/dashboard/contact/getMessages/request.go new file mode 100644 index 00000000..2ef93d59 --- /dev/null +++ b/application/dashboard/contact/getMessages/request.go @@ -0,0 +1,5 @@ +package getMessages + +type Request struct { + Page uint +} diff --git a/application/dashboard/contact/getMessages/response.go b/application/dashboard/contact/getMessages/response.go new file mode 100644 index 00000000..89ca6cdf --- /dev/null +++ b/application/dashboard/contact/getMessages/response.go @@ -0,0 +1,49 @@ +package getMessages + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type messageResponse struct { + UUID string `json:"uuid"` + Subject string `json:"subject"` + Body string `json:"body"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + ReadAt string `json:"read_at"` + CreatedAt string `json:"created_at"` +} + +type Response struct { + Items []messageResponse `json:"items"` + Pagination pagination `json:"pagination"` +} + +type pagination struct { + TotalPages uint `json:"total_pages"` + CurrentPage uint `json:"current_page"` +} + +func NewResponse(m []contact.Message, totalPages, currentPage uint) *Response { + items := make([]messageResponse, len(m)) + + for i := range m { + items[i].UUID = m[i].UUID + items[i].Subject = m[i].Subject + items[i].Body = m[i].Body + items[i].Email = m[i].Email + items[i].Phone = m[i].Phone + items[i].ReadAt = m[i].ReadAt.Format(time.RFC3339) + items[i].CreatedAt = m[i].CreatedAt.Format(time.RFC3339) + } + + return &Response{ + Items: items, + Pagination: pagination{ + TotalPages: totalPages, + CurrentPage: currentPage, + }, + } +} diff --git a/application/dashboard/contact/getMessages/usecase.go b/application/dashboard/contact/getMessages/usecase.go new file mode 100644 index 00000000..31e03615 --- /dev/null +++ b/application/dashboard/contact/getMessages/usecase.go @@ -0,0 +1,49 @@ +package getMessages + +import ( + "context" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +const limit = 10 + +type UseCase struct { + contactRepository contact.Repository +} + +func NewUseCase(contactRepository contact.Repository) *UseCase { + return &UseCase{ + contactRepository: contactRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + totalMessages, err := uc.contactRepository.Count(ctx) + 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 := totalMessages / limit + + if (totalPages * limit) != totalMessages { + totalPages++ + } + + m, err := uc.contactRepository.GetAll(ctx, offset, limit) + if err != nil { + return nil, err + } + + return NewResponse(m, totalPages, currentPage), nil +} diff --git a/application/dashboard/contact/getMessages/usecase_test.go b/application/dashboard/contact/getMessages/usecase_test.go new file mode 100644 index 00000000..92d98266 --- /dev/null +++ b/application/dashboard/contact/getMessages/usecase_test.go @@ -0,0 +1,132 @@ +package getMessages + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("getting messages succeeds", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + readAt = time.Now() + createdAt = time.Now() + + m = []contact.Message{ + { + UUID: "message-uuid-1", + Subject: "subject-1", + Body: "body-1", + Email: "user@example.com", + CreatedAt: createdAt, + }, + { + UUID: "message-uuid-2", + Subject: "subject-2", + Body: "body-2", + Phone: "09123456789", + ReadAt: readAt, + CreatedAt: createdAt, + }, + } + + r = Request{ + Page: 0, + } + + expectedResponse = Response{ + Items: []messageResponse{ + { + UUID: m[0].UUID, + Subject: m[0].Subject, + Body: m[0].Body, + Email: m[0].Email, + ReadAt: m[0].ReadAt.Format(time.RFC3339), + CreatedAt: m[0].CreatedAt.Format(time.RFC3339), + }, + { + UUID: m[1].UUID, + Subject: m[1].Subject, + Body: m[1].Body, + Phone: m[1].Phone, + ReadAt: m[1].ReadAt.Format(time.RFC3339), + CreatedAt: m[1].CreatedAt.Format(time.RFC3339), + }, + }, + Pagination: pagination{ + CurrentPage: 1, + TotalPages: 1, + }, + } + ) + + contactRepository.On("Count", mock.Anything).Once().Return(uint(len(m)), nil) + contactRepository.On("GetAll", mock.Anything, uint(0), uint(10)).Once().Return(m, nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Equal(t, &expectedResponse, response) + }) + + t.Run("counting messages fails", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + r = Request{ + Page: 0, + } + + expectedErr = errors.New("counting messages failed") + ) + + contactRepository.On("Count", mock.Anything).Once().Return(uint(0), expectedErr) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + contactRepository.AssertNotCalled(t, "GetAll") + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) + + t.Run("getting messages fails", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + r = Request{ + Page: 2, + } + + expectedErr = errors.New("getting messages failed") + ) + + contactRepository.On("Count", mock.Anything).Once().Return(uint(12), nil) + contactRepository.On("GetAll", mock.Anything, uint(10), uint(10)).Once().Return(nil, expectedErr) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, response) + }) +} diff --git a/application/dashboard/contact/markAsRead/request.go b/application/dashboard/contact/markAsRead/request.go new file mode 100644 index 00000000..dce51952 --- /dev/null +++ b/application/dashboard/contact/markAsRead/request.go @@ -0,0 +1,8 @@ +package markAsRead + +type Request struct { + MessageUUID string `json:"-"` + // Read carries the toggle's new state: true stamps the read time, false + // clears it back to unread. + Read bool `json:"read"` +} diff --git a/application/dashboard/contact/markAsRead/response.go b/application/dashboard/contact/markAsRead/response.go new file mode 100644 index 00000000..f0596cb8 --- /dev/null +++ b/application/dashboard/contact/markAsRead/response.go @@ -0,0 +1,19 @@ +package markAsRead + +import ( + "time" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type Response struct { + UUID string `json:"uuid"` + ReadAt string `json:"read_at"` +} + +func NewResponse(m contact.Message) *Response { + return &Response{ + UUID: m.UUID, + ReadAt: m.ReadAt.Format(time.RFC3339), + } +} diff --git a/application/dashboard/contact/markAsRead/usecase.go b/application/dashboard/contact/markAsRead/usecase.go new file mode 100644 index 00000000..2aa59eb5 --- /dev/null +++ b/application/dashboard/contact/markAsRead/usecase.go @@ -0,0 +1,40 @@ +package markAsRead + +import ( + "context" + "time" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type UseCase struct { + contactRepository contact.Repository +} + +func NewUseCase(contactRepository contact.Repository) *UseCase { + return &UseCase{ + contactRepository: contactRepository, + } +} + +func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, error) { + m, err := uc.contactRepository.GetOne(ctx, request.MessageUUID) + if err != nil { + return nil, err + } + + switch { + case !request.Read: + m.ReadAt = time.Time{} + case !m.IsRead(): + // The read time stamps itself the first time the message is marked as + // read; marking an already-read message keeps the original moment. + m.ReadAt = time.Now() + } + + if _, err := uc.contactRepository.Save(ctx, &m); err != nil { + return nil, err + } + + return NewResponse(m), nil +} diff --git a/application/dashboard/contact/markAsRead/usecase_test.go b/application/dashboard/contact/markAsRead/usecase_test.go new file mode 100644 index 00000000..63be5337 --- /dev/null +++ b/application/dashboard/contact/markAsRead/usecase_test.go @@ -0,0 +1,133 @@ +package markAsRead + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestUseCase_Execute(t *testing.T) { + t.Parallel() + + t.Run("marking an unread message as read stamps the read time", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + m = contact.Message{ + UUID: "message-uuid", + Subject: "a subject", + Body: "a body", + CreatedAt: time.Now(), + } + + r = Request{ + MessageUUID: m.UUID, + Read: true, + } + ) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + contactRepository.On("Save", mock.Anything, mock.MatchedBy(func(saved *contact.Message) bool { + return saved.UUID == m.UUID && saved.IsRead() + })).Once().Return(m.UUID, nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Equal(t, m.UUID, response.UUID) + assert.NotEqual(t, time.Time{}.Format(time.RFC3339), response.ReadAt) + }) + + t.Run("marking an already read message keeps the original read time", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + readAt = time.Now().Add(-24 * time.Hour) + + m = contact.Message{ + UUID: "message-uuid", + ReadAt: readAt, + } + + r = Request{ + MessageUUID: m.UUID, + Read: true, + } + ) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + contactRepository.On("Save", mock.Anything, mock.MatchedBy(func(saved *contact.Message) bool { + return saved.ReadAt.Equal(readAt) + })).Once().Return(m.UUID, nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Equal(t, readAt.Format(time.RFC3339), response.ReadAt) + }) + + t.Run("marking a message as unread clears the read time", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + m = contact.Message{ + UUID: "message-uuid", + ReadAt: time.Now(), + } + + r = Request{ + MessageUUID: m.UUID, + Read: false, + } + ) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + contactRepository.On("Save", mock.Anything, mock.MatchedBy(func(saved *contact.Message) bool { + return !saved.IsRead() + })).Once().Return(m.UUID, nil) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Equal(t, time.Time{}.Format(time.RFC3339), response.ReadAt) + }) + + t.Run("message does not exist", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + r = Request{ + MessageUUID: "message-uuid", + Read: true, + } + ) + + contactRepository.On("GetOne", mock.Anything, r.MessageUUID).Once().Return(contact.Message{}, domain.ErrNotExists) + defer contactRepository.AssertExpectations(t) + + response, err := NewUseCase(&contactRepository).Execute(context.Background(), &r) + + contactRepository.AssertNotCalled(t, "Save") + + assert.ErrorIs(t, err, domain.ErrNotExists) + assert.Nil(t, response) + }) +} diff --git a/application/dashboard/profile/updateprofile/request.go b/application/dashboard/profile/updateprofile/request.go index 681543da..e55a034b 100644 --- a/application/dashboard/profile/updateprofile/request.go +++ b/application/dashboard/profile/updateprofile/request.go @@ -2,7 +2,7 @@ package updateprofile import ( "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -29,7 +29,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Email) == 0 { validationErrors["email"] = "required_field" - } else if !user.IsValidEmail(r.Email) { + } else if !rules.IsValidEmail(r.Email) { validationErrors["email"] = "invalid_email" } diff --git a/application/dashboard/user/createUser/request.go b/application/dashboard/user/createUser/request.go index 56839335..99c05127 100644 --- a/application/dashboard/user/createUser/request.go +++ b/application/dashboard/user/createUser/request.go @@ -2,7 +2,7 @@ package createuser import ( "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -21,7 +21,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Email) == 0 { validationErrors["email"] = "required_field" - } else if !user.IsValidEmail(r.Email) { + } else if !rules.IsValidEmail(r.Email) { validationErrors["email"] = "invalid_email" } @@ -31,7 +31,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Username) == 0 { validationErrors["username"] = "required_field" - } else if !user.IsValidUsername(r.Username) { + } else if !rules.IsValidUsername(r.Username) { validationErrors["username"] = "invalid_value" } diff --git a/application/dashboard/user/updateUser/request.go b/application/dashboard/user/updateUser/request.go index 133d4979..457f6504 100644 --- a/application/dashboard/user/updateUser/request.go +++ b/application/dashboard/user/updateUser/request.go @@ -2,7 +2,7 @@ package updateuser import ( "github.com/khanzadimahdi/testproject/domain" - "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/validator/rules" ) type Request struct { @@ -25,7 +25,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Email) == 0 { validationErrors["email"] = "required_field" - } else if !user.IsValidEmail(r.Email) { + } else if !rules.IsValidEmail(r.Email) { validationErrors["email"] = "invalid_email" } @@ -35,7 +35,7 @@ func (r *Request) Validate() domain.ValidationErrors { if len(r.Username) == 0 { validationErrors["username"] = "required_field" - } else if !user.IsValidUsername(r.Username) { + } else if !rules.IsValidUsername(r.Username) { validationErrors["username"] = "invalid_value" } diff --git a/domain/contact/contact.go b/domain/contact/contact.go new file mode 100644 index 00000000..a893d0ab --- /dev/null +++ b/domain/contact/contact.go @@ -0,0 +1,31 @@ +package contact + +import ( + "context" + "time" +) + +// Message is a "contact us" message sent by a visitor. It is not tied to a user +// account — the sender identifies themselves by email, phone number, or both. +type Message struct { + UUID string + Subject string + Body string + Email string + Phone string + ReadAt time.Time + CreatedAt time.Time +} + +// IsRead reports whether the message has been marked as read. +func (m Message) IsRead() bool { + return !m.ReadAt.IsZero() +} + +type Repository interface { + GetAll(ctx context.Context, offset uint, limit uint) ([]Message, error) + GetOne(ctx context.Context, UUID string) (Message, error) + Count(ctx context.Context) (uint, error) + Save(ctx context.Context, m *Message) (string, error) + Delete(ctx context.Context, UUID string) error +} diff --git a/domain/contact/contact_test.go b/domain/contact/contact_test.go new file mode 100644 index 00000000..f2a1329c --- /dev/null +++ b/domain/contact/contact_test.go @@ -0,0 +1,16 @@ +package contact + +import ( + "testing" + "time" +) + +func TestMessage_IsRead(t *testing.T) { + if (Message{}).IsRead() { + t.Error("a message with a zero ReadAt should not be read") + } + + if !(Message{ReadAt: time.Now()}).IsRead() { + t.Error("a message with a ReadAt should be read") + } +} diff --git a/domain/permission/permission.go b/domain/permission/permission.go index 33c72967..a7d7a565 100644 --- a/domain/permission/permission.go +++ b/domain/permission/permission.go @@ -52,6 +52,11 @@ const ( RolesUpdate = "roles.update" RolesDelete = "roles.delete" + ContactUsIndex = "contactus.index" + ContactUsShow = "contactus.show" + ContactUsDelete = "contactus.delete" + ContactUsMarkAsRead = "contactus.markAsRead" + ConfigShow = "config.show" ConfigUpdate = "config.update" diff --git a/domain/user/user.go b/domain/user/user.go index b7926276..b75e1c81 100644 --- a/domain/user/user.go +++ b/domain/user/user.go @@ -2,7 +2,6 @@ package user import ( "context" - "regexp" "time" "github.com/khanzadimahdi/testproject/domain/password" @@ -28,20 +27,3 @@ type Repository interface { Delete(ctx context.Context, UUID string) error Count(ctx context.Context) (uint, error) } - -var ( - usernameRegex = regexp.MustCompile(`^[a-z0-9._-]*[a-z0-9][a-z0-9._-]*$`) - emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) -) - -// IsValidUsername reports whether s is a valid username: lowercase English -// letters, digits, dots, dashes and underscores only, with at least one -// alphanumeric character. -func IsValidUsername(s string) bool { - return usernameRegex.MatchString(s) -} - -// IsValidEmail reports whether s is a syntactically valid email address. -func IsValidEmail(s string) bool { - return emailRegex.MatchString(s) -} diff --git a/infrastructure/ioc/providers/blog.go b/infrastructure/ioc/providers/blog.go index b51ac2a5..0a5d0394 100644 --- a/infrastructure/ioc/providers/blog.go +++ b/infrastructure/ioc/providers/blog.go @@ -29,6 +29,7 @@ import ( "github.com/khanzadimahdi/testproject/application/code/runCode" "github.com/khanzadimahdi/testproject/application/comment/createComment" "github.com/khanzadimahdi/testproject/application/comment/getComments" + "github.com/khanzadimahdi/testproject/application/contact/createMessage" dashboardCreateArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/createArticle" dashboardDeleteArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/deleteArticle" dashboardGetArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/getArticle" @@ -47,6 +48,10 @@ import ( dashboardUpdateUserComment "github.com/khanzadimahdi/testproject/application/dashboard/comment/updateUserComment" dashboardGetConfig "github.com/khanzadimahdi/testproject/application/dashboard/config/getConfig" dashboardUpdateConfig "github.com/khanzadimahdi/testproject/application/dashboard/config/updateConfig" + dashboardDeleteContactMessage "github.com/khanzadimahdi/testproject/application/dashboard/contact/deleteMessage" + dashboardGetContactMessage "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessage" + dashboardGetContactMessages "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessages" + dashboardMarkContactMessageAsRead "github.com/khanzadimahdi/testproject/application/dashboard/contact/markAsRead" dashboardCreateElement "github.com/khanzadimahdi/testproject/application/dashboard/element/createElement" dashboardDeleteElement "github.com/khanzadimahdi/testproject/application/dashboard/element/deleteElement" dashboardGetElement "github.com/khanzadimahdi/testproject/application/dashboard/element/getElement" @@ -100,6 +105,7 @@ import ( bookmarksrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/bookmarks" commentsrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/comments" configrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/config" + contactsrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/contacts" 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" @@ -113,10 +119,12 @@ import ( authorArticleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/author/article" bookmarkAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/bookmark" commentAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/comment" + contactAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/contact" dashboardArticleAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/article" dashboardBookmarkAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/bookmark" dashboardCommentAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/comment" dashboardConfigAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/config" + dashboardContactAPI "github.com/khanzadimahdi/testproject/presentation/http/blog/api/dashboard/contact" 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" @@ -301,6 +309,7 @@ func blog( articlesRepository := articlesrepository.NewRepository(database) commentsRepository := commentsrepository.NewRepository(database) + contactsRepository := contactsrepository.NewRepository(database) filesRepository := filesrepository.NewRepository(database) elementsRepository := elementsrepository.NewRepository(database) userRepository := userrepository.NewRepository(database) @@ -367,6 +376,11 @@ func blog( dashboardGetCommentUsecase := dashboardGetComment.NewUseCase(commentsRepository, userRepository) dashboardGetCommentsUsecase := dashboardGetComments.NewUseCase(commentsRepository, userRepository) + dashboardDeleteContactMessageUsecase := dashboardDeleteContactMessage.NewUseCase(contactsRepository) + dashboardGetContactMessageUsecase := dashboardGetContactMessage.NewUseCase(contactsRepository) + dashboardGetContactMessagesUsecase := dashboardGetContactMessages.NewUseCase(contactsRepository) + dashboardMarkContactMessageAsReadUsecase := dashboardMarkContactMessageAsRead.NewUseCase(contactsRepository) + dashboardDeleteUserCommentUsecase := dashboardDeleteUserComment.NewUseCase(commentsRepository) dashboardGetUserCommentUsecase := dashboardGetUserComment.NewUseCase(commentsRepository, userRepository) dashboardGetUserCommentsUsecase := dashboardGetUserComments.NewUseCase(commentsRepository, userRepository) @@ -445,6 +459,11 @@ func blog( return commentAPI.NewIndexHandler(getComments.NewUseCase(commentsRepository, userRepository, va(c))) })) + // contact us + mux.Handle("POST /api/contact-us", scoped(func(c provider.Container) http.Handler { + return contactAPI.NewCreateHandler(createMessage.NewUseCase(contactsRepository, va(c))) + })) + // bookmark mux.Handle("POST /api/bookmarks/exists", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return bookmarkAPI.NewExistsHandler(bookmarkExists.NewUseCase(bookmarkRepository, va(c))) @@ -581,6 +600,12 @@ func blog( return dashboardElementAPI.NewUpdateHandler(dashboardUpdateElement.NewUseCase(elementsRepository, va(c))) }), authorizer, permission.ElementsUpdate), jwt, userRepository)) + // contact us + mux.Handle("DELETE /api/dashboard/contact-us/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardContactAPI.NewDeleteHandler(dashboardDeleteContactMessageUsecase), authorizer, permission.ContactUsDelete), jwt, userRepository)) + mux.Handle("GET /api/dashboard/contact-us", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardContactAPI.NewIndexHandler(dashboardGetContactMessagesUsecase), authorizer, permission.ContactUsIndex), jwt, userRepository)) + mux.Handle("GET /api/dashboard/contact-us/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardContactAPI.NewShowHandler(dashboardGetContactMessageUsecase), authorizer, permission.ContactUsShow), jwt, userRepository)) + mux.Handle("PUT /api/dashboard/contact-us/{uuid}/read", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardContactAPI.NewMarkAsReadHandler(dashboardMarkContactMessageAsReadUsecase), authorizer, permission.ContactUsMarkAsRead), jwt, userRepository)) + // config mux.Handle("GET /api/dashboard/config", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardConfigAPI.NewShowHandler(dashboardGetConfigUsecase), authorizer, permission.ConfigShow), jwt, userRepository)) mux.Handle("PUT /api/dashboard/config", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { diff --git a/infrastructure/repository/mocks/contacts/repository.go b/infrastructure/repository/mocks/contacts/repository.go new file mode 100644 index 00000000..dd0f81e7 --- /dev/null +++ b/infrastructure/repository/mocks/contacts/repository.go @@ -0,0 +1,49 @@ +package contacts + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/domain/contact" +) + +type MockContactsRepository struct { + mock.Mock +} + +var _ contact.Repository = &MockContactsRepository{} + +func (r *MockContactsRepository) GetAll(ctx context.Context, offset uint, limit uint) ([]contact.Message, error) { + args := r.Called(ctx, offset, limit) + + if m, ok := args.Get(0).([]contact.Message); ok { + return m, args.Error(1) + } + + return nil, args.Error(1) +} + +func (r *MockContactsRepository) GetOne(ctx context.Context, UUID string) (contact.Message, error) { + args := r.Called(ctx, UUID) + + return args.Get(0).(contact.Message), args.Error(1) +} + +func (r *MockContactsRepository) Count(ctx context.Context) (uint, error) { + args := r.Called(ctx) + + return args.Get(0).(uint), args.Error(1) +} + +func (r *MockContactsRepository) Save(ctx context.Context, m *contact.Message) (string, error) { + args := r.Called(ctx, m) + + return args.String(0), args.Error(1) +} + +func (r *MockContactsRepository) Delete(ctx context.Context, UUID string) error { + args := r.Called(ctx, UUID) + + return args.Error(0) +} diff --git a/infrastructure/repository/mongodb/contacts/model.go b/infrastructure/repository/mongodb/contacts/model.go new file mode 100644 index 00000000..0e9e7954 --- /dev/null +++ b/infrastructure/repository/mongodb/contacts/model.go @@ -0,0 +1,16 @@ +package contacts + +import ( + "time" +) + +type ContactMessageBson struct { + UUID string `bson:"_id,omitempty"` + Subject string `bson:"subject"` + Body string `bson:"body"` + Email string `bson:"email,omitempty"` + Phone string `bson:"phone,omitempty"` + ReadAt time.Time `bson:"read_at"` + CreatedAt time.Time `bson:"created_at,omitempty"` + UpdatedAt time.Time `bson:"updated_at,omitempty"` +} diff --git a/infrastructure/repository/mongodb/contacts/repository.go b/infrastructure/repository/mongodb/contacts/repository.go new file mode 100644 index 00000000..79ddbe3c --- /dev/null +++ b/infrastructure/repository/mongodb/contacts/repository.go @@ -0,0 +1,157 @@ +package contacts + +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/contact" +) + +const ( + collectionName = "contact_messages" + queryTimeout = 3 * time.Second +) + +type ContactsRepository struct { + collection *mongo.Collection +} + +var _ contact.Repository = &ContactsRepository{} + +func NewRepository(database *mongo.Database) *ContactsRepository { + if database == nil { + panic("database should not be nil") + } + + return &ContactsRepository{ + collection: database.Collection(collectionName), + } +} + +func (r *ContactsRepository) GetAll(ctx context.Context, offset uint, limit uint) ([]contact.Message, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + o := int64(offset) + l := int64(limit) + desc := bson.D{{Key: "_id", Value: -1}} + + cur, err := r.collection.Find(ctx, bson.D{}, options.Find().SetSkip(o).SetLimit(l).SetSort(desc)) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + items := make([]contact.Message, 0, limit) + for cur.Next(ctx) { + var m ContactMessageBson + + if err := cur.Decode(&m); err != nil { + return nil, err + } + items = append(items, contact.Message{ + UUID: m.UUID, + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + ReadAt: m.ReadAt, + CreatedAt: m.CreatedAt, + }) + } + + if err := cur.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func (r *ContactsRepository) GetOne(ctx context.Context, UUID string) (contact.Message, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + filter := bson.D{{Key: "_id", Value: UUID}} + + var m ContactMessageBson + if err := r.collection.FindOne(ctx, filter).Decode(&m); err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + err = domain.ErrNotExists + } + return contact.Message{}, err + } + + return contact.Message{ + UUID: m.UUID, + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + ReadAt: m.ReadAt, + CreatedAt: m.CreatedAt, + }, nil +} + +func (r *ContactsRepository) Count(ctx context.Context) (uint, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + c, err := r.collection.CountDocuments(ctx, bson.D{}) + if err != nil { + return uint(c), err + } + + return uint(c), nil +} + +func (r *ContactsRepository) Save(ctx context.Context, m *contact.Message) (string, error) { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + if len(m.UUID) == 0 { + UUID, err := uuid.NewV7() + if err != nil { + return "", err + } + m.UUID = UUID.String() + m.CreatedAt = time.Now() + } + + update := ContactMessageBson{ + UUID: m.UUID, + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + ReadAt: m.ReadAt, + CreatedAt: m.CreatedAt, + UpdatedAt: time.Now(), + } + + if _, err := r.collection.UpdateOne( + ctx, + bson.D{{Key: "_id", Value: m.UUID}}, + bson.M{"$set": update}, + options.UpdateOne().SetUpsert(true), + ); err != nil { + return "", err + } + + return m.UUID, nil +} + +func (r *ContactsRepository) Delete(ctx context.Context, UUID string) error { + ctx, cancel := context.WithTimeout(ctx, queryTimeout) + defer cancel() + + _, err := r.collection.DeleteOne(ctx, bson.D{{Key: "_id", Value: UUID}}) + + return err +} diff --git a/infrastructure/repository/mongodb/permissions/collection.go b/infrastructure/repository/mongodb/permissions/collection.go index b92a6e00..cb3f9a7a 100644 --- a/infrastructure/repository/mongodb/permissions/collection.go +++ b/infrastructure/repository/mongodb/permissions/collection.go @@ -48,6 +48,12 @@ var collection []permission.Permission = []permission.Permission{ {Name: "update a role", Value: permission.RolesUpdate}, {Name: "delete a role", Value: permission.RolesDelete}, + // contact us + {Name: "list of contact-us messages", Value: permission.ContactUsIndex}, + {Name: "show a contact-us message", Value: permission.ContactUsShow}, + {Name: "delete a contact-us message", Value: permission.ContactUsDelete}, + {Name: "mark a contact-us message as read", Value: permission.ContactUsMarkAsRead}, + // config {Name: "show configuration", Value: permission.ConfigShow}, {Name: "update configuration", Value: permission.ConfigUpdate}, diff --git a/infrastructure/validator/rules/email.go b/infrastructure/validator/rules/email.go new file mode 100644 index 00000000..9c8834cb --- /dev/null +++ b/infrastructure/validator/rules/email.go @@ -0,0 +1,12 @@ +package rules + +import "regexp" + +var ( + emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) +) + +// IsValidEmail reports whether s is a syntactically valid email address. +func IsValidEmail(s string) bool { + return emailRegex.MatchString(s) +} diff --git a/domain/user/user_test.go b/infrastructure/validator/rules/email_test.go similarity index 52% rename from domain/user/user_test.go rename to infrastructure/validator/rules/email_test.go index 9252a29c..fcfdc3d1 100644 --- a/domain/user/user_test.go +++ b/infrastructure/validator/rules/email_test.go @@ -1,4 +1,4 @@ -package user +package rules import "testing" @@ -35,38 +35,3 @@ func TestIsValidEmail(t *testing.T) { }) } } - -func TestIsValidUsername(t *testing.T) { - tests := []struct { - name string - username string - want bool - }{ - {"plain lowercase", "johndoe", true}, - {"digits only", "12345", true}, - {"letters and digits", "user123", true}, - {"with dot", "john.doe", true}, - {"with dash", "john-doe", true}, - {"with underscore", "john_doe", true}, - {"mixed allowed punctuation", "j.o-h_n.1", true}, - - {"empty", "", false}, - {"only dots", "...", false}, - {"only dashes", "---", false}, - {"only underscores", "___", false}, - {"only punctuation", ".-_.", false}, - {"uppercase letter", "JohnDoe", false}, - {"space", "john doe", false}, - {"plus", "john+doe", false}, - {"at sign", "john@doe", false}, - {"non-ascii", "johnë", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := IsValidUsername(tt.username); got != tt.want { - t.Errorf("IsValidUsername(%q) = %v, want %v", tt.username, got, tt.want) - } - }) - } -} diff --git a/infrastructure/validator/rules/phonenumber.go b/infrastructure/validator/rules/phonenumber.go new file mode 100644 index 00000000..1ace1dbd --- /dev/null +++ b/infrastructure/validator/rules/phonenumber.go @@ -0,0 +1,11 @@ +package rules + +import "regexp" + +var phoneRegex = regexp.MustCompile(`^[0-9]{4,}$`) + +// IsValidPhoneNumber reports whether s is a valid phone number: digits only, +// at least four of them. +func IsValidPhoneNumber(s string) bool { + return phoneRegex.MatchString(s) +} diff --git a/infrastructure/validator/rules/phonenumber_test.go b/infrastructure/validator/rules/phonenumber_test.go new file mode 100644 index 00000000..77f895ac --- /dev/null +++ b/infrastructure/validator/rules/phonenumber_test.go @@ -0,0 +1,29 @@ +package rules + +import "testing" + +func TestIsValidPhoneNumber(t *testing.T) { + tests := []struct { + phone string + want bool + }{ + {"", false}, + {"1", false}, + {"123", false}, + {"1234", true}, + {"09123456789", true}, + {"12 34", false}, + {"+1234", false}, + {"12-34", false}, + {"12a4", false}, + {"۱۲۳۴", false}, + } + + for _, tt := range tests { + t.Run(tt.phone, func(t *testing.T) { + if got := IsValidPhoneNumber(tt.phone); got != tt.want { + t.Errorf("IsValidPhoneNumber(%q) = %v, want %v", tt.phone, got, tt.want) + } + }) + } +} diff --git a/infrastructure/validator/rules/username.go b/infrastructure/validator/rules/username.go new file mode 100644 index 00000000..31a6cf09 --- /dev/null +++ b/infrastructure/validator/rules/username.go @@ -0,0 +1,14 @@ +package rules + +import "regexp" + +var ( + usernameRegex = regexp.MustCompile(`^[a-z0-9._-]*[a-z0-9][a-z0-9._-]*$`) +) + +// IsValidUsername reports whether s is a valid username: lowercase English +// letters, digits, dots, dashes and underscores only, with at least one +// alphanumeric character. +func IsValidUsername(s string) bool { + return usernameRegex.MatchString(s) +} diff --git a/infrastructure/validator/rules/username_test.go b/infrastructure/validator/rules/username_test.go new file mode 100644 index 00000000..4284d3f3 --- /dev/null +++ b/infrastructure/validator/rules/username_test.go @@ -0,0 +1,38 @@ +package rules + +import "testing" + +func TestIsValidUsername(t *testing.T) { + tests := []struct { + name string + username string + want bool + }{ + {"plain lowercase", "johndoe", true}, + {"digits only", "12345", true}, + {"letters and digits", "user123", true}, + {"with dot", "john.doe", true}, + {"with dash", "john-doe", true}, + {"with underscore", "john_doe", true}, + {"mixed allowed punctuation", "j.o-h_n.1", true}, + + {"empty", "", false}, + {"only dots", "...", false}, + {"only dashes", "---", false}, + {"only underscores", "___", false}, + {"only punctuation", ".-_.", false}, + {"uppercase letter", "JohnDoe", false}, + {"space", "john doe", false}, + {"plus", "john+doe", false}, + {"at sign", "john@doe", false}, + {"non-ascii", "johnë", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidUsername(tt.username); got != tt.want { + t.Errorf("IsValidUsername(%q) = %v, want %v", tt.username, got, tt.want) + } + }) + } +} diff --git a/presentation/http/blog/api/contact/create.go b/presentation/http/blog/api/contact/create.go new file mode 100644 index 00000000..b470f88b --- /dev/null +++ b/presentation/http/blog/api/contact/create.go @@ -0,0 +1,52 @@ +package contact + +import ( + "encoding/json" + "net/http" + + "github.com/khanzadimahdi/testproject/application/contact/createMessage" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type createHandler struct { + useCase *createMessage.UseCase +} + +func NewCreateHandler(useCase *createMessage.UseCase) *createHandler { + return &createHandler{ + useCase: useCase, + } +} + +// @Summary Send a contact-us message +// @Description send a message to the site owners; the sender needs no account +// @Tags contact-us +// @Accept json +// @Produce json +// @Param message body createMessage.Request true "Contact-us payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /contact-us [post] +func (h *createHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request createMessage.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + 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.WriteHeader(http.StatusCreated) + } +} diff --git a/presentation/http/blog/api/contact/create_test.go b/presentation/http/blog/api/contact/create_test.go new file mode 100644 index 00000000..c23fcd25 --- /dev/null +++ b/presentation/http/blog/api/contact/create_test.go @@ -0,0 +1,147 @@ +package contact + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/application/contact/createMessage" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" + "github.com/khanzadimahdi/testproject/infrastructure/validator" +) + +func TestCreateHandler(t *testing.T) { + t.Parallel() + + t.Run("sends a message", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + requestValidator validator.MockValidator + ) + + m := contact.Message{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + Phone: "09123456789", + } + + body := createMessage.Request{ + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + Phone: m.Phone, + } + + var payload bytes.Buffer + err := json.NewEncoder(&payload).Encode(body) + assert.NoError(t, err) + + requestValidator.On("Validate", &body).Once().Return(nil) + defer requestValidator.AssertExpectations(t) + + contactRepository.On("Save", mock.Anything, &m).Once().Return("message-uuid", nil) + defer contactRepository.AssertExpectations(t) + + handler := NewCreateHandler(createMessage.NewUseCase(&contactRepository, &requestValidator)) + + request := httptest.NewRequest(http.MethodPost, "/", &payload) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + assert.Len(t, response.Body.Bytes(), 0) + assert.Equal(t, http.StatusCreated, response.Code) + }) + + t.Run("validation failed", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + requestValidator validator.MockValidator + ) + + body := createMessage.Request{} + + var payload bytes.Buffer + err := json.NewEncoder(&payload).Encode(body) + assert.NoError(t, err) + + requestValidator.On("Validate", &body).Once().Return(domain.ValidationErrors{ + "subject": "this field is required", + "body": "this field is required", + "email": "either an email address or a phone number is required", + "phone": "either an email address or a phone number is required", + }) + defer requestValidator.AssertExpectations(t) + + handler := NewCreateHandler(createMessage.NewUseCase(&contactRepository, &requestValidator)) + + request := httptest.NewRequest(http.MethodPost, "/", &payload) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + contactRepository.AssertNotCalled(t, "Save") + + expected, err := os.ReadFile("testdata/send-message-validation-errors.json") + 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.StatusBadRequest, response.Code) + }) + + t.Run("error", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + requestValidator validator.MockValidator + ) + + m := contact.Message{ + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + } + + body := createMessage.Request{ + Subject: m.Subject, + Body: m.Body, + Email: m.Email, + } + + var payload bytes.Buffer + err := json.NewEncoder(&payload).Encode(body) + assert.NoError(t, err) + + requestValidator.On("Validate", &body).Once().Return(nil) + defer requestValidator.AssertExpectations(t) + + contactRepository.On("Save", mock.Anything, &m).Once().Return("", errors.New("some unwanted error")) + defer contactRepository.AssertExpectations(t) + + handler := NewCreateHandler(createMessage.NewUseCase(&contactRepository, &requestValidator)) + + request := httptest.NewRequest(http.MethodPost, "/", &payload) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + assert.Len(t, response.Body.Bytes(), 0) + assert.Equal(t, http.StatusInternalServerError, response.Code) + }) +} diff --git a/presentation/http/blog/api/contact/testdata/send-message-validation-errors.json b/presentation/http/blog/api/contact/testdata/send-message-validation-errors.json new file mode 100644 index 00000000..70df0271 --- /dev/null +++ b/presentation/http/blog/api/contact/testdata/send-message-validation-errors.json @@ -0,0 +1,8 @@ +{ + "errors": { + "subject": "this field is required", + "body": "this field is required", + "email": "either an email address or a phone number is required", + "phone": "either an email address or a phone number is required" + } +} diff --git a/presentation/http/blog/api/dashboard/contact/delete.go b/presentation/http/blog/api/dashboard/contact/delete.go new file mode 100644 index 00000000..5c777076 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/delete.go @@ -0,0 +1,45 @@ +package contact + +import ( + "net/http" + + "github.com/khanzadimahdi/testproject/application/dashboard/contact/deleteMessage" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type deleteHandler struct { + useCase *deleteMessage.UseCase +} + +func NewDeleteHandler(useCase *deleteMessage.UseCase) *deleteHandler { + return &deleteHandler{ + useCase: useCase, + } +} + +// @Summary Delete a contact-us message +// @Description delete a contact-us message by UUID +// @Tags dashboard contact-us +// @Accept json +// @Produce json +// @Param uuid path string true "Message UUID" +// @Success 204 +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/contact-us/{uuid} [delete] +func (h *deleteHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + UUID := r.PathValue("uuid") + + request := deleteMessage.Request{ + MessageUUID: UUID, + } + + err := h.useCase.Execute(r.Context(), &request) + switch { + 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/contact/delete_test.go b/presentation/http/blog/api/dashboard/contact/delete_test.go new file mode 100644 index 00000000..869833cf --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/delete_test.go @@ -0,0 +1,44 @@ +package contact + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/application/dashboard/contact/deleteMessage" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestDeleteHandler(t *testing.T) { + t.Parallel() + + t.Run("delete a message", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + ) + + contactRepository.On("Delete", mock.Anything, "message-uuid").Once().Return(nil) + defer contactRepository.AssertExpectations(t) + + handler := NewDeleteHandler(deleteMessage.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodDelete, "/", nil) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", "message-uuid") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + assert.Len(t, response.Body.Bytes(), 0) + assert.Equal(t, http.StatusNoContent, response.Code) + }) +} diff --git a/presentation/http/blog/api/dashboard/contact/index.go b/presentation/http/blog/api/dashboard/contact/index.go new file mode 100644 index 00000000..866c84ee --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/index.go @@ -0,0 +1,56 @@ +package contact + +import ( + "encoding/json" + "net/http" + "strconv" + "unsafe" + + "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessages" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type indexHandler struct { + useCase *getMessages.UseCase +} + +func NewIndexHandler(useCase *getMessages.UseCase) *indexHandler { + return &indexHandler{ + useCase: useCase, + } +} + +// @Summary List contact-us messages +// @Description paginated list of contact-us messages +// @Tags dashboard contact-us +// @Accept json +// @Produce json +// @Param page query int false "Page" default(1) +// @Success 200 {object} getMessages.Response +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/contact-us [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 := &getMessages.Request{ + Page: page, + } + + 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/contact/index_test.go b/presentation/http/blog/api/dashboard/contact/index_test.go new file mode 100644 index 00000000..3158fbae --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/index_test.go @@ -0,0 +1,101 @@ +package contact + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessages" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestIndexHandler(t *testing.T) { + t.Parallel() + + t.Run("show messages", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + + createdAt, _ = time.Parse(time.RFC3339, "2024-10-11T04:27:44Z") + + m = []contact.Message{ + { + UUID: "message-uuid-1", + Subject: "subject-1", + Body: "body-1", + Email: "user@example.com", + CreatedAt: createdAt, + }, + { + UUID: "message-uuid-2", + Subject: "subject-2", + Body: "body-2", + Phone: "09123456789", + ReadAt: createdAt, + CreatedAt: createdAt, + }, + } + ) + + contactRepository.On("Count", mock.Anything).Once().Return(uint(len(m)), nil) + contactRepository.On("GetAll", mock.Anything, uint(0), uint(10)).Once().Return(m, nil) + defer contactRepository.AssertExpectations(t) + + handler := NewIndexHandler(getMessages.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/?page=%d", 1), nil) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + expectedBody, err := os.ReadFile("testdata/index-messages-response.json") + assert.NoError(t, err) + + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.JSONEq(t, string(expectedBody), response.Body.String()) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("no data", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + ) + + contactRepository.On("Count", mock.Anything).Once().Return(uint(0), nil) + contactRepository.On("GetAll", mock.Anything, uint(0), uint(10)).Once().Return(nil, nil) + defer contactRepository.AssertExpectations(t) + + handler := NewIndexHandler(getMessages.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/?page=%d", 1), nil) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + expectedBody, err := os.ReadFile("testdata/index-messages-no-data-response.json") + assert.NoError(t, err) + + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.JSONEq(t, string(expectedBody), response.Body.String()) + assert.Equal(t, http.StatusOK, response.Code) + }) +} diff --git a/presentation/http/blog/api/dashboard/contact/markAsRead.go b/presentation/http/blog/api/dashboard/contact/markAsRead.go new file mode 100644 index 00000000..84cea382 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/markAsRead.go @@ -0,0 +1,56 @@ +package contact + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/dashboard/contact/markAsRead" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type markAsReadHandler struct { + useCase *markAsRead.UseCase +} + +func NewMarkAsReadHandler(useCase *markAsRead.UseCase) *markAsReadHandler { + return &markAsReadHandler{ + useCase: useCase, + } +} + +// @Summary Mark a contact-us message as read +// @Description toggle the read state of a contact-us message; the read time is stamped by the server +// @Tags dashboard contact-us +// @Accept json +// @Produce json +// @Param uuid path string true "Message UUID" +// @Param body body markAsRead.Request true "Read state" +// @Success 200 {object} markAsRead.Response +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/contact-us/{uuid}/read [put] +func (h *markAsReadHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request markAsRead.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + request.MessageUUID = r.PathValue("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/contact/markAsRead_test.go b/presentation/http/blog/api/dashboard/contact/markAsRead_test.go new file mode 100644 index 00000000..2b6bcc04 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/markAsRead_test.go @@ -0,0 +1,135 @@ +package contact + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/application/dashboard/contact/markAsRead" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestMarkAsReadHandler(t *testing.T) { + t.Parallel() + + t.Run("marks a message as read", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + + m = contact.Message{ + UUID: "message-uuid", + Subject: "a subject", + Body: "a body", + } + ) + + var payload bytes.Buffer + assert.NoError(t, json.NewEncoder(&payload).Encode(markAsRead.Request{Read: true})) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + contactRepository.On("Save", mock.Anything, mock.MatchedBy(func(saved *contact.Message) bool { + return saved.IsRead() + })).Once().Return(m.UUID, nil) + defer contactRepository.AssertExpectations(t) + + handler := NewMarkAsReadHandler(markAsRead.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodPut, "/", &payload) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", m.UUID) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + var body markAsRead.Response + assert.NoError(t, json.NewDecoder(response.Body).Decode(&body)) + + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.Equal(t, m.UUID, body.UUID) + assert.NotEqual(t, time.Time{}.Format(time.RFC3339), body.ReadAt) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("marks a message as unread", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + + m = contact.Message{ + UUID: "message-uuid", + ReadAt: time.Now(), + } + ) + + var payload bytes.Buffer + assert.NoError(t, json.NewEncoder(&payload).Encode(markAsRead.Request{Read: false})) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + contactRepository.On("Save", mock.Anything, mock.MatchedBy(func(saved *contact.Message) bool { + return !saved.IsRead() + })).Once().Return(m.UUID, nil) + defer contactRepository.AssertExpectations(t) + + handler := NewMarkAsReadHandler(markAsRead.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodPut, "/", &payload) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", m.UUID) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + var body markAsRead.Response + assert.NoError(t, json.NewDecoder(response.Body).Decode(&body)) + + assert.Equal(t, time.Time{}.Format(time.RFC3339), body.ReadAt) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("message does not exist", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + ) + + var payload bytes.Buffer + assert.NoError(t, json.NewEncoder(&payload).Encode(markAsRead.Request{Read: true})) + + contactRepository.On("GetOne", mock.Anything, "message-uuid").Once().Return(contact.Message{}, domain.ErrNotExists) + defer contactRepository.AssertExpectations(t) + + handler := NewMarkAsReadHandler(markAsRead.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodPut, "/", &payload) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", "message-uuid") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + contactRepository.AssertNotCalled(t, "Save") + + assert.Len(t, response.Body.Bytes(), 0) + assert.Equal(t, http.StatusNotFound, response.Code) + }) +} diff --git a/presentation/http/blog/api/dashboard/contact/show.go b/presentation/http/blog/api/dashboard/contact/show.go new file mode 100644 index 00000000..b14e26ee --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/show.go @@ -0,0 +1,50 @@ +package contact + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessage" + "github.com/khanzadimahdi/testproject/domain" + infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" + "go.opentelemetry.io/otel/trace" +) + +type showHandler struct { + useCase *getMessage.UseCase +} + +func NewShowHandler(useCase *getMessage.UseCase) *showHandler { + return &showHandler{ + useCase: useCase, + } +} + +// @Summary Get a contact-us message +// @Description retrieve a contact-us message by UUID +// @Tags dashboard contact-us +// @Accept json +// @Produce json +// @Param uuid path string true "Message UUID" +// @Success 200 {object} getMessage.Response +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /dashboard/contact-us/{uuid} [get] +func (h *showHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + UUID := r.PathValue("uuid") + + response, err := h.useCase.Execute(r.Context(), UUID) + + 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/contact/show_test.go b/presentation/http/blog/api/dashboard/contact/show_test.go new file mode 100644 index 00000000..69917d42 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/show_test.go @@ -0,0 +1,88 @@ +package contact + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/application/dashboard/contact/getMessage" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/contact" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/contacts" +) + +func TestShowHandler(t *testing.T) { + t.Parallel() + + t.Run("show a message", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + + createdAt, _ = time.Parse(time.RFC3339, "2024-10-11T04:27:44Z") + + m = contact.Message{ + UUID: "message-uuid", + Subject: "a subject", + Body: "a body", + Email: "user@example.com", + Phone: "09123456789", + CreatedAt: createdAt, + } + ) + + contactRepository.On("GetOne", mock.Anything, m.UUID).Once().Return(m, nil) + defer contactRepository.AssertExpectations(t) + + handler := NewShowHandler(getMessage.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", m.UUID) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + expectedBody, err := os.ReadFile("testdata/show-a-message-response.json") + assert.NoError(t, err) + + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.JSONEq(t, string(expectedBody), response.Body.String()) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("message does not exist", func(t *testing.T) { + t.Parallel() + + var ( + contactRepository contacts.MockContactsRepository + + u = user.User{UUID: "auth-user-uuid"} + ) + + contactRepository.On("GetOne", mock.Anything, "message-uuid").Once().Return(contact.Message{}, domain.ErrNotExists) + defer contactRepository.AssertExpectations(t) + + handler := NewShowHandler(getMessage.NewUseCase(&contactRepository)) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + request = request.WithContext(auth.ToContext(request.Context(), &u)) + request.SetPathValue("uuid", "message-uuid") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + assert.Len(t, response.Body.Bytes(), 0) + assert.Equal(t, http.StatusNotFound, response.Code) + }) +} diff --git a/presentation/http/blog/api/dashboard/contact/testdata/index-messages-no-data-response.json b/presentation/http/blog/api/dashboard/contact/testdata/index-messages-no-data-response.json new file mode 100644 index 00000000..41ed6ad2 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/testdata/index-messages-no-data-response.json @@ -0,0 +1,7 @@ +{ + "items": [], + "pagination": { + "total_pages": 0, + "current_page": 1 + } +} diff --git a/presentation/http/blog/api/dashboard/contact/testdata/index-messages-response.json b/presentation/http/blog/api/dashboard/contact/testdata/index-messages-response.json new file mode 100644 index 00000000..b663ff04 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/testdata/index-messages-response.json @@ -0,0 +1,24 @@ +{ + "items": [ + { + "uuid": "message-uuid-1", + "subject": "subject-1", + "body": "body-1", + "email": "user@example.com", + "read_at": "0001-01-01T00:00:00Z", + "created_at": "2024-10-11T04:27:44Z" + }, + { + "uuid": "message-uuid-2", + "subject": "subject-2", + "body": "body-2", + "phone": "09123456789", + "read_at": "2024-10-11T04:27:44Z", + "created_at": "2024-10-11T04:27:44Z" + } + ], + "pagination": { + "total_pages": 1, + "current_page": 1 + } +} diff --git a/presentation/http/blog/api/dashboard/contact/testdata/show-a-message-response.json b/presentation/http/blog/api/dashboard/contact/testdata/show-a-message-response.json new file mode 100644 index 00000000..669a6ac0 --- /dev/null +++ b/presentation/http/blog/api/dashboard/contact/testdata/show-a-message-response.json @@ -0,0 +1,9 @@ +{ + "uuid": "message-uuid", + "subject": "a subject", + "body": "a body", + "email": "user@example.com", + "phone": "09123456789", + "read_at": "0001-01-01T00:00:00Z", + "created_at": "2024-10-11T04:27:44Z" +} diff --git a/resources/docs/blog/openapi/docs.go b/resources/docs/blog/openapi/docs.go index 2fcc82ec..8733035e 100644 --- a/resources/docs/blog/openapi/docs.go +++ b/resources/docs/blog/openapi/docs.go @@ -659,6 +659,55 @@ const docTemplate = `{ } } }, + "/contact-us": { + "post": { + "description": "send a message to the site owners; the sender needs no account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "contact-us" + ], + "summary": "Send a contact-us message", + "parameters": [ + { + "description": "Contact-us payload", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createMessage.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/dashboard/articles": { "get": { "description": "page through articles in dashboard, grouped by correlation uuid", @@ -1356,6 +1405,187 @@ const docTemplate = `{ } } }, + "/dashboard/contact-us": { + "get": { + "description": "paginated list of contact-us messages", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "List contact-us messages", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getMessages.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/contact-us/{uuid}": { + "get": { + "description": "retrieve a contact-us message by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Get a contact-us message", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getMessage.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "delete a contact-us message by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Delete a contact-us message", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/contact-us/{uuid}/read": { + "put": { + "description": "toggle the read state of a contact-us message; the read time is stamped by the server", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Mark a contact-us message as read", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + }, + { + "description": "Read state", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/markAsRead.Request" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/markAsRead.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/elements": { "get": { "description": "paginated list of elements", @@ -3360,6 +3590,23 @@ const docTemplate = `{ } } }, + "createMessage.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "subject": { + "type": "string" + } + } + }, "createarticle.Request": { "type": "object", "properties": { @@ -3598,6 +3845,9 @@ const docTemplate = `{ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -3872,6 +4122,83 @@ const docTemplate = `{ } } }, + "getMessage.Response": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "read_at": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getMessages.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getMessages.messageResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getMessages.pagination" + } + } + }, + "getMessages.messageResponse": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "read_at": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getMessages.pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, "getNode.Response": { "type": "object", "properties": { @@ -5516,6 +5843,26 @@ const docTemplate = `{ } } }, + "markAsRead.Request": { + "type": "object", + "properties": { + "read": { + "description": "Read carries the toggle's new state: true stamps the read time, false\nclears it back to unread.", + "type": "boolean" + } + } + }, + "markAsRead.Response": { + "type": "object", + "properties": { + "read_at": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "refresh.Request": { "type": "object", "properties": { diff --git a/resources/docs/blog/openapi/swagger.json b/resources/docs/blog/openapi/swagger.json index f8cc3243..ae3137ac 100644 --- a/resources/docs/blog/openapi/swagger.json +++ b/resources/docs/blog/openapi/swagger.json @@ -656,6 +656,55 @@ } } }, + "/contact-us": { + "post": { + "description": "send a message to the site owners; the sender needs no account", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "contact-us" + ], + "summary": "Send a contact-us message", + "parameters": [ + { + "description": "Contact-us payload", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createMessage.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/dashboard/articles": { "get": { "description": "page through articles in dashboard, grouped by correlation uuid", @@ -1353,6 +1402,187 @@ } } }, + "/dashboard/contact-us": { + "get": { + "description": "paginated list of contact-us messages", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "List contact-us messages", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getMessages.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/contact-us/{uuid}": { + "get": { + "description": "retrieve a contact-us message by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Get a contact-us message", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/getMessage.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "delete a contact-us message by UUID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Delete a contact-us message", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/dashboard/contact-us/{uuid}/read": { + "put": { + "description": "toggle the read state of a contact-us message; the read time is stamped by the server", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboard contact-us" + ], + "summary": "Mark a contact-us message as read", + "parameters": [ + { + "type": "string", + "description": "Message UUID", + "name": "uuid", + "in": "path", + "required": true + }, + { + "description": "Read state", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/markAsRead.Request" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/markAsRead.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/elements": { "get": { "description": "paginated list of elements", @@ -3357,6 +3587,23 @@ } } }, + "createMessage.Request": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "subject": { + "type": "string" + } + } + }, "createarticle.Request": { "type": "object", "properties": { @@ -3595,6 +3842,9 @@ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -3869,6 +4119,83 @@ } } }, + "getMessage.Response": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "read_at": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getMessages.Response": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/getMessages.messageResponse" + } + }, + "pagination": { + "$ref": "#/definitions/getMessages.pagination" + } + } + }, + "getMessages.messageResponse": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "read_at": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "getMessages.pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, "getNode.Response": { "type": "object", "properties": { @@ -5513,6 +5840,26 @@ } } }, + "markAsRead.Request": { + "type": "object", + "properties": { + "read": { + "description": "Read carries the toggle's new state: true stamps the read time, false\nclears it back to unread.", + "type": "boolean" + } + } + }, + "markAsRead.Response": { + "type": "object", + "properties": { + "read_at": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, "refresh.Request": { "type": "object", "properties": { diff --git a/resources/docs/blog/openapi/swagger.yaml b/resources/docs/blog/openapi/swagger.yaml index 9b0392f4..dfd40e37 100644 --- a/resources/docs/blog/openapi/swagger.yaml +++ b/resources/docs/blog/openapi/swagger.yaml @@ -23,6 +23,17 @@ definitions: new_password: type: string type: object + createMessage.Request: + properties: + body: + type: string + email: + type: string + phone: + type: string + subject: + type: string + type: object createarticle.Request: properties: body: @@ -178,6 +189,8 @@ definitions: body: {} type: type: string + uuid: + type: string type: object forgetpassword.Request: properties: @@ -355,6 +368,56 @@ definitions: type: string type: array type: object + getMessage.Response: + properties: + body: + type: string + created_at: + type: string + email: + type: string + phone: + type: string + read_at: + type: string + subject: + type: string + uuid: + type: string + type: object + getMessages.Response: + properties: + items: + items: + $ref: '#/definitions/getMessages.messageResponse' + type: array + pagination: + $ref: '#/definitions/getMessages.pagination' + type: object + getMessages.messageResponse: + properties: + body: + type: string + created_at: + type: string + email: + type: string + phone: + type: string + read_at: + type: string + subject: + type: string + uuid: + type: string + type: object + getMessages.pagination: + properties: + current_page: + type: integer + total_pages: + type: integer + type: object getNode.Response: properties: created_at: @@ -1423,6 +1486,21 @@ definitions: refresh_token: type: string type: object + markAsRead.Request: + properties: + read: + description: |- + Read carries the toggle's new state: true stamps the read time, false + clears it back to unread. + type: boolean + type: object + markAsRead.Response: + properties: + read_at: + type: string + uuid: + type: string + type: object refresh.Request: properties: token: @@ -2035,6 +2113,39 @@ paths: summary: Create a new comment tags: - comments + /contact-us: + post: + consumes: + - application/json + description: send a message to the site owners; the sender needs no account + parameters: + - description: Contact-us payload + in: body + name: message + required: true + schema: + $ref: '#/definitions/createMessage.Request' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Send a contact-us message + tags: + - contact-us /dashboard/articles: get: consumes: @@ -2506,6 +2617,129 @@ paths: summary: Update config tags: - dashboard config + /dashboard/contact-us: + get: + consumes: + - application/json + description: paginated list of contact-us messages + parameters: + - default: 1 + description: Page + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getMessages.Response' + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: List contact-us messages + tags: + - dashboard contact-us + /dashboard/contact-us/{uuid}: + delete: + consumes: + - application/json + description: delete a contact-us message by UUID + parameters: + - description: Message UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Delete a contact-us message + tags: + - dashboard contact-us + get: + consumes: + - application/json + description: retrieve a contact-us message by UUID + parameters: + - description: Message UUID + in: path + name: uuid + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/getMessage.Response' + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Get a contact-us message + tags: + - dashboard contact-us + /dashboard/contact-us/{uuid}/read: + put: + consumes: + - application/json + description: toggle the read state of a contact-us message; the read time is + stamped by the server + parameters: + - description: Message UUID + in: path + name: uuid + required: true + type: string + - description: Read state + in: body + name: body + required: true + schema: + $ref: '#/definitions/markAsRead.Request' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/markAsRead.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: Mark a contact-us message as read + tags: + - dashboard contact-us /dashboard/elements: get: consumes: diff --git a/resources/translation/en.go b/resources/translation/en.go index 0d3cd5f0..07301428 100644 --- a/resources/translation/en.go +++ b/resources/translation/en.go @@ -8,6 +8,8 @@ var english = map[string]string{ "required_field": "this field is required", "invalid_value": "the provided value is invalid", "invalid_email": "should be a valid email address", + "invalid_phone_number": "should be a valid phone number (digits only, at least 4 of them)", + "email_or_phone_required": "either an email address or a phone number is required", "repassword": "password and it's repeat should be the same", "greater_than_zero": "the provided value should be greater than zero", "exceeds_limit": "the provided value exceeds the limits", diff --git a/resources/translation/fa.go b/resources/translation/fa.go index cfed625f..7203f9ff 100644 --- a/resources/translation/fa.go +++ b/resources/translation/fa.go @@ -8,6 +8,8 @@ var farsi = map[string]string{ "required_field": "این فیلد اجباری است", "invalid_value": "مقدار ارائه شده نامعتبر است", "invalid_email": "مقدار ارائه شده باید یک آدرس ایمیل معتبر باشد", + "invalid_phone_number": "شماره تماس باید فقط شامل ارقام و حداقل ۴ رقم باشد", + "email_or_phone_required": "وارد کردن ایمیل یا شماره تماس الزامی است", "repassword": "کلمه عبور و تکرار آن باید یکسان باشند", "greater_than_zero": "مقدار ارائه شده باید بزرگتر از صفر باشد", "exceeds_limit": "مقدار ارائه شده بیش از حد است",