Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions api/handle_cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params); 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
}
}
}
1 change: 1 addition & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions dto/case_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
25 changes: 25 additions & 0 deletions models/case.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
116 changes: 116 additions & 0 deletions repositories/case_mass_update_repository.go
Original file line number Diff line number Diff line change
@@ -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
}
159 changes: 159 additions & 0 deletions usecases/case_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking out loud: I think it's correct to make this check in the backend and let the endpoint accept cases from different inboxes, however I think we may want to restrict the action of (re)assigning cases in batch to the "inbox" view, meaning not allow in the "my cases" view, because doing so would create a level of headaches related to selection of eligible users and error handling that is beyond the scope of what we planned to do, cc @ChibiBlasphem

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
})
}
Loading