diff --git a/entity-service/internal/domain/entity.go b/entity-service/internal/domain/entity.go index b7c4b10ebc..64b502742a 100644 --- a/entity-service/internal/domain/entity.go +++ b/entity-service/internal/domain/entity.go @@ -799,24 +799,24 @@ type CaseView struct { // SearchCasesFilters holds all optional filter criteria for a case search. type SearchCasesFilters struct { - Types []string `json:"types"` - SearchQuery string `json:"searchQuery"` - ProjectIDs []string `json:"projectIds"` - DeploymentIDs []string `json:"deploymentIds"` - States []CaseState `json:"states"` - Severities []CaseSeverity `json:"severities"` - IssueTypes []CaseIssueType `json:"issueTypes"` - EngagementTypes []EngagementType `json:"engagementTypes"` - ClosedStartDate *time.Time `json:"closedStartDate"` - ClosedEndDate *time.Time `json:"closedEndDate"` - StartCreatedDate *time.Time `json:"startCreatedDate"` - EndCreatedDate *time.Time `json:"endCreatedDate"` - StartUpdatedDate *time.Time `json:"startUpdatedDate"` - EndUpdatedDate *time.Time `json:"endUpdatedDate"` - CreatedBy []string `json:"createdBy"` - CreatedByMe bool `json:"createdByMe"` - WorkStates []CaseWorkState `json:"workStates"` - AssignedUserIDs []string `json:"assignedUserIds"` + Types []string `json:"types"` + SearchQuery string `json:"searchQuery"` + ProjectIDs []string `json:"projectIds"` + DeploymentIDs []string `json:"deploymentIds"` + States []CaseState `json:"states"` + Severities []CaseSeverity `json:"severities"` + IssueTypes []CaseIssueType `json:"issueTypes"` + EngagementTypes []EngagementType `json:"engagementTypes"` + ClosedStartDate *time.Time `json:"closedStartDate"` + ClosedEndDate *time.Time `json:"closedEndDate"` + StartCreatedDate *time.Time `json:"startCreatedDate"` + EndCreatedDate *time.Time `json:"endCreatedDate"` + StartUpdatedDate *time.Time `json:"startUpdatedDate"` + EndUpdatedDate *time.Time `json:"endUpdatedDate"` + CreatedBy []string `json:"createdBy"` + CreatedByMe bool `json:"createdByMe"` + WorkStates []CaseWorkState `json:"workStates"` + AssignedUserIDs []string `json:"assignedUserIds"` } // SearchCasesRequest is the input for a case search operation. @@ -864,8 +864,6 @@ type SearchCasesResponse struct { Limit int `json:"limit"` } - - // UpdateCaseRequest is the input for PATCH /cases/{id}. // Exactly one of State, Severity, WorkState, WatchList, or AssigneeEmail must be provided. // WatchList and AssigneeEmail are only supported for the ServiceNow data source. @@ -939,25 +937,25 @@ type CaseAttachment struct { // For type "service_request": catalogId, catalogItemId, and variables are required. // For type "security_report_analysis": subject, description, and at least one attachment are required. type CreateCaseRequest struct { - CreatedBy string `json:"-"` - Type string `json:"type"` - ProjectID string `json:"projectId"` - DeploymentID string `json:"deploymentId"` - DeployedProductID string `json:"deployedProductId"` - Subject string `json:"subject"` - Description string `json:"description"` - Severity CaseSeverity `json:"severity"` - IssueType CaseIssueType `json:"issueType"` + CreatedBy string `json:"-"` + Type string `json:"type"` + ProjectID string `json:"projectId"` + DeploymentID string `json:"deploymentId"` + DeployedProductID string `json:"deployedProductId"` + Subject string `json:"subject"` + Description string `json:"description"` + Severity CaseSeverity `json:"severity"` + IssueType CaseIssueType `json:"issueType"` // For service_request type - CatalogID string `json:"catalogId"` - CatalogItemID string `json:"catalogItemId"` - Variables []Variable `json:"variables"` + CatalogID string `json:"catalogId"` + CatalogItemID string `json:"catalogItemId"` + Variables []Variable `json:"variables"` // Optional fields - RelatedCaseID string `json:"relatedCaseId"` - ConversationID string `json:"conversationId"` - WatchList []string `json:"watchList"` + RelatedCaseID string `json:"relatedCaseId"` + ConversationID string `json:"conversationId"` + WatchList []string `json:"watchList"` // For security_report_analysis type - Attachments []CaseAttachment `json:"attachments"` + Attachments []CaseAttachment `json:"attachments"` } // CommentType classifies the type of a case comment. @@ -1012,9 +1010,9 @@ type CaseCommentDetail struct { // SearchCaseCommentsRequest is the input for listing comments on a case. // CaseID is populated from the URL path parameter and is not part of the JSON body. type SearchCaseCommentsRequest struct { - CaseID string `json:"-"` + CaseID string `json:"-"` Filters *CommentFilters `json:"filters"` - Pagination Pagination `json:"pagination"` + Pagination Pagination `json:"pagination"` } // CommentFilters holds optional filter criteria for searching case comments. @@ -1038,13 +1036,13 @@ type Attachment struct { ReferenceID string `json:"referenceId"` ReferenceType ReferenceType `json:"referenceType"` Name string `json:"name"` - Type string `json:"type"` - SizeBytes int `json:"sizeBytes"` - Description *string `json:"description"` - CreatedBy string `json:"createdBy"` - CreatedOn time.Time `json:"createdOn"` - DownloadURL *string `json:"downloadUrl"` - PreviewURL *string `json:"previewUrl"` + Type string `json:"type"` + SizeBytes int `json:"sizeBytes"` + Description *string `json:"description"` + CreatedBy string `json:"createdBy"` + CreatedOn time.Time `json:"createdOn"` + DownloadURL *string `json:"downloadUrl"` + PreviewURL *string `json:"previewUrl"` } // CreateAttachmentRequest is the input for POST /attachments. @@ -1284,10 +1282,14 @@ const ( // SearchTimeCardsFilters holds optional filter criteria for POST /time-cards/search. type SearchTimeCardsFilters struct { - ProjectIDs []string `json:"projectIds,omitempty"` - StartDate *string `json:"startDate,omitempty"` - EndDate *string `json:"endDate,omitempty"` - States []TimeCardState `json:"states,omitempty"` + ProjectIDs []string `json:"projectIds,omitempty"` + CaseID *string `json:"caseId,omitempty"` + UserID *string `json:"userId,omitempty"` + ApproverID *string `json:"approverId,omitempty"` // eligible approver (SN approver_list) + ApprovedByID *string `json:"approvedById,omitempty"` // who actually approved (SN approved_by) + StartDate *string `json:"startDate,omitempty"` + EndDate *string `json:"endDate,omitempty"` + States []TimeCardState `json:"states,omitempty"` } // SearchTimeCardsRequest is the request body for POST /time-cards/search. @@ -1330,6 +1332,51 @@ type SearchTimeCardsResponse struct { Offset int `json:"offset"` } +// CreateTimeCardRequest is the request body for POST /time-cards. The submitter +// is taken from the authenticated session, never from the payload; the card is +// created in the "submitted" state. approverIds populate the eligible-approver list. +type CreateTimeCardRequest struct { + CaseID string `json:"caseId"` + ProjectID string `json:"projectId"` + Date string `json:"date"` // YYYY-MM-DD + ApproverIDs []string `json:"approverIds"` + IsBillable bool `json:"isBillable"` + IssueComplexity *string `json:"issueComplexity,omitempty"` + WorkLogComment *string `json:"workLogComment,omitempty"` + TimeAnalyzing int `json:"timeAnalyzing"` + TimeSettingUp int `json:"timeSettingUp"` + TimeReproducingDebugging int `json:"timeReproducingDebugging"` + TimeProvidingSolution int `json:"timeProvidingSolution"` + TimePatching int `json:"timePatching"` +} + +// UpdateTimeCardRequest is the request body for PATCH /time-cards/{id}. ID is +// injected from the path. It carries EITHER editable fields (submitter, while the +// card is submitted) OR a state transition: State="approved", or State="rejected" +// with LeadComment. SN enforces authorization (submitter for edits, an eligible +// approver in approver_list for transitions). +type UpdateTimeCardRequest struct { + ID string `json:"-"` + State *TimeCardState `json:"state,omitempty"` + LeadComment *string `json:"leadComment,omitempty"` + Date *string `json:"date,omitempty"` + ApproverIDs []string `json:"approverIds,omitempty"` + IsBillable *bool `json:"isBillable,omitempty"` + IssueComplexity *string `json:"issueComplexity,omitempty"` + WorkLogComment *string `json:"workLogComment,omitempty"` + TimeAnalyzing *int `json:"timeAnalyzing,omitempty"` + TimeSettingUp *int `json:"timeSettingUp,omitempty"` + TimeReproducingDebugging *int `json:"timeReproducingDebugging,omitempty"` + TimeProvidingSolution *int `json:"timeProvidingSolution,omitempty"` + TimePatching *int `json:"timePatching,omitempty"` +} + +// TimeCardMutationResponse is returned by create and update. +type TimeCardMutationResponse struct { + Message string `json:"message,omitempty"` + TimeCard *TimeCardView `json:"timeCard,omitempty"` +} + // ChangeRequest is the full change request detail returned by GET /change-requests/{id}. // It extends SearchChangeRequestView with additional fields. type ChangeRequest struct { diff --git a/entity-service/internal/handler/time_card_handler.go b/entity-service/internal/handler/time_card_handler.go index 3a40ef1a43..6e3c807247 100644 --- a/entity-service/internal/handler/time_card_handler.go +++ b/entity-service/internal/handler/time_card_handler.go @@ -20,6 +20,7 @@ import ( "encoding/json" "net/http" + "github.com/wso2-open-operations/cs-tools/entity-service/internal/apierror" "github.com/wso2-open-operations/cs-tools/entity-service/internal/domain" "github.com/wso2-open-operations/cs-tools/entity-service/internal/service" ) @@ -48,3 +49,40 @@ func (h *TimeCardHandler) SearchTimeCards(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } + +// CreateTimeCard handles POST /time-cards. +func (h *TimeCardHandler) CreateTimeCard(w http.ResponseWriter, r *http.Request) { + var req domain.CreateTimeCardRequest + if !decodeRequest(w, r, &req) { + return + } + resp, err := h.svc.CreateTimeCard(r.Context(), req) + if err != nil { + writeServiceError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) +} + +// UpdateTimeCard handles PATCH /time-cards/{id}. +func (h *TimeCardHandler) UpdateTimeCard(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + apierror.WriteJSON(w, http.StatusBadRequest, "time card ID is required") + return + } + var req domain.UpdateTimeCardRequest + if !decodeRequest(w, r, &req) { + return + } + req.ID = id + resp, err := h.svc.UpdateTimeCard(r.Context(), req) + if err != nil { + writeServiceError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/entity-service/internal/server/routes.go b/entity-service/internal/server/routes.go index 9ea4f82a3a..212f156e74 100644 --- a/entity-service/internal/server/routes.go +++ b/entity-service/internal/server/routes.go @@ -183,6 +183,8 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler { if timeCardHandler != nil { mux.HandleFunc("POST /time-cards/search", timeCardHandler.SearchTimeCards) + mux.HandleFunc("POST /time-cards", timeCardHandler.CreateTimeCard) + mux.HandleFunc("PATCH /time-cards/{id}", timeCardHandler.UpdateTimeCard) } if catalogHandler != nil { @@ -192,7 +194,7 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler { if productVulnerabilityHandler != nil { mux.HandleFunc("POST /products/vulnerabilities/search", productVulnerabilityHandler.SearchProductVulnerabilities) -mux.HandleFunc("GET /products/vulnerabilities/{id}", productVulnerabilityHandler.GetProductVulnerability) + mux.HandleFunc("GET /products/vulnerabilities/{id}", productVulnerabilityHandler.GetProductVulnerability) } return middleware.CorrelationID( diff --git a/entity-service/internal/service/interfaces.go b/entity-service/internal/service/interfaces.go index b62723c2cf..b5ff4faa14 100644 --- a/entity-service/internal/service/interfaces.go +++ b/entity-service/internal/service/interfaces.go @@ -202,8 +202,13 @@ type ChangeRequestService interface { // TimeCardService defines the operations available on the time-cards entity. type TimeCardService interface { // SearchTimeCards returns a paginated list of time cards filtered by optional - // project IDs, date range, and states. + // project IDs, case, user, approver, date range, and states. SearchTimeCards(ctx context.Context, req domain.SearchTimeCardsRequest) (domain.SearchTimeCardsResponse, error) + // CreateTimeCard logs a new time card against a case in the submitted state. + CreateTimeCard(ctx context.Context, req domain.CreateTimeCardRequest) (domain.TimeCardMutationResponse, error) + // UpdateTimeCard edits an editable (submitted) time card, or transitions its + // state (approve/reject) when req.State is set. SN enforces authorization. + UpdateTimeCard(ctx context.Context, req domain.UpdateTimeCardRequest) (domain.TimeCardMutationResponse, error) } // ProductVulnerabilityService defines the operations available on product vulnerabilities. @@ -217,5 +222,4 @@ type ProductVulnerabilityService interface { // GetProductVulnerability returns the detail of a single vulnerability by its UUID. // A NotFoundError is returned if the vulnerability does not exist. GetProductVulnerability(ctx context.Context, id string) (domain.ProductVulnerabilityView, error) - } diff --git a/entity-service/internal/service/sn_time_card_service.go b/entity-service/internal/service/sn_time_card_service.go index 8576cfa4c0..81927f9596 100644 --- a/entity-service/internal/service/sn_time_card_service.go +++ b/entity-service/internal/service/sn_time_card_service.go @@ -64,10 +64,14 @@ type snTimeCardSearchPayload struct { } type snTimeCardFilters struct { - ProjectIDs []string `json:"projectIds,omitempty"` - StartDate string `json:"startDate,omitempty"` - EndDate string `json:"endDate,omitempty"` - States []string `json:"states,omitempty"` + ProjectIDs []string `json:"projectIds,omitempty"` + CaseID string `json:"caseId,omitempty"` + UserID string `json:"userId,omitempty"` + ApproverID string `json:"approverId,omitempty"` + ApprovedByID string `json:"approvedById,omitempty"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` + States []string `json:"states,omitempty"` } type snTimeCardsResponse struct { @@ -78,15 +82,15 @@ type snTimeCardsResponse struct { } type snTimeCard struct { - ID string `json:"id"` - TotalTime float64 `json:"totalTime"` - CreatedOn string `json:"createdOn"` - HasBillable bool `json:"hasBillable"` - State *snTimeCardLabel `json:"state"` - User *snTimeCardRef `json:"user"` - ApprovedBy *snTimeCardRef `json:"approvedBy"` - Project *snTimeCardRef `json:"project"` - Case *snTimeCardCaseRef `json:"case"` + ID string `json:"id"` + TotalTime float64 `json:"totalTime"` + CreatedOn string `json:"createdOn"` + HasBillable bool `json:"hasBillable"` + State *snTimeCardLabel `json:"state"` + User *snTimeCardRef `json:"user"` + ApprovedBy *snTimeCardRef `json:"approvedBy"` + Project *snTimeCardRef `json:"project"` + Case *snTimeCardCaseRef `json:"case"` } type snTimeCardLabel struct { @@ -173,6 +177,26 @@ func (s *snTimeCardService) SearchTimeCards(ctx context.Context, req domain.Sear if err := validateUUIDs("projectIds", req.Filters.ProjectIDs); err != nil { return domain.SearchTimeCardsResponse{}, err } + if req.Filters.CaseID != nil { + if err := validateUUIDs("caseId", []string{*req.Filters.CaseID}); err != nil { + return domain.SearchTimeCardsResponse{}, err + } + } + if req.Filters.UserID != nil { + if err := validateUUIDs("userId", []string{*req.Filters.UserID}); err != nil { + return domain.SearchTimeCardsResponse{}, err + } + } + if req.Filters.ApproverID != nil { + if err := validateUUIDs("approverId", []string{*req.Filters.ApproverID}); err != nil { + return domain.SearchTimeCardsResponse{}, err + } + } + if req.Filters.ApprovedByID != nil { + if err := validateUUIDs("approvedById", []string{*req.Filters.ApprovedByID}); err != nil { + return domain.SearchTimeCardsResponse{}, err + } + } snStates := make([]string, 0, len(req.Filters.States)) for _, state := range req.Filters.States { @@ -183,6 +207,18 @@ func (s *snTimeCardService) SearchTimeCards(ctx context.Context, req domain.Sear ProjectIDs: uuidsToSysids(req.Filters.ProjectIDs), States: snStates, } + if req.Filters.CaseID != nil { + filters.CaseID = uuidToSysid(*req.Filters.CaseID) + } + if req.Filters.UserID != nil { + filters.UserID = uuidToSysid(*req.Filters.UserID) + } + if req.Filters.ApproverID != nil { + filters.ApproverID = uuidToSysid(*req.Filters.ApproverID) + } + if req.Filters.ApprovedByID != nil { + filters.ApprovedByID = uuidToSysid(*req.Filters.ApprovedByID) + } if req.Filters.StartDate != nil { filters.StartDate = *req.Filters.StartDate } @@ -214,3 +250,200 @@ func (s *snTimeCardService) SearchTimeCards(ctx context.Context, req domain.Sear Offset: snResp.Offset, }, nil } + +// --- write path (create / update / approve / reject) --------------------------- + +type snTimeCardCreatePayload struct { + CaseID string `json:"caseId"` + ProjectID string `json:"projectId"` + Date string `json:"date"` + ApproverIDs []string `json:"approverIds"` + IsBillable bool `json:"isBillable"` + IssueComplexity string `json:"issueComplexity,omitempty"` + WorkLogComment string `json:"workLogComment,omitempty"` + TimeAnalyzing int `json:"timeAnalyzing"` + TimeSettingUp int `json:"timeSettingUp"` + TimeReproducingDebugging int `json:"timeReproducingDebugging"` + TimeProvidingSolution int `json:"timeProvidingSolution"` + TimePatching int `json:"timePatching"` +} + +type snTimeCardUpdatePayload struct { + State string `json:"state,omitempty"` + LeadComment *string `json:"leadComment,omitempty"` + Date *string `json:"date,omitempty"` + ApproverIDs []string `json:"approverIds,omitempty"` + IsBillable *bool `json:"isBillable,omitempty"` + IssueComplexity *string `json:"issueComplexity,omitempty"` + WorkLogComment *string `json:"workLogComment,omitempty"` + TimeAnalyzing *int `json:"timeAnalyzing,omitempty"` + TimeSettingUp *int `json:"timeSettingUp,omitempty"` + TimeReproducingDebugging *int `json:"timeReproducingDebugging,omitempty"` + TimeProvidingSolution *int `json:"timeProvidingSolution,omitempty"` + TimePatching *int `json:"timePatching,omitempty"` +} + +type snTimeCardMutationResponse struct { + Message string `json:"message"` + TimeCard *snTimeCard `json:"timeCard"` +} + +func parseTimeCardMutation(raw []byte, op string) (domain.TimeCardMutationResponse, error) { + var snResp snTimeCardMutationResponse + if err := json.Unmarshal(raw, &snResp); err != nil { + return domain.TimeCardMutationResponse{}, fmt.Errorf("sn time cards: parse %s response: %w", op, err) + } + out := domain.TimeCardMutationResponse{Message: snResp.Message} + if snResp.TimeCard != nil { + v := snTimeCardToView(*snResp.TimeCard) + out.TimeCard = &v + } + return out, nil +} + +func nonNegativeMinutes(field string, v int) error { + if v < 0 { + return &apierror.ValidationError{Msg: field + " must not be negative"} + } + return nil +} + +func (s *snTimeCardService) CreateTimeCard(ctx context.Context, req domain.CreateTimeCardRequest) (domain.TimeCardMutationResponse, error) { + token := middleware.UserIDTokenFromContext(ctx) + if token == "" { + return domain.TimeCardMutationResponse{}, &apierror.UnauthorizedError{Msg: "x-user-id-token header is required"} + } + + if req.CaseID == "" { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "caseId is required"} + } + if req.ProjectID == "" { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "projectId is required"} + } + if req.Date == "" { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "date is required"} + } + if len(req.ApproverIDs) == 0 { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "approverIds must not be empty"} + } + if err := validateUUIDs("caseId", []string{req.CaseID}); err != nil { + return domain.TimeCardMutationResponse{}, err + } + if err := validateUUIDs("projectId", []string{req.ProjectID}); err != nil { + return domain.TimeCardMutationResponse{}, err + } + if err := validateUUIDs("approverIds", req.ApproverIDs); err != nil { + return domain.TimeCardMutationResponse{}, err + } + for f, v := range map[string]int{ + "timeAnalyzing": req.TimeAnalyzing, "timeSettingUp": req.TimeSettingUp, + "timeReproducingDebugging": req.TimeReproducingDebugging, + "timeProvidingSolution": req.TimeProvidingSolution, "timePatching": req.TimePatching, + } { + if err := nonNegativeMinutes(f, v); err != nil { + return domain.TimeCardMutationResponse{}, err + } + } + + payload := snTimeCardCreatePayload{ + CaseID: uuidToSysid(req.CaseID), + ProjectID: uuidToSysid(req.ProjectID), + Date: req.Date, + ApproverIDs: uuidsToSysids(req.ApproverIDs), + IsBillable: req.IsBillable, + TimeAnalyzing: req.TimeAnalyzing, + TimeSettingUp: req.TimeSettingUp, + TimeReproducingDebugging: req.TimeReproducingDebugging, + TimeProvidingSolution: req.TimeProvidingSolution, + TimePatching: req.TimePatching, + } + if req.IssueComplexity != nil { + payload.IssueComplexity = *req.IssueComplexity + } + if req.WorkLogComment != nil { + payload.WorkLogComment = *req.WorkLogComment + } + + raw, err := s.client.Post(ctx, "/time-cards", token, payload) + if err != nil { + return domain.TimeCardMutationResponse{}, err + } + return parseTimeCardMutation(raw, "create") +} + +func (s *snTimeCardService) UpdateTimeCard(ctx context.Context, req domain.UpdateTimeCardRequest) (domain.TimeCardMutationResponse, error) { + token := middleware.UserIDTokenFromContext(ctx) + if token == "" { + return domain.TimeCardMutationResponse{}, &apierror.UnauthorizedError{Msg: "x-user-id-token header is required"} + } + if err := validateUUIDs("id", []string{req.ID}); err != nil { + return domain.TimeCardMutationResponse{}, err + } + // Reject no-op and ambiguous updates: the PATCH carries either a state + // transition OR editable fields, never neither and never both (a transition + // ignores field edits downstream, so combining them would silently drop them). + hasEdit := req.Date != nil || req.ApproverIDs != nil || req.IsBillable != nil || + req.IssueComplexity != nil || req.WorkLogComment != nil || + req.TimeAnalyzing != nil || req.TimeSettingUp != nil || + req.TimeReproducingDebugging != nil || req.TimeProvidingSolution != nil || + req.TimePatching != nil + if req.State == nil && !hasEdit { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "no fields to update"} + } + if req.State != nil && hasEdit { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "a state transition cannot be combined with field edits"} + } + if req.State != nil { + if *req.State != domain.TimeCardStateApproved && *req.State != domain.TimeCardStateRejected { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "state must be approved or rejected"} + } + if *req.State == domain.TimeCardStateRejected && + (req.LeadComment == nil || strings.TrimSpace(*req.LeadComment) == "") { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "leadComment is required when rejecting"} + } + } + if req.ApproverIDs != nil { + if len(req.ApproverIDs) == 0 { + return domain.TimeCardMutationResponse{}, &apierror.ValidationError{Msg: "approverIds must not be empty when provided"} + } + if err := validateUUIDs("approverIds", req.ApproverIDs); err != nil { + return domain.TimeCardMutationResponse{}, err + } + } + for f, v := range map[string]*int{ + "timeAnalyzing": req.TimeAnalyzing, "timeSettingUp": req.TimeSettingUp, + "timeReproducingDebugging": req.TimeReproducingDebugging, + "timeProvidingSolution": req.TimeProvidingSolution, "timePatching": req.TimePatching, + } { + if v != nil { + if err := nonNegativeMinutes(f, *v); err != nil { + return domain.TimeCardMutationResponse{}, err + } + } + } + + payload := snTimeCardUpdatePayload{ + LeadComment: req.LeadComment, + Date: req.Date, + IsBillable: req.IsBillable, + IssueComplexity: req.IssueComplexity, + WorkLogComment: req.WorkLogComment, + TimeAnalyzing: req.TimeAnalyzing, + TimeSettingUp: req.TimeSettingUp, + TimeReproducingDebugging: req.TimeReproducingDebugging, + TimeProvidingSolution: req.TimeProvidingSolution, + TimePatching: req.TimePatching, + } + if req.State != nil { + payload.State = snTimeCardStateLabelToSN[*req.State] + } + if req.ApproverIDs != nil { + payload.ApproverIDs = uuidsToSysids(req.ApproverIDs) + } + + raw, err := s.client.Patch(ctx, fmt.Sprintf("/time-cards/%s", uuidToSysid(req.ID)), token, payload) + if err != nil { + return domain.TimeCardMutationResponse{}, err + } + return parseTimeCardMutation(raw, "update") +} diff --git a/entity-service/openapi.yaml b/entity-service/openapi.yaml index ac81ae8a02..8596654efb 100644 --- a/entity-service/openapi.yaml +++ b/entity-service/openapi.yaml @@ -1384,6 +1384,91 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /time-cards: + post: + summary: Create a time card in the submitted state (ServiceNow data source only). + operationId: createTimeCard + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTimeCardRequest' + responses: + "201": + description: The created time card. + content: + application/json: + schema: + $ref: '#/components/schemas/TimeCardMutationResponse' + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "401": + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /time-cards/{id}: + patch: + summary: Update an editable (submitted) time card (ServiceNow data source only). + operationId: updateTimeCard + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTimeCardRequest' + responses: + "200": + description: The updated time card. + content: + application/json: + schema: + $ref: '#/components/schemas/TimeCardMutationResponse' + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "401": + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Time card not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: schemas: Pagination: @@ -3315,6 +3400,22 @@ components: items: type: string format: uuid + caseId: + type: string + format: uuid + description: Return only time cards logged against this case. + userId: + type: string + format: uuid + description: Return only time cards submitted by this user. + approverId: + type: string + format: uuid + description: Return only time cards this user is eligible to approve (SN approver_list). + approvedById: + type: string + format: uuid + description: Return only time cards actually approved by this user (SN approved_by). startDate: type: string description: ISO 8601 date (YYYY-MM-DD). @@ -3402,6 +3503,90 @@ components: offset: type: integer + CreateTimeCardRequest: + type: object + required: [caseId, projectId, date, approverIds] + properties: + caseId: + type: string + format: uuid + projectId: + type: string + format: uuid + date: + type: string + description: ISO 8601 date (YYYY-MM-DD). + approverIds: + type: array + items: + type: string + format: uuid + description: Eligible approvers (approver_list). Must be non-empty. + isBillable: + type: boolean + issueComplexity: + type: string + workLogComment: + type: string + timeAnalyzing: + type: integer + timeSettingUp: + type: integer + timeReproducingDebugging: + type: integer + timeProvidingSolution: + type: integer + timePatching: + type: integer + + UpdateTimeCardRequest: + type: object + minProperties: 1 + description: > + Carries either editable fields (submitter, while the card is submitted), + or a state transition: state=approved, or state=rejected with leadComment. + The two are mutually exclusive and an empty body is rejected (400); the + entity service enforces this and authorization. + properties: + state: + type: string + enum: [approved, rejected] + leadComment: + type: string + description: Required when state is rejected. + date: + type: string + description: ISO 8601 date (YYYY-MM-DD). + approverIds: + type: array + items: + type: string + format: uuid + isBillable: + type: boolean + issueComplexity: + type: string + workLogComment: + type: string + timeAnalyzing: + type: integer + timeSettingUp: + type: integer + timeReproducingDebugging: + type: integer + timeProvidingSolution: + type: integer + timePatching: + type: integer + + TimeCardMutationResponse: + type: object + properties: + message: + type: string + timeCard: + $ref: '#/components/schemas/TimeCardView' + VulnerabilityPriority: type: string enum: