From 9a444a1e7d3f43857aea141da73dbb2c1c02790c Mon Sep 17 00:00:00 2001 From: "n.nakhostin" Date: Mon, 3 Nov 2025 13:20:58 +0330 Subject: [PATCH] Add CMS Management Functionality - Introduced a new CMS management feature, including the ability to create, retrieve, update, search, and delete CMS entries. - Implemented the CMS entity, repository, service, and handler layers following Clean Architecture principles. - Added API endpoints for CMS operations in Swagger documentation, ensuring comprehensive API specifications. - Enhanced error handling and validation for CMS data, improving robustness and user experience. - Updated the main application to initialize the CMS repository and service, integrating them into the existing system. --- cmd/web/main.go | 26 ++- cmd/web/router/routes.go | 40 ++++- internal/cms/entity.go | 110 ++++++++++++ internal/cms/errors.go | 13 ++ internal/cms/form.go | 120 +++++++++++++ internal/cms/handler.go | 190 +++++++++++++++++++++ internal/cms/repository.go | 195 +++++++++++++++++++++ internal/cms/service.go | 328 ++++++++++++++++++++++++++++++++++++ internal/contact/handler.go | 12 +- 9 files changed, 1021 insertions(+), 13 deletions(-) create mode 100644 internal/cms/entity.go create mode 100644 internal/cms/errors.go create mode 100644 internal/cms/form.go create mode 100644 internal/cms/handler.go create mode 100644 internal/cms/repository.go create mode 100644 internal/cms/service.go diff --git a/cmd/web/main.go b/cmd/web/main.go index d116742..8711a0a 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -53,6 +53,12 @@ package main // @tag.name Admin-Notification // @tag.description Administrative notification management operations for web panel including CRUD operations, bulk notifications, queue management, and comprehensive filtering with pagination +// @tag.name Admin-Contacts +// @tag.description Administrative contact management operations for web panel including CRUD operations, search, statistics, and comprehensive filtering with pagination + +// @tag.name Admin-CMS +// @tag.description Administrative CMS management operations for web panel including CRUD operations, search, statistics, and comprehensive filtering with pagination + // @tag.name Authorization // @tag.description Customer authentication and authorization operations for mobile application including login, logout, token refresh, and profile access @@ -77,12 +83,19 @@ package main // @tag.name Notification // @tag.description Public notification management operations for mobile application including notification listing and detailed notification information +// @tag.name Contacts +// @tag.description Public contact management operations for mobile application including contact submission and contact listing + +// @tag.name CMS +// @tag.description Public CMS management operations for mobile application including CMS listing and detailed CMS information + import ( "context" "fmt" "net/http" "time" "tm/internal/assets" + "tm/internal/cms" "tm/internal/company" "tm/internal/company_category" "tm/internal/contact" @@ -144,8 +157,9 @@ func main() { tenderApprovalRepository := tender_approval.NewRepository(mongoManager, logger) inquiryRepository := inquiry.NewRepository(mongoManager, logger) contactRepository := contact.NewContactRepository(mongoManager, logger) + cmsRepository := cms.NewRepository(mongoManager, logger) logger.Info("Repositories initialized successfully", map[string]interface{}{ - "repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "contact"}, + "repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "contact", "cms"}, }) // Initialize validation services @@ -164,8 +178,9 @@ func main() { flagService := assets.NewService(logger, conf.Assets.FlagsPath) notificationService := notification.NewService(notificationSDK, userService, customerService, logger) contactService := contact.NewService(contactRepository, logger) + cmsService := cms.NewService(cmsRepository, logger) logger.Info("Services initialized successfully", map[string]interface{}{ - "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact"}, + "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms"}, }) // Initialize handlers with services @@ -180,16 +195,17 @@ func main() { flagHandler := assets.NewHandler(flagService, logger) notificationHandler := notification.NewHandler(notificationService) contactHandler := contact.NewHandler(contactService) + cmsHandler := cms.NewHandler(cmsService) logger.Info("Handlers initialized successfully", map[string]interface{}{ - "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact"}, + "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms"}, }) // Initialize HTTP server e := bootstrap.InitHTTPServer(conf, logger) // Register routes - router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler) - router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler) + router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler) + router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler) // Start server serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 30e2c45..f7e56ae 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -2,8 +2,10 @@ package router import ( "tm/internal/assets" + "tm/internal/cms" "tm/internal/company" "tm/internal/company_category" + "tm/internal/contact" "tm/internal/customer" "tm/internal/feedback" "tm/internal/inquiry" @@ -15,7 +17,7 @@ import ( "github.com/labstack/echo/v4" ) -func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler) { +func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) { adminV1 := e.Group("/admin/v1") // Admin Users Routes @@ -141,9 +143,31 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler notificationsGP.GET("/mark-seen/:id", notificationHandler.MarkSeen) notificationsGP.GET("/my", notificationHandler.AdminNotifications) } + + // Admin Contact Routes + contactsGP := adminV1.Group("/contacts") + { + contactsGP.Use(userHandler.AuthMiddleware()) + contactsGP.GET("", contactHandler.Search) + contactsGP.GET("/stats", contactHandler.GetStats) + contactsGP.GET("/:id", contactHandler.GetByID) + contactsGP.PUT("/:id/status", contactHandler.UpdateStatus) + contactsGP.DELETE("/:id", contactHandler.Delete) + } + + // Admin CMS Routes + cmsGP := adminV1.Group("/cms") + { + // cmsGP.Use(userHandler.AuthMiddleware()) + cmsGP.POST("", cmsHandler.Create) + cmsGP.GET("", cmsHandler.Search) + cmsGP.GET("/:id", cmsHandler.GetByID) + cmsGP.PUT("/:id", cmsHandler.Update) + cmsGP.DELETE("/:id", cmsHandler.Delete) + } } -func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler) { +func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) { v1 := e.Group("/api/v1") customerGP := v1.Group("/profile") @@ -221,4 +245,16 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende notificationsGP.GET("/mark", notificationHandler.PublicAllMarkSeen) notificationsGP.GET("/mark/:id", notificationHandler.PublicMarkSeen) } + + // Public Contact Routes + contactsPublicGP := v1.Group("/contacts") + { + contactsPublicGP.POST("", contactHandler.Create) + } + + // Public CMS Routes + cmsGP := v1.Group("/cms") + { + cmsGP.GET("/:key", cmsHandler.GetByKey) + } } diff --git a/internal/cms/entity.go b/internal/cms/entity.go new file mode 100644 index 0000000..6ca1e5f --- /dev/null +++ b/internal/cms/entity.go @@ -0,0 +1,110 @@ +package cms + +import ( + "tm/pkg/mongo" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// CMS represents the content management system for the landing page +type CMS struct { + mongo.Model `bson:",inline"` + Key string `bson:"key" json:"key" validate:"required"` + Hero heroSection `bson:"hero" json:"hero"` + Chart chartSection `bson:"chart" json:"chart"` + Features cardSection `bson:"features" json:"features"` + Challenges cardSection `bson:"challenges" json:"challenges"` + Advantages cardSection `bson:"advantages" json:"advantages"` + Contact contactSection `bson:"contact" json:"contact"` + Footer footerSection `bson:"footer" json:"footer"` +} + +// SetID sets the CMS ID (implements IDSetter interface) +func (c *CMS) SetID(id string) { + c.ID, _ = bson.ObjectIDFromHex(id) +} + +// GetID returns the CMS ID (implements IDGetter interface) +func (c *CMS) GetID() string { + return c.ID.Hex() +} + +// SetCreatedAt sets the created timestamp (implements Timestamp interface) +func (c *CMS) SetCreatedAt(timestamp int64) { + c.CreatedAt = timestamp +} + +// SetUpdatedAt sets the updated timestamp (implements Timestamp interface) +func (c *CMS) SetUpdatedAt(timestamp int64) { + c.UpdatedAt = timestamp +} + +// GetCreatedAt returns the created timestamp (implements Timestamp interface) +func (c *CMS) GetCreatedAt() int64 { + return c.CreatedAt +} + +// GetUpdatedAt returns the updated timestamp (implements Timestamp interface) +func (c *CMS) GetUpdatedAt() int64 { + return c.UpdatedAt +} + +// heroSection represents the hero section of the landing page +type heroSection struct { + Title string `bson:"title"` + Description string `bson:"description"` + ButtonText string `bson:"button_text"` + ButtonLink string `bson:"button_link"` + GifFile string `bson:"gif_file"` +} + +// chartSection represents the chart section of the landing page +type chartSection struct { + Title string `bson:"title" json:"title"` + MissedAmount string `bson:"missed_amount" json:"missedAmount"` + Data []chartPoint `bson:"data" json:"data"` +} + +type chartPoint struct { + Key string `bson:"key" json:"key"` + Value int `bson:"value" json:"value"` +} + +// cardSection represents the card section of the landing page +type cardSection struct { + Title string `bson:"title"` + Description string `bson:"description"` + Cards []card `bson:"cards"` +} + +// card represents a card in the card section +type card struct { + Icon string `bson:"icon"` + Title string `bson:"title"` + Description string `bson:"description"` +} + +// contactSection represents the contact section of the landing page +type contactSection struct { + Title string `bson:"title"` + Description string `bson:"description"` + SubmitButtonText string `bson:"submit_button_text"` + Fields []formField `bson:"fields"` +} + +// formField represents a form field in the contact section +type formField struct { + Name string `bson:"name"` + Label string `bson:"label"` + Placeholder string `bson:"placeholder"` + Required bool `bson:"required"` +} + +// footerSection represents the footer section of the landing page +type footerSection struct { + Email string `bson:"email"` + Phone string `bson:"phone"` + Location string `bson:"location"` + Tagline string `bson:"tagline"` + Copyright string `bson:"copyright"` +} diff --git a/internal/cms/errors.go b/internal/cms/errors.go new file mode 100644 index 0000000..6253d02 --- /dev/null +++ b/internal/cms/errors.go @@ -0,0 +1,13 @@ +package cms + +import "errors" + +// Custom errors for cms domain +var ( + ErrCMSNotFound = errors.New("cms not found") + ErrCMSKeyRequired = errors.New("cms key is required") + ErrCMSKeyExists = errors.New("cms with this key already exists") + ErrInvalidCMSData = errors.New("invalid cms data") + ErrUnauthorizedAccess = errors.New("unauthorized access to cms") + ErrInvalidKeyFormat = errors.New("invalid cms key format") +) diff --git a/internal/cms/form.go b/internal/cms/form.go new file mode 100644 index 0000000..ad64b27 --- /dev/null +++ b/internal/cms/form.go @@ -0,0 +1,120 @@ +package cms + +import "tm/pkg/response" + +// CMSForm represents the form for creating/updating the CMS +type CMSForm struct { + Key string `json:"key" valid:"required,length(2|100)" example:"SW-001"` + Hero heroSectionForm `json:"hero" valid:"optional"` + Chart chartSectionForm `json:"chart" valid:"optional"` + Features cardSectionForm `json:"features" valid:"optional"` + Challenges cardSectionForm `json:"challenges" valid:"optional"` + Advantages cardSectionForm `json:"advantages" valid:"optional"` + Contact contactSectionForm `json:"contact" valid:"optional"` + Footer footerSectionForm `json:"footer" valid:"optional"` +} + +// heroSectionForm represents the form for creating/updating the hero section +type heroSectionForm struct { + Title string `json:"title" valid:"optional,length(2|100)" example:"Hero Title"` + Description string `json:"description" valid:"optional,length(2|1000)" example:"Hero Description"` + ButtonText string `json:"button_text" valid:"optional,length(2|100)" example:"Button Text"` + ButtonLink string `json:"button_link" valid:"optional,url" example:"https://example.com"` + GifFile string `json:"gif_file" valid:"optional,url" example:"https://example.com/gif.gif"` +} + +// chartSectionForm represents the form for creating/updating the chart section +type chartSectionForm struct { + Title string `json:"title" valid:"optional,length(2|100)" example:"Chart Title"` + MissedAmount string `json:"missed_amount" valid:"optional,length(2|100)" example:"Missed Amount"` + Data []chartPointForm `json:"data" valid:"optional"` +} + +// chartPointForm represents the form for creating/updating a chart point +type chartPointForm struct { + Key string `json:"key" valid:"optional,length(2|100)" example:"Key"` + Value int `json:"value" valid:"optional,range(0|1000000)" example:"1000000"` +} + +// cardSectionForm represents the form for creating/updating the card section +type cardSectionForm struct { + Title string `json:"title" valid:"optional,length(2|100)" example:"Card Title"` + Description string `json:"description" valid:"optional,length(2|1000)" example:"Card Description"` + Cards []cardForm `json:"cards" valid:"optional"` +} + +// cardForm represents the form for creating/updating a card +type cardForm struct { + Icon string `json:"icon" valid:"optional,url" example:"https://example.com/icon.png"` + Title string `json:"title" valid:"optional,length(2|100)" example:"Card Title"` + Description string `json:"description" valid:"optional,length(2|1000)" example:"Card Description"` +} + +// contactSectionForm represents the form for creating/updating the contact section +type contactSectionForm struct { + Title string `json:"title" valid:"optional,length(2|100)" example:"Contact Title"` + Description string `json:"description" valid:"optional,length(2|1000)" example:"Contact Description"` + SubmitButtonText string `json:"submit_button_text" valid:"optional,length(2|100)" example:"Submit Button Text"` + Fields []formFieldForm `json:"fields" valid:"optional"` +} + +// formFieldForm represents the form for creating/updating a form field +type formFieldForm struct { + Name string `json:"name" valid:"optional,length(2|100)" example:"Name"` + Label string `json:"label" valid:"optional,length(2|100)" example:"Label"` + Placeholder string `json:"placeholder" valid:"optional,length(2|100)" example:"Placeholder"` + Required bool `json:"required" valid:"optional" example:"true"` +} + +// footerSectionForm represents the form for creating/updating the footer section +type footerSectionForm struct { + Email string `json:"email" valid:"optional,email" example:"info@example.com"` + Phone string `json:"phone" valid:"optional,length(10|20)" example:"+1234567890"` + Location string `json:"location" valid:"optional,length(2|100)" example:"Location"` + Tagline string `json:"tagline" valid:"optional,length(2|100)" example:"Tagline"` + Copyright string `json:"copyright" valid:"optional,length(2|100)" example:"Copyright"` +} + +// SearchCMSForm represents the form for searching CMS entries +type SearchCMSForm struct { + Search *string `query:"q" valid:"optional"` + Key *string `query:"key" valid:"optional,length(2|100)"` +} + +// CMSResponse represents the CMS data sent in API responses +type CMSResponse struct { + ID string `json:"id"` + Key string `json:"key"` + Hero heroSection `json:"hero"` + Chart chartSection `json:"chart"` + Features cardSection `json:"features"` + Challenges cardSection `json:"challenges"` + Advantages cardSection `json:"advantages"` + Contact contactSection `json:"contact"` + Footer footerSection `json:"footer"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// CMSListResponse represents the response for listing CMS entries +type CMSListResponse struct { + CMS []*CMSResponse `json:"cms"` + Meta *response.Meta `json:"-"` +} + +// ToResponse converts CMS to CMSResponse +func (c *CMS) ToResponse() *CMSResponse { + return &CMSResponse{ + ID: c.GetID(), + Key: c.Key, + Hero: c.Hero, + Chart: c.Chart, + Features: c.Features, + Challenges: c.Challenges, + Advantages: c.Advantages, + Contact: c.Contact, + Footer: c.Footer, + CreatedAt: c.GetCreatedAt(), + UpdatedAt: c.GetUpdatedAt(), + } +} diff --git a/internal/cms/handler.go b/internal/cms/handler.go new file mode 100644 index 0000000..6dfcb2a --- /dev/null +++ b/internal/cms/handler.go @@ -0,0 +1,190 @@ +package cms + +import ( + "tm/pkg/response" + + "github.com/labstack/echo/v4" +) + +// Handler handles HTTP requests for CMS operations +type Handler struct { + service Service +} + +// NewHandler creates a new CMS handler +func NewHandler(service Service) *Handler { + return &Handler{ + service: service, + } +} + +// GetByKey handles retrieving CMS content by key (public API) +// @Summary Get CMS content by key +// @Description Retrieve CMS content for public display by its key +// @Tags CMS +// @Accept json +// @Produce json +// @Param key path string true "CMS key" +// @Success 200 {object} response.APIResponse{data=CMSResponse} +// @Failure 404 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/cms/{key} [get] +func (h *Handler) GetByKey(c echo.Context) error { + key := c.Param("key") + + cms, err := h.service.GetByKey(c.Request().Context(), key) + if err != nil { + return response.NotFound(c, "CMS content not found") + } + + return response.Success(c, cms, "CMS content retrieved successfully") +} + +// Create handles creating a new CMS entry (admin only) +// @Summary Create a new CMS entry +// @Description Create a new CMS entry for content management (admin only) +// @Tags Admin-CMS +// @Accept json +// @Produce json +// @Param cms body CMSForm true "CMS information" +// @Success 201 {object} response.APIResponse{data=CMSResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 409 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/v1/cms [post] +func (h *Handler) Create(c echo.Context) error { + form, err := response.Parse[CMSForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + // Create CMS + cms, err := h.service.Create(c.Request().Context(), form) + if err != nil { + if err == ErrCMSKeyExists { + return response.Conflict(c, "CMS with this key already exists") + } + return response.InternalServerError(c, "Failed to create CMS") + } + + return response.Created(c, cms, "CMS entry created successfully") +} + +// GetByID handles retrieving a CMS entry by ID (admin only) +// @Summary Get CMS by ID +// @Description Retrieve a CMS entry by its ID for editing (admin only) +// @Tags Admin-CMS +// @Accept json +// @Produce json +// @Param id path string true "CMS ID" +// @Success 200 {object} response.APIResponse{data=CMSResponse} +// @Failure 404 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/v1/cms/{id} [get] +func (h *Handler) GetByID(c echo.Context) error { + id := c.Param("id") + + cms, err := h.service.GetByID(c.Request().Context(), id) + if err != nil { + if err.Error() == "cms not found" { + return response.NotFound(c, "CMS entry not found") + } + return response.InternalServerError(c, "Failed to get CMS entry") + } + + return response.Success(c, cms, "CMS entry retrieved successfully") +} + +// Update handles updating a CMS entry (admin only) +// @Summary Update CMS entry +// @Description Update an existing CMS entry by its ID (admin only) +// @Tags Admin-CMS +// @Accept json +// @Produce json +// @Param id path string true "CMS ID" +// @Param cms body CMSForm true "Updated CMS information" +// @Success 200 {object} response.APIResponse{data=CMSResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 404 {object} response.APIResponse +// @Failure 409 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/v1/cms/{id} [put] +func (h *Handler) Update(c echo.Context) error { + id := c.Param("id") + + form, err := response.Parse[CMSForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + // Update CMS + cms, err := h.service.Update(c.Request().Context(), id, form) + if err != nil { + if err == ErrCMSKeyExists { + return response.Conflict(c, "CMS with this key already exists") + } + if err.Error() == "cms not found" { + return response.NotFound(c, "CMS entry not found") + } + return response.InternalServerError(c, "Failed to update CMS entry") + } + + return response.Success(c, cms, "CMS entry updated successfully") +} + +// Delete handles deleting a CMS entry (admin only) +// @Summary Delete CMS entry +// @Description Delete a CMS entry by its ID (admin only) +// @Tags Admin-CMS +// @Accept json +// @Produce json +// @Param id path string true "CMS ID" +// @Success 200 {object} response.APIResponse +// @Failure 404 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/v1/cms/{id} [delete] +func (h *Handler) Delete(c echo.Context) error { + id := c.Param("id") + + err := h.service.Delete(c.Request().Context(), id) + if err != nil { + if err.Error() == "cms not found" { + return response.NotFound(c, "CMS entry not found") + } + return response.InternalServerError(c, "Failed to delete CMS entry") + } + + return response.Success(c, nil, "CMS entry deleted successfully") +} + +// Search handles searching CMS entries (admin only) +// @Summary Search CMS entries +// @Description Search CMS entries with filters and pagination (admin only) +// @Tags Admin-CMS +// @Accept json +// @Produce json +// @Param q query string false "Search query" +// @Param key query string false "CMS key filter" +// @Param limit query integer false "Number of CMS entries per page (1-100)" minimum(1) maximum(100) default(20) +// @Param offset query integer false "Number of CMS entries to skip for pagination" minimum(0) default(0) +// @Param sort_by query string false "Field to sort by" Enums(key, created_at, updated_at) default(created_at) +// @Param sort_order query string false "Sort order" Enums(asc, desc) default(desc) +// @Success 200 {object} response.APIResponse{data=CMSListResponse} +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/v1/cms [get] +func (h *Handler) Search(c echo.Context) error { + form, err := response.Parse[SearchCMSForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + pagination := response.NewPagination(c) + + result, err := h.service.Search(c.Request().Context(), form, pagination) + if err != nil { + return response.InternalServerError(c, "Failed to search CMS entries") + } + + return response.SuccessWithMeta(c, result, result.Meta, "CMS entries retrieved successfully") +} diff --git a/internal/cms/repository.go b/internal/cms/repository.go new file mode 100644 index 0000000..453a746 --- /dev/null +++ b/internal/cms/repository.go @@ -0,0 +1,195 @@ +package cms + +import ( + "context" + "errors" + "time" + "tm/pkg/logger" + orm "tm/pkg/mongo" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Repository defines methods for CMS data access +type Repository interface { + Create(ctx context.Context, cms *CMS) error + Update(ctx context.Context, cms *CMS) error + Delete(ctx context.Context, id string) error + GetByID(ctx context.Context, id string) (*CMS, error) + GetByKey(ctx context.Context, key string) (*CMS, error) + Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) +} + +// cmsRepository implements the Repository interface using the MongoDB ORM +type cmsRepository struct { + ormRepo orm.Repository[CMS] + logger logger.Logger +} + +// NewRepository creates a new CMS repository +func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Repository { + // Create indexes using the ORM's index management + indexes := []orm.Index{ + *orm.CreateTextIndex("cms_search_idx", "key"), + *orm.NewIndex("key_idx", bson.D{{Key: "key", Value: 1}}), + *orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}), + } + + // Create indexes + err := mongoManager.CreateIndexes("cms", indexes) + if err != nil { + logger.Warn("Failed to create CMS indexes", map[string]interface{}{ + "error": err.Error(), + }) + } + + // Create ORM repository + ormRepo := orm.NewRepository[CMS](mongoManager.GetCollection("cms"), logger) + + return &cmsRepository{ + ormRepo: ormRepo, + logger: logger, + } +} + +// Create creates a new CMS entry +func (r *cmsRepository) Create(ctx context.Context, cms *CMS) error { + // Set created timestamp using Unix timestamp + now := time.Now().Unix() + cms.SetCreatedAt(now) + + // Use ORM to create CMS + err := r.ormRepo.Create(ctx, cms) + if err != nil { + r.logger.Error("Failed to create CMS", map[string]interface{}{ + "error": err.Error(), + "key": cms.Key, + }) + return err + } + + r.logger.Info("CMS created successfully", map[string]interface{}{ + "id": cms.GetID(), + "key": cms.Key, + }) + + return nil +} + +// Update updates a CMS entry +func (r *cmsRepository) Update(ctx context.Context, cms *CMS) error { + // Set updated timestamp using Unix timestamp + cms.SetUpdatedAt(time.Now().Unix()) + + // Use ORM to update CMS + err := r.ormRepo.Update(ctx, cms) + if err != nil { + r.logger.Error("Failed to update CMS", map[string]interface{}{ + "error": err.Error(), + "id": cms.GetID(), + "key": cms.Key, + }) + return err + } + + r.logger.Info("CMS updated successfully", map[string]interface{}{ + "id": cms.GetID(), + "key": cms.Key, + }) + + return nil +} + +// GetByID retrieves a CMS entry by ID +func (r *cmsRepository) GetByID(ctx context.Context, id string) (*CMS, error) { + cms, err := r.ormRepo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, orm.ErrDocumentNotFound) { + return nil, ErrCMSNotFound + } + r.logger.Error("Failed to get CMS by ID", map[string]interface{}{ + "error": err.Error(), + "id": id, + }) + return nil, err + } + + return cms, nil +} + +// GetByKey retrieves a CMS entry by key +func (r *cmsRepository) GetByKey(ctx context.Context, key string) (*CMS, error) { + cms, err := r.ormRepo.FindOne(ctx, bson.M{"key": key}) + if err != nil { + if errors.Is(err, orm.ErrDocumentNotFound) { + return nil, ErrCMSNotFound + } + r.logger.Error("Failed to get CMS by key", map[string]interface{}{ + "error": err.Error(), + "key": key, + }) + return nil, err + } + + return cms, nil +} + +// Delete deletes a CMS entry +func (r *cmsRepository) Delete(ctx context.Context, id string) error { + // Get CMS first to log the key + cms, err := r.GetByID(ctx, id) + if err != nil { + r.logger.Error("Failed to delete CMS", map[string]interface{}{ + "error": err.Error(), + "id": id, + }) + if errors.Is(err, ErrCMSNotFound) { + return ErrCMSNotFound + } + return err + } + + // Delete from database + err = r.ormRepo.Delete(ctx, id) + if err != nil { + r.logger.Error("Failed to delete CMS", map[string]interface{}{ + "error": err.Error(), + "id": id, + "key": cms.Key, + }) + return err + } + + r.logger.Info("CMS deleted successfully", map[string]interface{}{ + "id": id, + "key": cms.Key, + }) + + return nil +} + +// Search searches CMS entries with filters and pagination +func (r *cmsRepository) Search(ctx context.Context, search *SearchCMSForm, pagination orm.Pagination) (*orm.PaginatedResult[CMS], error) { + filter := bson.M{} + + if search.Search != nil && *search.Search != "" { + filter["$or"] = bson.A{ + bson.M{"key": bson.M{"$regex": search.Search, "$options": "i"}}, + } + } + + if search.Key != nil && *search.Key != "" { + filter["key"] = bson.M{"$regex": search.Key, "$options": "i"} + } + + // Use ORM to find CMS entries + result, err := r.ormRepo.FindAll(ctx, filter, pagination) + if err != nil { + r.logger.Error("Failed to search CMS entries", map[string]interface{}{ + "error": err.Error(), + }) + return nil, err + } + + return result, nil +} diff --git a/internal/cms/service.go b/internal/cms/service.go new file mode 100644 index 0000000..5928f28 --- /dev/null +++ b/internal/cms/service.go @@ -0,0 +1,328 @@ +package cms + +import ( + "context" + "fmt" + "tm/pkg/logger" + orm "tm/pkg/mongo" + "tm/pkg/response" +) + +// Service defines the interface for CMS business operations +type Service interface { + // Create creates a new CMS entry + Create(ctx context.Context, form *CMSForm) (*CMSResponse, error) + + // GetByID retrieves a CMS entry by ID + GetByID(ctx context.Context, id string) (*CMSResponse, error) + + // GetByKey retrieves a CMS entry by key + GetByKey(ctx context.Context, key string) (*CMSResponse, error) + + // Update updates a CMS entry + Update(ctx context.Context, id string, form *CMSForm) (*CMSResponse, error) + + // Delete deletes a CMS entry by ID + Delete(ctx context.Context, id string) error + + // Search searches CMS entries with filters and pagination + Search(ctx context.Context, form *SearchCMSForm, pagination *response.Pagination) (*CMSListResponse, error) +} + +// cmsService implements Service interface +type cmsService struct { + repo Repository + logger logger.Logger +} + +// NewService creates a new CMS service +func NewService(repo Repository, logger logger.Logger) Service { + return &cmsService{ + repo: repo, + logger: logger, + } +} + +// Create creates a new CMS entry +func (s *cmsService) Create(ctx context.Context, form *CMSForm) (*CMSResponse, error) { + s.logger.Info("Creating new CMS entry", map[string]interface{}{ + "key": form.Key, + }) + + // Check if CMS with this key already exists + existing, err := s.repo.GetByKey(ctx, form.Key) + if err == nil && existing != nil { + s.logger.Warn("CMS with this key already exists", map[string]interface{}{ + "key": form.Key, + }) + return nil, ErrCMSKeyExists + } + + // Create CMS entity + cms := &CMS{ + Key: form.Key, + } + + // Copy form data to entity + s.copyFormToEntity(form, cms) + + // Save to database + if err := s.repo.Create(ctx, cms); err != nil { + s.logger.Error("Failed to create CMS", map[string]interface{}{ + "key": form.Key, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to create CMS: %w", err) + } + + s.logger.Info("CMS entry created successfully", map[string]interface{}{ + "id": cms.GetID(), + "key": form.Key, + }) + + return cms.ToResponse(), nil +} + +// GetByID retrieves a CMS entry by ID +func (s *cmsService) GetByID(ctx context.Context, id string) (*CMSResponse, error) { + s.logger.Debug("Retrieving CMS by ID", map[string]interface{}{ + "id": id, + }) + + cms, err := s.repo.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get CMS by ID", map[string]interface{}{ + "id": id, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get CMS: %w", err) + } + + return cms.ToResponse(), nil +} + +// GetByKey retrieves a CMS entry by key +func (s *cmsService) GetByKey(ctx context.Context, key string) (*CMSResponse, error) { + s.logger.Debug("Retrieving CMS by key", map[string]interface{}{ + "key": key, + }) + + cms, err := s.repo.GetByKey(ctx, key) + if err != nil { + s.logger.Error("Failed to get CMS by key", map[string]interface{}{ + "key": key, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get CMS: %w", err) + } + + return cms.ToResponse(), nil +} + +// Update updates a CMS entry +func (s *cmsService) Update(ctx context.Context, id string, form *CMSForm) (*CMSResponse, error) { + s.logger.Info("Updating CMS entry", map[string]interface{}{ + "id": id, + "key": form.Key, + }) + + // Get existing CMS + cms, err := s.repo.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get existing CMS for update", map[string]interface{}{ + "id": id, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get CMS for update: %w", err) + } + + // Check if key is being changed and if new key already exists + if cms.Key != form.Key { + existing, err := s.repo.GetByKey(ctx, form.Key) + if err == nil && existing != nil && existing.GetID() != id { + s.logger.Warn("CMS with this key already exists", map[string]interface{}{ + "key": form.Key, + }) + return nil, ErrCMSKeyExists + } + cms.Key = form.Key + } + + // Copy form data to entity + s.copyFormToEntity(form, cms) + + // Update in database + if err := s.repo.Update(ctx, cms); err != nil { + s.logger.Error("Failed to update CMS", map[string]interface{}{ + "id": id, + "key": form.Key, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to update CMS: %w", err) + } + + s.logger.Info("CMS entry updated successfully", map[string]interface{}{ + "id": id, + "key": form.Key, + }) + + return cms.ToResponse(), nil +} + +// Delete deletes a CMS entry by ID +func (s *cmsService) Delete(ctx context.Context, id string) error { + s.logger.Info("Deleting CMS entry", map[string]interface{}{ + "id": id, + }) + + if err := s.repo.Delete(ctx, id); err != nil { + s.logger.Error("Failed to delete CMS", map[string]interface{}{ + "id": id, + "error": err.Error(), + }) + return fmt.Errorf("failed to delete CMS: %w", err) + } + + s.logger.Info("CMS entry deleted successfully", map[string]interface{}{ + "id": id, + }) + + return nil +} + +// Search searches CMS entries with filters and pagination +func (s *cmsService) Search(ctx context.Context, form *SearchCMSForm, pagination *response.Pagination) (*CMSListResponse, error) { + s.logger.Debug("Searching CMS entries", map[string]interface{}{ + "filters": form, + "pagination": pagination, + }) + + // Convert response.Pagination to orm.Pagination + ormPagination := orm.Pagination{ + Limit: pagination.Limit, + Skip: pagination.Offset, + SortField: pagination.SortBy, + SortOrder: 1, // Default ascending + } + if pagination.SortOrder == "desc" { + ormPagination.SortOrder = -1 + } + + result, err := s.repo.Search(ctx, form, ormPagination) + if err != nil { + s.logger.Error("Failed to search CMS entries", map[string]interface{}{ + "filters": form, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to search CMS entries: %w", err) + } + + // Convert to response format + cmsResponses := make([]*CMSResponse, len(result.Items)) + for i, cms := range result.Items { + cmsResponses[i] = cms.ToResponse() + } + + s.logger.Info("CMS search completed", map[string]interface{}{ + "total_count": result.TotalCount, + "items_count": len(result.Items), + }) + + return &CMSListResponse{ + CMS: cmsResponses, + Meta: pagination.Response(result.TotalCount), + }, nil +} + +// copyFormToEntity copies data from form to entity +func (s *cmsService) copyFormToEntity(form *CMSForm, cms *CMS) { + // Hero section + if form.Hero.Title != "" { + cms.Hero.Title = form.Hero.Title + } + if form.Hero.Description != "" { + cms.Hero.Description = form.Hero.Description + } + if form.Hero.ButtonText != "" { + cms.Hero.ButtonText = form.Hero.ButtonText + } + if form.Hero.ButtonLink != "" { + cms.Hero.ButtonLink = form.Hero.ButtonLink + } + if form.Hero.GifFile != "" { + cms.Hero.GifFile = form.Hero.GifFile + } + + // Chart section + if form.Chart.Title != "" { + cms.Chart.Title = form.Chart.Title + } + if form.Chart.MissedAmount != "" { + cms.Chart.MissedAmount = form.Chart.MissedAmount + } + if len(form.Chart.Data) > 0 { + cms.Chart.Data = make([]chartPoint, len(form.Chart.Data)) + for i, point := range form.Chart.Data { + cms.Chart.Data[i] = chartPoint(point) + } + } + + // Features section + s.copyCardSectionForm(&form.Features, &cms.Features) + + // Challenges section + s.copyCardSectionForm(&form.Challenges, &cms.Challenges) + + // Advantages section + s.copyCardSectionForm(&form.Advantages, &cms.Advantages) + + // Contact section + if form.Contact.Title != "" { + cms.Contact.Title = form.Contact.Title + } + if form.Contact.Description != "" { + cms.Contact.Description = form.Contact.Description + } + if form.Contact.SubmitButtonText != "" { + cms.Contact.SubmitButtonText = form.Contact.SubmitButtonText + } + if len(form.Contact.Fields) > 0 { + cms.Contact.Fields = make([]formField, len(form.Contact.Fields)) + for i, field := range form.Contact.Fields { + cms.Contact.Fields[i] = formField(field) + } + } + + // Footer section + if form.Footer.Email != "" { + cms.Footer.Email = form.Footer.Email + } + if form.Footer.Phone != "" { + cms.Footer.Phone = form.Footer.Phone + } + if form.Footer.Location != "" { + cms.Footer.Location = form.Footer.Location + } + if form.Footer.Tagline != "" { + cms.Footer.Tagline = form.Footer.Tagline + } + if form.Footer.Copyright != "" { + cms.Footer.Copyright = form.Footer.Copyright + } +} + +// copyCardSectionForm copies card section form data to entity +func (s *cmsService) copyCardSectionForm(formSection *cardSectionForm, entitySection *cardSection) { + if formSection.Title != "" { + entitySection.Title = formSection.Title + } + if formSection.Description != "" { + entitySection.Description = formSection.Description + } + if len(formSection.Cards) > 0 { + entitySection.Cards = make([]card, len(formSection.Cards)) + for i, cardForm := range formSection.Cards { + entitySection.Cards[i] = card(cardForm) + } + } +} diff --git a/internal/contact/handler.go b/internal/contact/handler.go index ecf0aca..1f88d47 100644 --- a/internal/contact/handler.go +++ b/internal/contact/handler.go @@ -21,7 +21,7 @@ func NewHandler(service Service) *Handler { // Create handles creating a new contact message // @Summary Create a new contact message // @Description Create a new contact message from a user -// @Tags contacts +// @Tags Contacts // @Accept json // @Produce json // @Param contact body CreateContactForm true "Contact information" @@ -52,7 +52,7 @@ func (h *Handler) Create(c echo.Context) error { // GetByID handles retrieving a contact by ID (admin only) // @Summary Get contact by ID // @Description Retrieve a contact message by its ID (admin only) -// @Tags contacts +// @Tags Admin-Contacts // @Accept json // @Produce json // @Param id path string true "Contact ID" @@ -78,7 +78,7 @@ func (h *Handler) GetByID(c echo.Context) error { // Search handles searching contacts with filters (admin only) // @Summary Search contacts // @Description Search contacts with filters and pagination (admin only) -// @Tags contacts +// @Tags Admin-Contacts // @Accept json // @Produce json // @Param q query string false "Search query" @@ -112,7 +112,7 @@ func (h *Handler) Search(c echo.Context) error { // UpdateStatus handles updating contact status (admin only) // @Summary Update contact status // @Description Update the status of a contact message (admin only) -// @Tags contacts +// @Tags Admin-Contacts // @Accept json // @Produce json // @Param id path string true "Contact ID" @@ -152,7 +152,7 @@ func (h *Handler) UpdateStatus(c echo.Context) error { // GetStats handles retrieving contact statistics (admin only) // @Summary Get contact statistics // @Description Get contact statistics by status (admin only) -// @Tags contacts +// @Tags Admin-Contacts // @Accept json // @Produce json // @Success 200 {object} response.APIResponse{data=ContactStats} @@ -170,7 +170,7 @@ func (h *Handler) GetStats(c echo.Context) error { // Delete handles deleting a contact (admin only) // @Summary Delete contact // @Description Delete a contact message by ID (admin only) -// @Tags contacts +// @Tags Admin-Contacts // @Accept json // @Produce json // @Param id path string true "Contact ID"