Skip to content

Commit

Permalink
Implemented case snoozes.
Browse files Browse the repository at this point in the history
A case can now be snoozed, either until a specified date and time, or until a new decision is added to the case, whichever happens first.

A new filter can be provided when listing cases, `include_snoozed`, to keep displaying snoozed cases.
  • Loading branch information
apognu committed Mar 5, 2025
1 parent 9f5790d commit 4f26438
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 7 deletions.
57 changes: 57 additions & 0 deletions api/handle_cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"mime/multipart"
"net/http"
"time"

"github.com/cockroachdb/errors"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -155,6 +156,62 @@ func handlePatchCase(uc usecases.Usecases) func(c *gin.Context) {
}
}

type CaseSnoozeParams struct {
Until time.Time `json:"until"`
}

func handleSnoozeCase(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
caseId := c.Param("case_id")

var params CaseSnoozeParams

if err := c.ShouldBindBodyWithJSON(&params); err != nil {
presentError(ctx, c, err)
return
}

if params.Until.Before(time.Now()) {
presentError(ctx, c, errors.Wrap(models.BadParameterError,
"a case cannot only be snoozed until a future date"))
return
}

uc := usecasesWithCreds(ctx, uc)
caseUsecase := uc.NewCaseUseCase()

req := models.CaseSnoozeRequest{
CaseId: caseId,
Until: params.Until,
}

if err := caseUsecase.Snooze(ctx, req); err != nil {
presentError(ctx, c, err)
return
}

c.Status(http.StatusNoContent)
}
}

func handleUnsnoozeCase(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
caseId := c.Param("case_id")

uc := usecasesWithCreds(ctx, uc)
caseUsecase := uc.NewCaseUseCase()

if err := caseUsecase.Unsnooze(ctx, caseId); err != nil {
presentError(ctx, c, err)
return
}

c.Status(http.StatusNoContent)
}
}

func handlePostCaseDecisions(uc usecases.Usecases) func(c *gin.Context) {
return func(c *gin.Context) {
ctx := c.Request.Context()
Expand Down
2 changes: 2 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ func addRoutes(r *gin.Engine, conf Configuration, uc usecases.Usecases, auth Aut
router.GET("/cases", tom, handleListCases(uc))
router.POST("/cases", tom, handlePostCase(uc))
router.GET("/cases/:case_id", tom, handleGetCase(uc))
router.POST("/cases/:case_id/snooze", tom, handleSnoozeCase(uc))
router.POST("/cases/:case_id/unsnooze", tom, handleUnsnoozeCase(uc))
router.PATCH("/cases/:case_id", tom, handlePatchCase(uc))
router.POST("/cases/:case_id/decisions", tom, handlePostCaseDecisions(uc))
router.POST("/cases/:case_id/comments", tom, handlePostCaseComment(uc))
Expand Down
20 changes: 14 additions & 6 deletions dto/case_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type APICase struct {
Status string `json:"status"`
Tags []APICaseTag `json:"tags"`
Files []APICaseFile `json:"files"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
}

type APICaseWithDecisions struct {
Expand All @@ -26,7 +27,7 @@ type APICaseWithDecisions struct {
}

func AdaptCaseDto(c models.Case) APICase {
return APICase{
dto := APICase{
Id: c.Id,
Contributors: pure_utils.Map(c.Contributors, NewAPICaseContributor),
CreatedAt: c.CreatedAt,
Expand All @@ -38,6 +39,12 @@ func AdaptCaseDto(c models.Case) APICase {
Tags: pure_utils.Map(c.Tags, NewAPICaseTag),
Files: pure_utils.Map(c.Files, NewAPICaseFile),
}

if c.SnoozedUntil != nil && c.SnoozedUntil.After(time.Now()) {
dto.SnoozedUntil = c.SnoozedUntil
}

return dto
}

type CastListPage struct {
Expand Down Expand Up @@ -86,11 +93,12 @@ type CreateCaseCommentBody struct {
}

type CaseFilters struct {
EndDate time.Time `form:"end_date"`
InboxIds []string `form:"inbox_id[]"`
StartDate time.Time `form:"start_date"`
Statuses []string `form:"status[]"`
Name string `form:"name"`
EndDate time.Time `form:"end_date"`
InboxIds []string `form:"inbox_id[]"`
StartDate time.Time `form:"start_date"`
Statuses []string `form:"status[]"`
Name string `form:"name"`
IncludeSnoozed bool `form:"include_snoozed"`
}

type ReviewCaseDecisionsBody struct {
Expand Down
7 changes: 7 additions & 0 deletions models/case.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Case struct {
Status CaseStatus
Tags []CaseTag
Files []CaseFile
SnoozedUntil *time.Time
}

func (c Case) GetMetadata() CaseMetadata {
Expand Down Expand Up @@ -76,6 +77,7 @@ type CaseFilters struct {
EndDate time.Time
Statuses []CaseStatus
InboxIds []string
IncludeSnoozed bool
}

type CaseListPage struct {
Expand Down Expand Up @@ -109,3 +111,8 @@ type ReviewCaseDecisionsBody struct {
ReviewStatus string
UserId string
}

type CaseSnoozeRequest struct {
CaseId string
Until time.Time
}
33 changes: 33 additions & 0 deletions repositories/case_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/Masterminds/squirrel"
"github.com/cockroachdb/errors"
Expand Down Expand Up @@ -145,6 +146,32 @@ func (repo *MarbleDbRepository) UpdateCase(ctx context.Context, exec Executor, u
return err
}

func (repo *MarbleDbRepository) SnoozeCase(ctx context.Context, exec Executor, snoozeRequest models.CaseSnoozeRequest) error {
if err := validateMarbleDbExecutor(exec); err != nil {
return err
}

sql := NewQueryBuilder().
Update(dbmodels.TABLE_CASES).
Set("snoozed_until", snoozeRequest.Until).
Where(squirrel.Eq{"id": snoozeRequest.CaseId})

return ExecBuilder(ctx, exec, sql)
}

func (repo *MarbleDbRepository) UnsnoozeCase(ctx context.Context, exec Executor, caseId string) error {
if err := validateMarbleDbExecutor(exec); err != nil {
return err
}

sql := NewQueryBuilder().
Update(dbmodels.TABLE_CASES).
Set("snoozed_until", nil).
Where(squirrel.Eq{"id": caseId})

return ExecBuilder(ctx, exec, sql)
}

func (repo *MarbleDbRepository) CreateCaseTag(ctx context.Context, exec Executor, caseId, tagId string) error {
if err := validateMarbleDbExecutor(exec); err != nil {
return err
Expand Down Expand Up @@ -247,6 +274,12 @@ func applyCaseFilters(query squirrel.SelectBuilder, filters models.CaseFilters)
if filters.Name != "" {
query = query.Where("c.name % ?", filters.Name)
}
if !filters.IncludeSnoozed {
query = query.Where(squirrel.Or{
squirrel.Eq{"snoozed_until": nil},
squirrel.LtOrEq{"snoozed_until": time.Now()},
})
}
return query
}

Expand Down
6 changes: 5 additions & 1 deletion repositories/dbmodels/db_case.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package dbmodels

import (
"time"

"github.com/checkmarble/marble-backend/models"
"github.com/jackc/pgx/v5/pgtype"
)
Expand All @@ -12,6 +14,7 @@ type DBCase struct {
Name pgtype.Text `db:"name"`
OrganizationId pgtype.Text `db:"org_id"`
Status pgtype.Text `db:"status"`
SnoozedUntil *time.Time `db:"snoozed_until"`
}

type DBCaseWithContributorsAndTags struct {
Expand All @@ -28,7 +31,7 @@ type DBPaginatedCases struct {

const TABLE_CASES = "cases"

var SelectCaseColumn = []string{"id", "created_at", "inbox_id", "name", "org_id", "status"}
var SelectCaseColumn = []string{"id", "created_at", "inbox_id", "name", "org_id", "status", "snoozed_until"}

func AdaptCase(db DBCase) (models.Case, error) {
return models.Case{
Expand All @@ -38,6 +41,7 @@ func AdaptCase(db DBCase) (models.Case, error) {
Name: db.Name.String,
OrganizationId: db.OrganizationId.String,
Status: models.CaseStatus(db.Status.String),
SnoozedUntil: db.SnoozedUntil,
}, nil
}

Expand Down
9 changes: 9 additions & 0 deletions repositories/migrations/20250305145800_add_case_snoozes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- +goose Up

alter table cases
add column snoozed_until timestamp with time zone null;

-- +goose Down

alter table cases
drop column snoozed_until;
40 changes: 40 additions & 0 deletions usecases/case_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"mime/multipart"
"slices"
"strings"
"time"

"github.com/checkmarble/marble-backend/dto"
"github.com/checkmarble/marble-backend/models"
Expand All @@ -29,6 +30,9 @@ type CaseUseCaseRepository interface {
createCaseAttributes models.CreateCaseAttributes, newCaseId string) error
UpdateCase(ctx context.Context, exec repositories.Executor,
updateCaseAttributes models.UpdateCaseAttributes) error
SnoozeCase(ctx context.Context, exec repositories.Executor, snoozeRequest models.CaseSnoozeRequest) error
UnsnoozeCase(ctx context.Context, exec repositories.Executor,
caseId string) error

CreateCaseEvent(ctx context.Context, exec repositories.Executor,
createCaseEventAttributes models.CreateCaseEventAttributes) error
Expand Down Expand Up @@ -119,6 +123,7 @@ func (usecase *CaseUseCase) ListCases(
Statuses: statuses,
OrganizationId: organizationId,
Name: filters.Name,
IncludeSnoozed: filters.IncludeSnoozed,
}
if len(filters.InboxIds) > 0 {
repoFilters.InboxIds = filters.InboxIds
Expand Down Expand Up @@ -362,6 +367,36 @@ func (usecase *CaseUseCase) UpdateCase(
return updatedCase, nil
}

func (uc *CaseUseCase) Snooze(ctx context.Context, req models.CaseSnoozeRequest) error {
c, err := uc.repository.GetCaseById(ctx, uc.executorFactory.NewExecutor(), req.CaseId)
if err != nil {
return err
}

if err := uc.enforceSecurity.ReadOrUpdateCase(c, []string{c.InboxId}); err != nil {
return err
}

return uc.repository.SnoozeCase(ctx, uc.executorFactory.NewExecutor(), req)
}

func (uc *CaseUseCase) Unsnooze(ctx context.Context, caseId string) error {
c, err := uc.repository.GetCaseById(ctx, uc.executorFactory.NewExecutor(), caseId)
if err != nil {
return err
}

if c.SnoozedUntil == nil || c.SnoozedUntil.Before(time.Now()) {
return errors.Wrap(models.ConflictError, "case is not currently snoozed")
}

if err := uc.enforceSecurity.ReadOrUpdateCase(c, []string{c.InboxId}); err != nil {
return err
}

return uc.repository.UnsnoozeCase(ctx, uc.executorFactory.NewExecutor(), caseId)
}

func isIdenticalCaseUpdate(updateCaseAttributes models.UpdateCaseAttributes, c models.Case) bool {
return (updateCaseAttributes.Name == "" || updateCaseAttributes.Name == c.Name) &&
(updateCaseAttributes.Status == "" || updateCaseAttributes.Status == c.Status) &&
Expand Down Expand Up @@ -435,6 +470,11 @@ func (usecase *CaseUseCase) AddDecisionsToCase(ctx context.Context, userId, case
return models.Case{}, err
}

err = usecase.repository.UnsnoozeCase(ctx, tx, caseId)
if err != nil {
return models.Case{}, err
}

err = usecase.UpdateDecisionsWithEvents(ctx, tx, caseId, userId, decisionIds)
if err != nil {
return models.Case{}, err
Expand Down

0 comments on commit 4f26438

Please sign in to comment.