diff --git a/api/handle_cases.go b/api/handle_cases.go index ded97279b..2f77e91ff 100644 --- a/api/handle_cases.go +++ b/api/handle_cases.go @@ -741,3 +741,22 @@ func handleEnrichCasePivotObjects(uc usecases.Usecases) func(c *gin.Context) { c.JSON(http.StatusOK, agent_dto.AdaptKYCEnrichmentResultsDto(responses)) } } + +func handleCaseMassUpdate(uc usecases.Usecases) func(c *gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + + var params dto.CaseMassUpdateDto + + if err := c.ShouldBindJSON(¶ms); presentError(ctx, c, err) { + c.Status(http.StatusBadRequest) + return + } + + uc := usecasesWithCreds(ctx, uc).NewCaseUseCase() + + if err := uc.MassUpdate(ctx, params); presentError(ctx, c, err) { + return + } + } +} diff --git a/api/routes.go b/api/routes.go index 14ef380ea..a7602d795 100644 --- a/api/routes.go +++ b/api/routes.go @@ -246,6 +246,7 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth uti router.GET("/cases", tom, handleListCases(uc)) router.POST("/cases", tom, handlePostCase(uc)) + router.POST("/cases/mass-update", tom, handleCaseMassUpdate(uc)) router.GET("/cases/:case_id", tom, handleGetCase(uc)) router.GET("/cases/:case_id/next", tom, handleGetNextCase(uc)) router.POST("/cases/:case_id/snooze", tom, handleSnoozeCase(uc)) diff --git a/dto/case_dto.go b/dto/case_dto.go index f26541eda..73822622e 100644 --- a/dto/case_dto.go +++ b/dto/case_dto.go @@ -155,3 +155,18 @@ type CaseDecisionListPaginationDto struct { HasMore bool `json:"has_more"` NextCursorId string `json:"next_cursor_id"` } + +type CaseMassUpdateDto struct { + Action string `json:"action" binding:"required,oneof=close reopen assign move_to_inbox"` + CaseIds []uuid.UUID `json:"case_ids" binding:"required,dive,uuid"` + Assign *CaseMassUpdateAssignDto `json:"assign" binding:"required_if=Action assign"` + MoveToInbox *CaseMassUpdateMoveToInbox `json:"move_to_inbox" binding:"required_if=Action move_to_inbox"` +} + +type CaseMassUpdateAssignDto struct { + AssigneeId uuid.UUID `json:"assignee_id" binding:"required"` +} + +type CaseMassUpdateMoveToInbox struct { + InboxId uuid.UUID `json:"inbox_id" binding:"required"` +} diff --git a/models/case.go b/models/case.go index 97dfcd7a5..49a30a47b 100644 --- a/models/case.go +++ b/models/case.go @@ -202,3 +202,28 @@ type CaseDecisionsRequest struct { CursorId string Limit int } + +type CaseMassUpdateAction string + +const ( + CaseMassUpdateClose CaseMassUpdateAction = "close" + CaseMassUpdateReopen = "reopen" + CaseMassUpdateAssign = "assign" + CaseMassUpdateMoveToInbox = "move_to_inbox" + CaseMassUpdateUnknown = "unknown" +) + +func CaseMassUpdateActionFromString(s string) CaseMassUpdateAction { + switch s { + case "close": + return CaseMassUpdateClose + case "reopen": + return CaseMassUpdateReopen + case "assign": + return CaseMassUpdateAssign + case "move_to_inbox": + return CaseMassUpdateMoveToInbox + default: + return CaseMassUpdateUnknown + } +} diff --git a/repositories/case_mass_update_repository.go b/repositories/case_mass_update_repository.go new file mode 100644 index 000000000..bda625cab --- /dev/null +++ b/repositories/case_mass_update_repository.go @@ -0,0 +1,116 @@ +package repositories + +import ( + "context" + + "github.com/Masterminds/squirrel" + "github.com/checkmarble/marble-backend/models" + "github.com/checkmarble/marble-backend/repositories/dbmodels" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (repo *MarbleDbRepository) GetMassCasesByIds(ctx context.Context, exec Executor, caseIds []uuid.UUID) ([]models.Case, error) { + if err := validateMarbleDbExecutor(exec); err != nil { + return nil, err + } + + query := NewQueryBuilder(). + Select(dbmodels.SelectCaseColumn...). + From(dbmodels.TABLE_CASES). + Where(squirrel.Eq{"id": caseIds}) + + return SqlToListOfModels(ctx, exec, query, dbmodels.AdaptCase) +} + +func (repo *MarbleDbRepository) CaseMassChangeStatus(ctx context.Context, tx Transaction, caseIds []uuid.UUID, status models.CaseStatus) ([]uuid.UUID, error) { + if err := validateMarbleDbExecutor(tx); err != nil { + return nil, err + } + + query := NewQueryBuilder(). + Update(dbmodels.TABLE_CASES). + Set("status", status). + Set("boost", nil). + Where(squirrel.And{ + squirrel.Eq{"id": caseIds}, + squirrel.NotEq{"status": status}, + }). + Suffix("returning id") + + return caseMassUpdateExecAndReturnedChanged(ctx, tx, query) +} + +func (repo *MarbleDbRepository) CaseMassAssign(ctx context.Context, tx Transaction, caseIds []uuid.UUID, assigneeId uuid.UUID) ([]uuid.UUID, error) { + if err := validateMarbleDbExecutor(tx); err != nil { + return nil, err + } + + query := NewQueryBuilder(). + Update(dbmodels.TABLE_CASES). + Set("assigned_to", assigneeId). + Set("boost", nil). + Where(squirrel.And{ + squirrel.Eq{"id": caseIds}, + squirrel.Or{ + squirrel.Eq{"assigned_to": nil}, + squirrel.NotEq{"assigned_to": assigneeId}, + }, + }). + Suffix("returning id") + + return caseMassUpdateExecAndReturnedChanged(ctx, tx, query) +} + +func (repo *MarbleDbRepository) CaseMassMoveToInbox(ctx context.Context, tx Transaction, caseIds []uuid.UUID, inboxId uuid.UUID) ([]uuid.UUID, error) { + if err := validateMarbleDbExecutor(tx); err != nil { + return nil, err + } + + query := NewQueryBuilder(). + Update(dbmodels.TABLE_CASES). + Set("inbox_id", inboxId). + Set("boost", nil). + Where(squirrel.And{ + squirrel.Eq{"id": caseIds}, + squirrel.Or{ + squirrel.Eq{"inbox_id": nil}, + squirrel.NotEq{"inbox_id": inboxId}, + }, + }). + Suffix("returning id") + + return caseMassUpdateExecAndReturnedChanged(ctx, tx, query) +} + +func caseMassUpdateExecAndReturnedChanged(ctx context.Context, tx Transaction, query squirrel.UpdateBuilder) ([]uuid.UUID, error) { + sql, args, err := query.ToSql() + + if err != nil { + return nil, err + } + + rows, err := tx.Query(ctx, sql, args...) + + if err != nil { + return nil, err + } + + defer rows.Close() + + var tmp uuid.UUID + + ids, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (uuid.UUID, error) { + if err := row.Scan(&tmp); err != nil { + return uuid.Nil, err + } + + return tmp, nil + }) + + if err != nil { + return nil, err + } + + return ids, nil +} diff --git a/usecases/case_usecase.go b/usecases/case_usecase.go index 8876b27bd..f8cae3410 100644 --- a/usecases/case_usecase.go +++ b/usecases/case_usecase.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "io" + "maps" "mime/multipart" "slices" "strings" "time" + "github.com/checkmarble/marble-backend/dto" "github.com/checkmarble/marble-backend/models" "github.com/checkmarble/marble-backend/pure_utils" "github.com/checkmarble/marble-backend/repositories" @@ -69,6 +71,11 @@ type CaseUseCaseRepository interface { GetNextCase(ctx context.Context, exec repositories.Executor, c models.Case) (string, error) UserById(ctx context.Context, exec repositories.Executor, userId string) (models.User, error) + + GetMassCasesByIds(ctx context.Context, exec repositories.Executor, caseIds []uuid.UUID) ([]models.Case, error) + CaseMassChangeStatus(ctx context.Context, tx repositories.Transaction, caseIds []uuid.UUID, status models.CaseStatus) ([]uuid.UUID, error) + CaseMassAssign(ctx context.Context, tx repositories.Transaction, caseIds []uuid.UUID, assigneeId uuid.UUID) ([]uuid.UUID, error) + CaseMassMoveToInbox(ctx context.Context, tx repositories.Transaction, caseIds []uuid.UUID, inboxId uuid.UUID) ([]uuid.UUID, error) } type CaseUsecaseScreeningRepository interface { @@ -1663,3 +1670,155 @@ func (uc *CaseUseCase) triggerAutoAssignment(ctx context.Context, tx repositorie return nil } + +func (uc *CaseUseCase) MassUpdate(ctx context.Context, req dto.CaseMassUpdateDto) error { + exec := uc.executorFactory.NewExecutor() + orgId := uc.enforceSecurity.OrgId() + userId := uc.enforceSecurity.UserId() + + sourceCases := make(map[string]models.Case, len(req.CaseIds)) + events := make(map[string]models.CreateCaseEventAttributes, len(req.CaseIds)) + + var newAssignee models.User + + cases, err := uc.repository.GetMassCasesByIds(ctx, exec, req.CaseIds) + if err != nil { + return errors.Wrap(err, "could not retrieve requested cases for mass update") + } + + if len(cases) != len(req.CaseIds) { + return errors.New("some requested cases for mass update do not exist") + } + + casesMap := pure_utils.MapSliceToMap(cases, func(c models.Case) (string, models.Case) { + return c.Id, c + }) + + availableInboxIds, err := uc.getAvailableInboxIds(ctx, exec, orgId) + if err != nil { + return errors.Wrap(err, "could not retrieve available inboxes") + } + + if req.Action == models.CaseMassUpdateAssign { + var err error + + newAssignee, err = uc.repository.UserById(ctx, exec, req.Assign.AssigneeId.String()) + if err != nil { + return errors.Wrap(err, "target user for assignment not found") + } + } + + // For all cases in the mass update, we need to check the current user can manage them. + for _, caseId := range req.CaseIds { + c, ok := casesMap[caseId.String()] + if !ok { + return errors.Newf("requested cases '%s' for mass update does not exist", caseId) + } + + if err := uc.enforceSecurity.ReadOrUpdateCase(c.GetMetadata(), availableInboxIds); err != nil { + return err + } + + // If we are trying to mass-assign, we need to check, for each case, that the target user can manage the case. + if req.Action == models.CaseMassUpdateAssign { + if err := security.EnforceSecurityCaseForUser(newAssignee).ReadOrUpdateCase(c.GetMetadata(), availableInboxIds); err != nil { + return errors.Wrap(err, "target user lacks case permissions for assignment") + } + } + + sourceCases[c.Id] = c + } + + // When changing the cases' inboxes, the user needs to have access to the target inbox. + if req.Action == models.CaseMassUpdateMoveToInbox { + if _, err := uc.inboxReader.GetInboxById(ctx, req.MoveToInbox.InboxId); err != nil { + return errors.Wrap(err, fmt.Sprintf("user does not have access the new inbox %s", req.MoveToInbox.InboxId)) + } + } + + return uc.transactionFactory.Transaction(ctx, func(tx repositories.Transaction) error { + switch models.CaseMassUpdateActionFromString(req.Action) { + case models.CaseMassUpdateClose: + updatedIds, err := uc.repository.CaseMassChangeStatus(ctx, tx, req.CaseIds, models.CaseClosed) + if err != nil { + return errors.Wrap(err, "could not update case status in mass update") + } + + for _, updatedId := range updatedIds { + events[updatedId.String()] = models.CreateCaseEventAttributes{ + UserId: userId, + CaseId: updatedId.String(), + EventType: models.CaseStatusUpdated, + PreviousValue: utils.Ptr(string(sourceCases[updatedId.String()].Status)), + NewValue: utils.Ptr(string(models.CaseClosed)), + } + } + + case models.CaseMassUpdateReopen: + updatedIds, err := uc.repository.CaseMassChangeStatus(ctx, tx, req.CaseIds, models.CasePending) + + if err != nil { + return errors.Wrap(err, "could not updaet case status in mass update") + } + + for _, updatedId := range updatedIds { + events[updatedId.String()] = models.CreateCaseEventAttributes{ + UserId: userId, + CaseId: updatedId.String(), + EventType: models.CaseStatusUpdated, + PreviousValue: utils.Ptr(string(sourceCases[updatedId.String()].Status)), + NewValue: utils.Ptr(string(models.CasePending)), + } + } + + case models.CaseMassUpdateAssign: + updatedIds, err := uc.repository.CaseMassAssign(ctx, tx, req.CaseIds, req.Assign.AssigneeId) + + if err != nil { + return errors.Wrap(err, "could not assign cases in mass update") + } + + for _, updatedId := range updatedIds { + events[updatedId.String()] = models.CreateCaseEventAttributes{ + UserId: userId, + CaseId: updatedId.String(), + EventType: models.CaseAssigned, + PreviousValue: (*string)(sourceCases[updatedId.String()].AssignedTo), + NewValue: utils.Ptr(req.Assign.AssigneeId.String()), + } + } + + case models.CaseMassUpdateMoveToInbox: + updatedIds, err := uc.repository.CaseMassMoveToInbox(ctx, tx, req.CaseIds, req.MoveToInbox.InboxId) + + if err != nil { + return errors.Wrap(err, "could not change case inbox in mass update") + } + + for _, updatedId := range updatedIds { + events[updatedId.String()] = models.CreateCaseEventAttributes{ + UserId: userId, + CaseId: updatedId.String(), + EventType: models.CaseInboxChanged, + PreviousValue: utils.Ptr(sourceCases[updatedId.String()].InboxId.String()), + NewValue: utils.Ptr(req.MoveToInbox.InboxId.String()), + } + } + + default: + return errors.Newf("unknown case mass update action %s", req.Action) + } + + if len(events) == 0 { + return nil + } + + // TODO: perform relevant side effects + + if err := uc.repository.BatchCreateCaseEvents(ctx, tx, slices.Collect(maps.Values(events))); err != nil { + return errors.Wrap(err, "could not create case events in mass update") + } + + return nil + }) +}