diff --git a/internal/customer/company_context.go b/internal/customer/company_context.go index ebfc339..bfa9039 100644 --- a/internal/customer/company_context.go +++ b/internal/customer/company_context.go @@ -6,7 +6,10 @@ import ( "strings" ) -var errCompanyNotAssigned = errors.New("company is not assigned to customer") +// ErrCompanyNotAssigned is returned when the requested company is not assigned to the customer. +var ErrCompanyNotAssigned = errors.New("company is not assigned to customer") + +var errCompanyNotAssigned = ErrCompanyNotAssigned func normalizeAssignedCompanyIDs(assigned []string) []string { normalized := make([]string, 0, len(assigned)) @@ -56,6 +59,27 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s return assigned[0], nil } +// PickAssignedCompanyID chooses the company for a company-scoped write (e.g. tender approval). +// When requestedCompanyID is set it must be in assigned; otherwise activeCompanyID is used. +func PickAssignedCompanyID(activeCompanyID string, assigned []string, requestedCompanyID string) (string, error) { + activeCompanyID = strings.TrimSpace(activeCompanyID) + requestedCompanyID = strings.TrimSpace(requestedCompanyID) + + if requestedCompanyID != "" { + for _, id := range normalizeAssignedCompanyIDs(assigned) { + if id == requestedCompanyID { + return requestedCompanyID, nil + } + } + return "", ErrCompanyNotAssigned + } + + if activeCompanyID == "" { + return "", errors.New("company ID is required") + } + return activeCompanyID, nil +} + // ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer. func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) { customer, err := s.repository.GetByID(ctx, customerID) diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 3987164..1bedf77 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -249,12 +249,19 @@ func (r *tenderRepository) GetByIDs(ctx context.Context, ids []string) ([]Tender objectIDs := make([]bson.ObjectID, 0, len(batch)) for _, id := range batch { - objectID, err := bson.ObjectIDFromHex(id) + objectID, err := bson.ObjectIDFromHex(strings.TrimSpace(id)) if err != nil { - return nil, err + r.logger.Warn("Skipping invalid tender ID in batch lookup", map[string]interface{}{ + "tender_id": id, + "error": err.Error(), + }) + continue } objectIDs = append(objectIDs, objectID) } + if len(objectIDs) == 0 { + continue + } pagination := orm.Pagination{ Limit: len(objectIDs), diff --git a/internal/tender/service.go b/internal/tender/service.go index c02b081..0d4aa38 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -55,6 +55,8 @@ 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) + // ResolveMongoID maps a MongoDB id or AI procedure ref to the canonical tender MongoDB id. + ResolveMongoID(ctx context.Context, tenderRef string) (string, 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) @@ -181,6 +183,35 @@ func (s *tenderService) GetByIDs(ctx context.Context, ids []string) (map[string] return out, nil } +// ResolveMongoID maps a MongoDB id or AI procedure ref (PROC_{folder}/{notice}) to the canonical tender id. +func (s *tenderService) ResolveMongoID(ctx context.Context, tenderRef string) (string, error) { + tenderRef = strings.TrimSpace(tenderRef) + if tenderRef == "" { + return "", fmt.Errorf("tender reference is required") + } + + if IsMongoObjectIDRef(tenderRef) { + if _, err := s.repository.GetByID(ctx, tenderRef); err != nil { + return "", fmt.Errorf("tender not found") + } + return tenderRef, nil + } + + if folder, notice, ok := ParseAIProcedureRef(tenderRef); ok { + t, err := s.repository.GetByProcedureReference(ctx, folder, notice) + if err != nil || t == nil { + return "", fmt.Errorf("tender not found") + } + return t.GetID(), nil + } + + if _, err := s.repository.GetByID(ctx, tenderRef); err == nil { + return tenderRef, nil + } + + return "", fmt.Errorf("tender not found") +} + // 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("") diff --git a/internal/tender_approval/form.go b/internal/tender_approval/form.go index f2593d1..7f392fe 100644 --- a/internal/tender_approval/form.go +++ b/internal/tender_approval/form.go @@ -81,6 +81,7 @@ type CompanyTenderApprovalStatsResponse struct { // ToggleTenderApprovalForm represents the form for toggling a tender approval type ToggleTenderApprovalForm struct { TenderID string `json:"tender_id" valid:"required"` + CompanyID *string `json:"company_id,omitempty" valid:"optional"` Status ApprovalStatus `json:"status" valid:"required,in(submitted|rejected)"` SubmissionMode SubmissionMode `json:"submission_mode" valid:"required,in(self-apply|partnership)"` } diff --git a/internal/tender_approval/handler.go b/internal/tender_approval/handler.go index a973e3b..43ae803 100644 --- a/internal/tender_approval/handler.go +++ b/internal/tender_approval/handler.go @@ -1,7 +1,10 @@ package tender_approval import ( + "errors" "strconv" + "strings" + "tm/internal/customer" "tm/pkg/logger" "tm/pkg/response" @@ -42,14 +45,26 @@ func (h *Handler) ToggleTenderApproval(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - // Get company ID from context - companyID := c.Get("company_id").(string) - if companyID == "" { - return response.Unauthorized(c, "Unauthorized") + requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id")) + if form.CompanyID != nil && strings.TrimSpace(*form.CompanyID) != "" { + requestedCompanyID = strings.TrimSpace(*form.CompanyID) + } + + activeCompanyID, _ := c.Get("company_id").(string) + assignedCompanyIDs, _ := c.Get("company_ids").([]string) + companyID, err := customer.PickAssignedCompanyID(activeCompanyID, assignedCompanyIDs, requestedCompanyID) + if err != nil { + if errors.Is(err, customer.ErrCompanyNotAssigned) { + return response.Forbidden(c, "Company is not assigned to customer") + } + return response.BadRequest(c, "Company ID is required", "") } tenderApproval, err := h.service.ToggleTenderApproval(c.Request().Context(), form, companyID) if err != nil { + if errors.Is(err, ErrTenderNotFound) { + return response.NotFound(c, "Tender not found") + } if err.Error() == "tender approval already exists for this tender and company" { return response.Conflict(c, err.Error()) } diff --git a/internal/tender_approval/repository.go b/internal/tender_approval/repository.go index 8c4a4be..cad62a7 100644 --- a/internal/tender_approval/repository.go +++ b/internal/tender_approval/repository.go @@ -104,7 +104,7 @@ func (r *tenderApprovalRepository) GetByID(ctx context.Context, id string) (*Ten tenderApproval, err := r.ormRepo.FindByID(ctx, id) if err != nil { if errors.Is(err, mongo.ErrDocumentNotFound) { - return nil, errors.New("tender approval not found") + return nil, ErrTenderApprovalNotFound } r.logger.Error("Failed to get tender approval by ID", map[string]interface{}{ "error": err.Error(), @@ -126,7 +126,7 @@ func (r *tenderApprovalRepository) GetByTenderAndCompany(ctx context.Context, te tenderApproval, err := r.ormRepo.FindOne(ctx, filter) if err != nil { if errors.Is(err, mongo.ErrDocumentNotFound) { - return nil, errors.New("tender approval not found") + return nil, ErrTenderApprovalNotFound } r.logger.Error("Failed to get tender approval by tender and company", map[string]interface{}{ "error": err.Error(), diff --git a/internal/tender_approval/service.go b/internal/tender_approval/service.go index f0e89cf..58c0d77 100644 --- a/internal/tender_approval/service.go +++ b/internal/tender_approval/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math" + "strings" "time" "tm/internal/tender" "tm/pkg/logger" @@ -853,10 +854,34 @@ func (s *tenderApprovalService) ListTenderApprovalsWithTender(ctx context.Contex } func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *ToggleTenderApprovalForm, companyID string) (*TenderApprovalWithTenderResponse, error) { - existing, err := s.repository.GetByTenderAndCompany(ctx, req.TenderID, companyID) + companyID = strings.TrimSpace(companyID) + if companyID == "" { + return nil, ErrInvalidCompanyID + } + + tenderID, err := s.tenderService.ResolveMongoID(ctx, req.TenderID) if err != nil { + s.logger.Warn("Failed to resolve tender for approval toggle", map[string]interface{}{ + "tender_ref": req.TenderID, + "company_id": companyID, + "error": err.Error(), + }) + return nil, ErrTenderNotFound + } + + existing, err := s.repository.GetByTenderAndCompany(ctx, tenderID, companyID) + if err != nil { + if !errors.Is(err, ErrTenderApprovalNotFound) { + s.logger.Error("Failed to load tender approval for toggle", map[string]interface{}{ + "tender_id": tenderID, + "company_id": companyID, + "error": err.Error(), + }) + return nil, err + } + approval := &TenderApproval{ - TenderID: req.TenderID, + TenderID: tenderID, CompanyID: companyID, SubmissionMode: req.SubmissionMode, Status: req.Status, @@ -865,7 +890,7 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T err = s.repository.Create(ctx, approval) if err != nil { s.logger.Error("Failed to create new tender approval", map[string]interface{}{ - "tender_id": req.TenderID, + "tender_id": tenderID, "company_id": companyID, "status": req.Status, "submission_mode": req.SubmissionMode, @@ -874,13 +899,13 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T return nil, err } - return approval.ToResponseWithTender(nil), nil + return s.toggleResponseWithTender(ctx, approval) } if existing.Status == req.Status { err = s.repository.Delete(ctx, existing.GetID()) if err != nil { - s.logger.Error("Failed to update tender approval", map[string]interface{}{ + s.logger.Error("Failed to delete tender approval during toggle", map[string]interface{}{ "error": err.Error(), }) return nil, err @@ -892,24 +917,17 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T existing.Status = "unrejected" } - return existing.ToResponseWithTender(nil), nil - } else if existing.Status == req.Status && existing.SubmissionMode != req.SubmissionMode { - existing.SubmissionMode = req.SubmissionMode - err = s.repository.Update(ctx, existing) - if err != nil { - s.logger.Error("Failed to update tender approval", map[string]interface{}{ - "error": err.Error(), - }) - return nil, err - } - return existing.ToResponseWithTender(nil), nil } - if existing.Status == ApprovalStatusSubmitted { + if existing.Status == ApprovalStatusRejected && req.Status == ApprovalStatusSubmitted { + existing.Submit() + existing.SubmissionMode = req.SubmissionMode + } else if existing.Status == ApprovalStatusSubmitted && req.Status == ApprovalStatusRejected { existing.Reject() } else { - existing.Submit() + existing.Status = req.Status + existing.SubmissionMode = req.SubmissionMode } err = s.repository.Update(ctx, existing) @@ -920,7 +938,20 @@ func (s *tenderApprovalService) ToggleTenderApproval(ctx context.Context, req *T return nil, err } - return existing.ToResponseWithTender(nil), nil + return s.toggleResponseWithTender(ctx, existing) +} + +func (s *tenderApprovalService) toggleResponseWithTender(ctx context.Context, approval *TenderApproval) (*TenderApprovalWithTenderResponse, error) { + if approval == nil { + return nil, ErrInvalidData + } + + tenderResponse, err := s.tenderService.GetByID(ctx, approval.TenderID, "") + if err != nil { + return approval.ToResponseWithTender(nil), nil + } + + return approval.ToResponseWithTender(listTenderDetailsFromResponse(tenderResponse)), nil } // GetCompanyTenderApprovalStats returns company-specific tender approval statistics