diff --git a/cmd/stufflog3-local/main.go b/cmd/stufflog3-local/main.go index 8b8f040..e3e9abd 100644 --- a/cmd/stufflog3-local/main.go +++ b/cmd/stufflog3-local/main.go @@ -57,9 +57,11 @@ func main() { Repository: db.Stats(), } itemsService := &items.Service{ - Scopes: scopesService, - Stats: statsService, - Repository: db.Items(), + Auth: authService, + Scopes: scopesService, + Stats: statsService, + Repository: db.Items(), + RequirementFetcher: db.Projects(), } projectsService := &projects.Service{ Auth: authService, diff --git a/entities/item.go b/entities/item.go index 46664cf..b94c837 100644 --- a/entities/item.go +++ b/entities/item.go @@ -18,7 +18,7 @@ type Item struct { ScheduledDate *models.Date `json:"scheduledDate"` } -type ItemProgress struct { +type ItemStat struct { ItemID int `json:"itemId"` StatID int `json:"statId"` Acquired int `json:"acquired"` diff --git a/models/date.go b/models/date.go index ae7bfa9..2a98439 100644 --- a/models/date.go +++ b/models/date.go @@ -38,6 +38,14 @@ func ParseDate(s string) (Date, error) { return Date{y, int(m), d}, nil } +func (d Date) String() string { + return fmt.Sprintf("%04d-%02d-%02d", d[0], d[1], d[2]) +} + +func (d Date) ToTime() time.Time { + return time.Date(d[0], time.Month(d[1]), d[2], 0, 0, 0, 0, time.UTC) +} + func (d *Date) UnmarshalJSON(b []byte) error { var str string err := json.Unmarshal(b, &str) @@ -53,14 +61,6 @@ func (d *Date) UnmarshalJSON(b []byte) error { return nil } -func (d Date) String() string { - return fmt.Sprintf("%04d-%02d-%02d", d[0], d[1], d[2]) -} - func (d Date) MarshalJSON() ([]byte, error) { return []byte("\"" + d.String() + "\""), nil } - -func (d Date) ToTime() time.Time { - return time.Date(d[0], time.Month(d[1]), d[2], 0, 0, 0, 0, time.UTC) -} diff --git a/ports/httpapi/items.go b/ports/httpapi/items.go index eeb2df1..fab4d1b 100644 --- a/ports/httpapi/items.go +++ b/ports/httpapi/items.go @@ -2,6 +2,7 @@ package httpapi import ( "fmt" + "git.aiterp.net/stufflog3/stufflog3/entities" "git.aiterp.net/stufflog3/stufflog3/models" "git.aiterp.net/stufflog3/stufflog3/usecases/items" "github.com/gin-gonic/gin" @@ -11,6 +12,15 @@ import ( ) func Items(g *gin.RouterGroup, items *items.Service) { + g.GET("/:item_id", handler("project", func(c *gin.Context) (interface{}, error) { + id, err := reqInt(c, "item_id") + if err != nil { + return nil, err + } + + return items.Find(c.Request.Context(), id) + })) + g.GET("", handler("items", func(c *gin.Context) (interface{}, error) { filter := models.ItemFilter{} @@ -172,4 +182,84 @@ func Items(g *gin.RouterGroup, items *items.Service) { return items.ListScoped(c.Request.Context(), filter) })) + + g.POST("", handler("item", func(c *gin.Context) (interface{}, error) { + input := struct { + entities.Item + Stats []entities.ItemStat `json:"stats"` + }{} + err := c.BindJSON(&input) + if err != nil { + return nil, models.BadInputError{ + Object: "Project", + Problem: "Invalid JSON: " + err.Error(), + } + } + + return items.Create(c.Request.Context(), input.Item, input.Stats) + })) + + g.PUT("/:item_id", handler("item", func(c *gin.Context) (interface{}, error) { + input := models.ItemUpdate{} + err := c.BindJSON(&input) + if err != nil { + return nil, models.BadInputError{ + Object: "Project", + Problem: "Invalid JSON: " + err.Error(), + } + } + + id, err := reqInt(c, "item_id") + if err != nil { + return nil, err + } + + return items.Update(c.Request.Context(), id, input) + })) + + g.PUT("/:item_id/stats/:stat_id", handler("item", func(c *gin.Context) (interface{}, error) { + input := entities.ItemStat{} + err := c.BindJSON(&input) + if err != nil { + return nil, models.BadInputError{ + Object: "Project", + Problem: "Invalid JSON: " + err.Error(), + } + } + + itemID, err := reqInt(c, "item_id") + if err != nil { + return nil, err + } + statID, err := reqInt(c, "stat_id") + if err != nil { + return nil, err + } + + input.StatID = statID + + return items.UpdateStat(c.Request.Context(), itemID, input) + })) + + g.DELETE("/:item_id/stats/:stat_id", handler("item", func(c *gin.Context) (interface{}, error) { + itemID, err := reqInt(c, "item_id") + if err != nil { + return nil, err + } + statID, err := reqInt(c, "stat_id") + if err != nil { + return nil, err + } + + return items.DeleteStat(c.Request.Context(), itemID, statID) + })) + + g.DELETE("/:item_id", handler("item", func(c *gin.Context) (interface{}, error) { + id, err := reqInt(c, "item_id") + if err != nil { + return nil, err + } + + return items.Delete(c.Request.Context(), id) + })) } diff --git a/ports/httpapi/projects.go b/ports/httpapi/projects.go index eacc734..84da8b2 100644 --- a/ports/httpapi/projects.go +++ b/ports/httpapi/projects.go @@ -8,7 +8,6 @@ import ( ) func Projects(g *gin.RouterGroup, service *projects.Service) { - g.GET("", handler("projects", func(c *gin.Context) (interface{}, error) { return service.List(c.Request.Context()) })) @@ -49,7 +48,7 @@ func Projects(g *gin.RouterGroup, service *projects.Service) { return project.Requirements, nil })) - g.GET("/:project_id/requirements/:requirement_id", handler("requirements", func(c *gin.Context) (interface{}, error) { + g.GET("/:project_id/requirements/:requirement_id", handler("requirement", func(c *gin.Context) (interface{}, error) { projectID, err := reqInt(c, "project_id") if err != nil { return nil, err diff --git a/ports/mysql/items.go b/ports/mysql/items.go index 737ae6e..be8a909 100644 --- a/ports/mysql/items.go +++ b/ports/mysql/items.go @@ -166,7 +166,7 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([ func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entities.Item, error) { res, err := r.q.InsertItem(ctx, mysqlcore.InsertItemParams{ ScopeID: item.ScopeID, - ProjectRequirementID: sql.NullInt32{}, + ProjectRequirementID: sqlIntPtr(item.ProjectRequirementID), Name: item.Name, Description: item.Description, CreatedTime: item.CreatedTime, @@ -204,9 +204,9 @@ func (r *itemRepository) Delete(ctx context.Context, item entities.Item) error { return r.q.DeleteItem(ctx, item.ID) } -func (r *itemRepository) ListProgress(ctx context.Context, items ...entities.Item) ([]entities.ItemProgress, error) { +func (r *itemRepository) ListStat(ctx context.Context, items ...entities.Item) ([]entities.ItemStat, error) { if len(items) == 0 { - return []entities.ItemProgress{}, nil + return []entities.ItemStat{}, nil } ids := make([]interface{}, 0, 64) @@ -222,15 +222,15 @@ func (r *itemRepository) ListProgress(ctx context.Context, items ...entities.Ite rows, err := r.db.QueryContext(ctx, query, ids...) if err != nil { if err == sql.ErrNoRows { - return []entities.ItemProgress{}, nil + return []entities.ItemStat{}, nil } return nil, err } - res := make([]entities.ItemProgress, 0, 8) + res := make([]entities.ItemStat, 0, 8) for rows.Next() { - progress := entities.ItemProgress{} + progress := entities.ItemStat{} err = rows.Scan(&progress.ItemID, &progress.StatID, &progress.Acquired, &progress.Required) if err != nil { @@ -243,7 +243,7 @@ func (r *itemRepository) ListProgress(ctx context.Context, items ...entities.Ite return res, nil } -func (r *itemRepository) UpdateProgress(ctx context.Context, item entities.ItemProgress) error { +func (r *itemRepository) UpdateStat(ctx context.Context, item entities.ItemStat) error { if item.Required <= 0 { return r.q.DeleteItemStatProgress(ctx, mysqlcore.DeleteItemStatProgressParams{ItemID: item.ItemID, StatID: item.StatID}) } @@ -251,7 +251,7 @@ func (r *itemRepository) UpdateProgress(ctx context.Context, item entities.ItemP return r.q.ReplaceItemStatProgress(ctx, mysqlcore.ReplaceItemStatProgressParams{ ItemID: item.ItemID, StatID: item.StatID, - Acquired: item.Required, - Required: item.Acquired, + Acquired: item.Acquired, + Required: item.Required, }) } diff --git a/usecases/items/repository.go b/usecases/items/repository.go index ba0ea84..37aaca4 100644 --- a/usecases/items/repository.go +++ b/usecases/items/repository.go @@ -12,6 +12,10 @@ type Repository interface { Insert(ctx context.Context, item entities.Item) (*entities.Item, error) Update(ctx context.Context, item entities.Item, update models.ItemUpdate) error Delete(ctx context.Context, item entities.Item) error - ListProgress(ctx context.Context, items ...entities.Item) ([]entities.ItemProgress, error) - UpdateProgress(ctx context.Context, progress entities.ItemProgress) error + ListStat(ctx context.Context, items ...entities.Item) ([]entities.ItemStat, error) + UpdateStat(ctx context.Context, progress entities.ItemStat) error +} + +type RequirementFetcher interface { + FetchRequirements(ctx context.Context, scopeID int, requirementIDs ...int) ([]entities.Requirement, []entities.RequirementStat, error) } diff --git a/usecases/items/result.go b/usecases/items/result.go index 3552303..f2e912b 100644 --- a/usecases/items/result.go +++ b/usecases/items/result.go @@ -3,6 +3,7 @@ package items import ( "git.aiterp.net/stufflog3/stufflog3/entities" "git.aiterp.net/stufflog3/stufflog3/usecases/scopes" + "sort" ) type Result struct { @@ -21,6 +22,44 @@ func (r *Result) Stat(statID int) *ResultStat { return nil } +func (r *Result) AddStat(scope scopes.Result, stat entities.ItemStat) { + for i, stat2 := range r.Stats { + if stat2.ID == stat.StatID { + r.Stats[i].Acquired = stat.Acquired + r.Stats[i].Required = stat.Required + return + } + } + + scopeStat := scope.Stat(stat.StatID) + if scopeStat == nil { + return + } + + r.Stats = append(r.Stats, ResultStat{ + ID: scopeStat.ID, + Name: scopeStat.Name, + Weight: scopeStat.Weight, + Acquired: stat.Acquired, + Required: stat.Required, + }) + r.SortStats() +} + +func (r *Result) SortStats() { + sort.Slice(r.Stats, func(i, j int) bool { + return r.Stats[i].Name < r.Stats[j].Name + }) +} + +func (r *Result) RemoveStat(id int) { + for i, stat := range r.Stats { + if stat.ID == id { + r.Stats = append(r.Stats[:i], r.Stats[i+1:]...) + } + } +} + type ResultStat struct { ID int `json:"id"` Name string `json:"name"` @@ -29,7 +68,7 @@ type ResultStat struct { Required int `json:"required"` } -func generateResult(item entities.Item, scope scopes.Result, progresses []entities.ItemProgress) Result { +func generateResult(item entities.Item, scope scopes.Result, progresses []entities.ItemStat) Result { res := Result{ Item: item, Stats: make([]ResultStat, 0, 8), @@ -55,5 +94,7 @@ func generateResult(item entities.Item, scope scopes.Result, progresses []entiti } } + res.SortStats() + return res } diff --git a/usecases/items/service.go b/usecases/items/service.go index 0f644d5..e0c58e0 100644 --- a/usecases/items/service.go +++ b/usecases/items/service.go @@ -2,15 +2,21 @@ package items import ( "context" + "git.aiterp.net/stufflog3/stufflog3/entities" "git.aiterp.net/stufflog3/stufflog3/models" + "git.aiterp.net/stufflog3/stufflog3/usecases/auth" "git.aiterp.net/stufflog3/stufflog3/usecases/scopes" "git.aiterp.net/stufflog3/stufflog3/usecases/stats" + "strings" + "time" ) type Service struct { - Scopes *scopes.Service - Stats *stats.Service - Repository Repository + Auth *auth.Service + Scopes *scopes.Service + Stats *stats.Service + Repository Repository + RequirementFetcher RequirementFetcher } func (s *Service) Find(ctx context.Context, id int) (*Result, error) { @@ -24,12 +30,12 @@ func (s *Service) Find(ctx context.Context, id int) (*Result, error) { return nil, err } - progresses, err := s.Repository.ListProgress(ctx, *item) + itemStats, err := s.Repository.ListStat(ctx, *item) if err != nil { return nil, err } - result := generateResult(*item, sc.Scope, progresses) + result := generateResult(*item, sc.Scope, itemStats) return &result, nil } @@ -67,7 +73,7 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R return nil, err } - progresses, err := s.Repository.ListProgress(ctx, items...) + progresses, err := s.Repository.ListStat(ctx, items...) if err != nil { return nil, err } @@ -79,3 +85,124 @@ func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]R return res, nil } + +func (s *Service) Create(ctx context.Context, item entities.Item, stats []entities.ItemStat) (*Result, error) { + scope := s.Scopes.Context(ctx).Scope + user := s.Auth.GetUser(ctx) + + item.Name = strings.Trim(item.Name, "  \t\r\n") + if item.Name == "" { + return nil, models.BadInputError{ + Object: "ProjectInput", + Field: "name", + Problem: "Empty name provided", + } + } + if item.ProjectRequirementID != nil { + reqs, _, err := s.RequirementFetcher.FetchRequirements(ctx, scope.ID, *item.ProjectRequirementID) + if err != nil { + return nil, err + } + if len(reqs) == 0 { + return nil, models.NotFoundError("Requirement") + } + + item.ProjectID = &reqs[0].ProjectID + } + item.ScopeID = scope.ID + item.OwnerID = user.ID + + if item.CreatedTime.IsZero() { + item.CreatedTime = time.Now() + } + + newItem, err := s.Repository.Insert(ctx, item) + if err != nil { + return nil, err + } + + goodStats := make([]entities.ItemStat, 0, len(stats)) + for _, stat := range stats { + stat.ItemID = newItem.ID + if scope.Stat(stat.StatID) == nil { + return nil, models.NotFoundError("Stat") + } + + err := s.Repository.UpdateStat(ctx, stat) + if err != nil { + continue + } + + goodStats = append(goodStats, stat) + } + + results := generateResult(*newItem, scope, goodStats) + return &results, nil +} + +func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate) (*Result, error) { + item, err := s.Find(ctx, id) + if err != nil { + return nil, err + } + + err = s.Repository.Update(ctx, item.Item, update) + if err != nil { + return nil, err + } + item.Item.ApplyUpdate(update) + + return item, nil +} + +func (s *Service) Delete(ctx context.Context, id int) (*Result, error) { + item, err := s.Find(ctx, id) + if err != nil { + return nil, err + } + + err = s.Repository.Delete(ctx, item.Item) + if err != nil { + return nil, err + } + + return item, nil +} + +func (s *Service) UpdateStat(ctx context.Context, itemID int, stat entities.ItemStat) (*Result, error) { + scope := s.Scopes.Context(ctx).Scope + item, err := s.Find(ctx, itemID) + if err != nil { + return nil, err + } + + stat.ItemID = itemID + + err = s.Repository.UpdateStat(ctx, stat) + if err != nil { + return nil, err + } + item.AddStat(scope, stat) + + return item, nil +} + +func (s *Service) DeleteStat(ctx context.Context, itemID int, statID int) (*Result, error) { + item, err := s.Find(ctx, itemID) + if err != nil { + return nil, err + } + + err = s.Repository.UpdateStat(ctx, entities.ItemStat{ + ItemID: itemID, + StatID: statID, + Acquired: 0, + Required: -1, + }) + if err != nil { + return nil, err + } + item.RemoveStat(statID) + + return item, nil +} diff --git a/usecases/projects/result.go b/usecases/projects/result.go index 919ba92..f7f89cd 100644 --- a/usecases/projects/result.go +++ b/usecases/projects/result.go @@ -5,6 +5,7 @@ import ( "git.aiterp.net/stufflog3/stufflog3/models" "git.aiterp.net/stufflog3/stufflog3/usecases/items" "git.aiterp.net/stufflog3/stufflog3/usecases/scopes" + "sort" "time" ) @@ -157,5 +158,10 @@ func generateRequirementResult(req entities.Requirement, scope scopes.Result, re resReq.Items = append(resReq.Items, item) } + + sort.Slice(resReq.Stats, func(i, j int) bool { + return resReq.Stats[i].Name < resReq.Stats[j].Name + }) + return resReq }