Merge pull request 'company feedback performance update' (#20) from TM-540 into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/20
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
This commit is contained in:
m.nazemi
2026-05-25 18:45:21 +03:30
8 changed files with 197 additions and 19 deletions
+2
View File
@@ -248,6 +248,8 @@ func main() {
// Initialize HTTP server
e := bootstrap.InitHTTPServer(conf, logger)
router.SetupCSPSecurity(e)
// Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, xssPolicy)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, xssPolicy)
+9 -13
View File
@@ -22,17 +22,20 @@ 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, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, xssPolicy *security.XSSPolicy) {
adminV1 := e.Group("/admin/v1")
// Add CSP middleware to admin routes (stricter policy)
// SetupCSPSecurity registers global CSP middleware and the CSP report endpoint once.
func SetupCSPSecurity(e *echo.Echo) {
adminCSPConfig := security.DefaultCSPConfig()
adminCSPConfig.ScriptSrc = []string{"'self'"} // No unsafe-inline for admin
adminCSPConfig.StyleSrc = []string{"'self'"} // No unsafe-inline for admin
e.Use(security.CSPMiddleware(adminCSPConfig))
// Add CSP report endpoint
publicCSPConfig := security.DefaultCSPConfig()
e.Use(security.CSPMiddlewareForPaths("/admin", adminCSPConfig, "/api/v1", publicCSPConfig))
e.POST("/api/v1/csp-report", security.CSPReportHandler)
}
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, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, dashboardHandler *dashboard.Handler, xssPolicy *security.XSSPolicy) {
adminV1 := e.Group("/admin/v1")
// Admin Users Routes
adminUsersGP := adminV1.Group("/users")
@@ -221,13 +224,6 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
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, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
v1 := e.Group("/api/v1")
// Add CSP middleware to public routes
cspConfig := security.DefaultCSPConfig()
e.Use(security.CSPMiddleware(cspConfig))
// Add CSP report endpoint
e.POST("/api/v1/csp-report", security.CSPReportHandler)
customerGP := v1.Group("/profile")
{
customerGP.POST("/login", customerHandler.Login)
+25
View File
@@ -16,6 +16,7 @@ type Service interface {
Update(ctx context.Context, id string, form *CompanyForm) (*CompanyResponse, error)
GetByID(ctx context.Context, id string) (*CompanyResponse, error)
GetByIDs(ctx context.Context, ids []string) ([]*CompanyResponse, error)
GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error)
Delete(ctx context.Context, id string) error
// Company listing and search
@@ -389,6 +390,30 @@ func (s *companyService) GetByIDs(ctx context.Context, ids []string) ([]*Company
return companyResponses, nil
}
// GetNamesByIDs returns company id and name keyed by company ID without loading categories.
func (s *companyService) GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error) {
if len(ids) == 0 {
return map[string]*CompanyResponse{}, nil
}
companies, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
s.logger.Error("Failed to get companies by IDs for names", map[string]interface{}{
"count": len(ids),
"error": err.Error(),
})
return nil, errors.New("failed to get companies by IDs")
}
out := make(map[string]*CompanyResponse, len(companies))
for _, c := range companies {
id := c.ID.Hex()
out[id] = &CompanyResponse{ID: id, Name: c.Name}
}
return out, nil
}
// UpdateCompanyStatus updates company status
func (s *companyService) UpdateStatus(ctx context.Context, id string, form *StatusForm) error {
// Get company to check if exists
+2 -1
View File
@@ -1,6 +1,7 @@
package document_scraper
import (
"crypto/subtle"
"strings"
"tm/pkg/response"
@@ -25,7 +26,7 @@ func APIKeyMiddleware(apiKey string) echo.MiddlewareFunc {
return response.Unauthorized(c, "API key required. Use X-API-Key header or Authorization: ApiKey <key>")
}
if apiKeyHeader != apiKey {
if subtle.ConstantTimeCompare([]byte(apiKeyHeader), []byte(apiKey)) != 1 {
return response.Unauthorized(c, "Invalid API key")
}
+82 -2
View File
@@ -137,10 +137,19 @@ func (s *feedbackService) Search(ctx context.Context, criteria SearchForm, pagin
meta := pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset)
tenderByID, companyByID := s.loadListDetails(ctx, page.Items)
result := make([]*FeedbackResponse, len(page.Items))
for i, feedback := range page.Items {
tender, company := s.getDetails(ctx, feedback.TenderID, feedbackCompanyID(feedback))
result[i] = feedback.ToResponse(tender, company)
var tenderResp *tenderResponse
if t, ok := tenderByID[feedback.TenderID]; ok {
tenderResp = t
}
var companyResp *companyResponse
if cid := feedbackCompanyID(feedback); cid != "" {
companyResp = companyByID[cid]
}
result[i] = feedback.ToResponse(tenderResp, companyResp)
}
return &FeedbackListResponse{
@@ -175,6 +184,77 @@ func feedbackCompanyID(f Feedback) string {
return *f.CompanyID
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func collectFeedbackListIDs(items []Feedback) (tenderIDs, companyIDs []string) {
tenderIDs = make([]string, 0, len(items))
companyIDs = make([]string, 0, len(items))
for _, item := range items {
tenderIDs = append(tenderIDs, item.TenderID)
companyIDs = append(companyIDs, feedbackCompanyID(item))
}
return uniqueNonEmptyStrings(tenderIDs), uniqueNonEmptyStrings(companyIDs)
}
// loadListDetails batch-loads tender and company data for feedback list responses.
func (s *feedbackService) loadListDetails(ctx context.Context, items []Feedback) (
map[string]*tenderResponse,
map[string]*companyResponse,
) {
tenderByID := make(map[string]*tenderResponse)
companyByID := make(map[string]*companyResponse)
if len(items) == 0 {
return tenderByID, companyByID
}
tenderIDs, companyIDs := collectFeedbackListIDs(items)
if len(tenderIDs) > 0 {
tenders, err := s.tenderService.GetByIDs(ctx, tenderIDs)
if err != nil {
s.logger.Error("Failed to batch load tenders for feedback list", map[string]interface{}{
"count": len(tenderIDs),
"error": err.Error(),
})
} else {
for id, t := range tenders {
tenderByID[id] = tenderToFeedbackResponse(t)
}
}
}
if len(companyIDs) > 0 {
companies, err := s.companyService.GetNamesByIDs(ctx, companyIDs)
if err != nil {
s.logger.Error("Failed to batch load companies for feedback list", map[string]interface{}{
"count": len(companyIDs),
"error": err.Error(),
})
} else {
for id, c := range companies {
companyByID[id] = companyToFeedbackResponse(c)
}
}
}
return tenderByID, companyByID
}
func tenderToFeedbackResponse(t *tender.TenderResponse) *tenderResponse {
if t == nil {
return nil
+28
View File
@@ -18,6 +18,7 @@ type TenderRepository interface {
// Tender CRUD operations
Create(ctx context.Context, tender *Tender) error
GetByID(ctx context.Context, id string) (*Tender, error)
GetByIDs(ctx context.Context, ids []string) ([]Tender, error)
GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error)
GetByNoticePublicationID(ctx context.Context, noticePublicationID string) (*Tender, error)
GetByContractFolderID(ctx context.Context, contractFolderID string) (*Tender, error)
@@ -214,6 +215,33 @@ func (r *tenderRepository) GetByID(ctx context.Context, id string) (*Tender, err
return tender, nil
}
// GetByIDs retrieves tenders by IDs in a single query.
func (r *tenderRepository) GetByIDs(ctx context.Context, ids []string) ([]Tender, error) {
if len(ids) == 0 {
return nil, nil
}
objectIDs := make([]bson.ObjectID, 0, len(ids))
for _, id := range ids {
objectID, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, err
}
objectIDs = append(objectIDs, objectID)
}
result, err := r.ormRepo.FindAll(ctx, bson.M{"_id": bson.M{"$in": objectIDs}}, orm.NewPaginationBuilder().Build())
if err != nil {
r.logger.Error("Failed to get tenders by IDs", map[string]interface{}{
"count": len(ids),
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
// GetByContractNoticeID retrieves a tender by contract notice ID
func (r *tenderRepository) GetByContractNoticeID(ctx context.Context, contractNoticeID string) (*Tender, error) {
filter := bson.M{"contract_notice_id": contractNoticeID}
+28 -3
View File
@@ -47,6 +47,7 @@ type TenderDocumentDownload struct {
type Service interface {
// Tender operations
GetByID(ctx context.Context, id string, language string) (*TenderResponse, error)
GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error)
Update(ctx context.Context, req UpdateTenderRequest) (*TenderResponse, error)
Delete(ctx context.Context, id string) error
Search(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*SearchResponse, error)
@@ -82,7 +83,7 @@ type tenderService struct {
}
const (
aiSummaryEnrichmentTimeout = 2 * time.Second
aiSummaryEnrichmentTimeout = 2 * time.Second
aiTranslationEnrichmentTimeout = 2 * time.Second
)
@@ -128,6 +129,30 @@ func (s *tenderService) GetByID(ctx context.Context, id string, language string)
return s.buildDetailResponse(ctx, tender, language), nil
}
// GetByIDs loads multiple tenders for list endpoints without MinIO or AI enrichment.
func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string]*TenderResponse, error) {
if len(ids) == 0 {
return map[string]*TenderResponse{}, nil
}
tenders, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
s.logger.Error("Failed to retrieve tenders by IDs", map[string]interface{}{
"count": len(ids),
"error": err.Error(),
})
return nil, fmt.Errorf("failed to retrieve tenders: %w", err)
}
out := make(map[string]*TenderResponse, len(tenders))
for i := range tenders {
resp := tenders[i].ToResponseWithLanguage("")
out[resp.ID] = resp
}
return out, nil
}
// buildDetailResponse maps a tender to API form and enriches translation (MinIO) and summary.
func (s *tenderService) buildDetailResponse(ctx context.Context, tender *Tender, language string) *TenderResponse {
resp := tender.ToResponseWithLanguage("")
@@ -1271,8 +1296,8 @@ func (s *tenderService) ListTendersWithScrapedDocuments(ctx context.Context, pag
}
s.logger.Info("Listing tenders with scraped documents from MinIO", map[string]interface{}{
"limit": pagination.Limit,
"offset": pagination.Offset,
"limit": pagination.Limit,
"offset": pagination.Offset,
"sync_mongo_metadata": syncMongoMetadata,
})
+21
View File
@@ -108,6 +108,27 @@ func CSPMiddleware(config *CSPConfig) echo.MiddlewareFunc {
}
}
// CSPMiddlewareForPaths applies CSP middleware based on request path prefix.
// Requests matching no prefix proceed without a CSP header.
func CSPMiddlewareForPaths(adminPrefix string, adminConfig *CSPConfig, publicPrefix string, publicConfig *CSPConfig) echo.MiddlewareFunc {
adminMW := CSPMiddleware(adminConfig)
publicMW := CSPMiddleware(publicConfig)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
switch {
case strings.HasPrefix(path, adminPrefix):
return adminMW(next)(c)
case strings.HasPrefix(path, publicPrefix):
return publicMW(next)(c)
default:
return next(c)
}
}
}
}
// CSPReportHandler handles CSP violation reports
func CSPReportHandler(c echo.Context) error {
var report struct {