Browse Source

add item list API endpoint and add filtering to repository. I don't know why I tried to avoid squirrel.

master
Gisle Aune 2 years ago
parent
commit
d8bc855bda
  1. 16
      cmd/stufflog3-local/main.go
  2. 22
      go.mod
  3. 13
      go.sum
  4. 20
      models/date.go
  5. 10
      models/errors.go
  6. 15
      models/generics.go
  7. 11
      models/item.go
  8. 159
      ports/httpapi/items.go
  9. 24
      ports/httpapi/scopes.go
  10. 131
      ports/mysql/items.go
  11. 9
      scripts/goose-mysql/20220514164924_item_idx_requirement_id.sql
  12. 9
      scripts/goose-mysql/20220514165854_item_idx_created_time.sql
  13. 9
      scripts/goose-mysql/20220514165858_item_idx_acquired_time.sql
  14. 9
      scripts/goose-mysql/20220514165902_item_idx_scheduled_date.sql
  15. 7
      usecases/items/repository.go
  16. 46
      usecases/items/result.go
  17. 52
      usecases/items/service.go
  18. 82
      usecases/projects/result.go
  19. 16
      usecases/projects/service.go
  20. 42
      usecases/scopes/context.go
  21. 4
      usecases/scopes/repository.go
  22. 20
      usecases/scopes/service.go
  23. 2
      usecases/stats/service.go

16
cmd/stufflog3-local/main.go

@ -31,21 +31,22 @@ func main() {
authService := &auth.Service{}
scopesService := &scopes.Service{
Auth: authService,
Repository: db.Scopes(),
Auth: authService,
Repository: db.Scopes(),
StatsLister: db.Stats(),
}
statsSerice := &stats.Service{
statsService := &stats.Service{
Scopes: scopesService,
Repository: db.Stats(),
}
itemsService := &items.Service{
Scopes: scopesService,
Stats: statsSerice,
Stats: statsService,
Repository: db.Items(),
}
projectsService := &projects.Service{
Scopes: scopesService,
Stats: statsSerice,
Stats: statsService,
Items: itemsService,
Repository: db.Projects(),
}
@ -59,10 +60,11 @@ func main() {
apiV1.Use(httpapi.TrustingJwtParserMiddleware(authService))
}
apiV1Scopes := apiV1.Group("/scopes")
apiV1ScopesSub := apiV1Scopes.Group("/:scope_id")
apiV1ScopesSub.Use(httpapi.ScopeMiddleware(scopesService))
httpapi.Scopes(apiV1Scopes, scopesService)
apiV1ScopesSub := apiV1Scopes.Group("/:scope_id")
apiV1ScopesSub.Use(httpapi.ScopeMiddleware(scopesService, authService))
httpapi.Projects(apiV1ScopesSub.Group("/projects"), projectsService)
httpapi.Items(apiV1ScopesSub.Group("/items"), itemsService)
exitSignal := make(chan os.Signal)
signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM)

22
go.mod

@ -1,8 +1,28 @@
module git.aiterp.net/stufflog3/stufflog3
go 1.13
go 1.18
require (
github.com/Masterminds/squirrel v1.5.2
github.com/gin-gonic/gin v1.7.7
github.com/go-sql-driver/mysql v1.6.0
)
require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.13.0 // indirect
github.com/go-playground/universal-translator v0.17.0 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/golang/protobuf v1.3.3 // indirect
github.com/json-iterator/go v1.1.9 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

13
go.sum

@ -1,9 +1,13 @@
github.com/Masterminds/squirrel v1.5.2 h1:UiOEi2ZX4RCSkpiNDQN5kro/XIBpSRk9iTqdIRPzUXE=
github.com/Masterminds/squirrel v1.5.2/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@ -18,6 +22,10 @@ github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaW
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
@ -26,11 +34,13 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
@ -45,6 +55,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=

20
models/date.go

@ -8,6 +8,26 @@ import (
type Date [3]int
func (d Date) Before(other Date) bool {
if d[0] == other[0] {
if d[1] == other[1] {
return d[2] < other[2]
} else {
return d[1] < other[1]
}
} else {
return d[0] < other[0]
}
}
func (d Date) IsZero() bool {
return d == [3]int{0, 0, 0}
}
func (d *Date) AsTime() time.Time {
return time.Date(d[0], time.Month(d[1]), d[2], 0, 0, 0, 0, time.UTC)
}
func ParseDate(s string) (Date, error) {
date, err := time.ParseInLocation("2006-01-02", s, time.UTC)
if err != nil {

10
models/errors.go

@ -22,6 +22,16 @@ func (e PermissionDeniedError) HttpStatus() (int, string, interface{}) {
return 403, "Permission denied", nil
}
type ForbiddenError string
func (e ForbiddenError) Error() string {
return string(e)
}
func (e ForbiddenError) HttpStatus() (int, string, interface{}) {
return 401, string(e), nil
}
type BadInputError struct {
Object string
Field string

15
models/generics.go

@ -0,0 +1,15 @@
package models
type betweenContract[T any] interface {
Before(other T) bool
IsZero() bool
}
type Between[T betweenContract[T]] struct {
Min T `json:"min"`
Max T `json:"max"`
}
func (b *Between[T]) Valid() bool {
return !b.Min.IsZero() && !b.Max.IsZero() && b.Min.Before(b.Max)
}

11
models/item.go

@ -13,3 +13,14 @@ type ItemUpdate struct {
ClearAcquiredTime bool `json:"clearAcquiredTime"`
ClearScheduledDate bool `json:"clearScheduledDate"`
}
type ItemFilter struct {
OwnerID *string `json:"ownerId,omitempty"`
AcquiredTime *Between[time.Time] `json:"acquiredTime,omitempty"`
CreatedTime *Between[time.Time] `json:"createdTime,omitempty"`
ScheduledDate *Between[Date] `json:"scheduledDate,omitempty"`
ScopeIDs []int `json:"scopeIds,omitempty"`
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`
Loose bool `json:"loose,omitempty"`
}

159
ports/httpapi/items.go

@ -0,0 +1,159 @@
package httpapi
import (
"fmt"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"github.com/gin-gonic/gin"
"strconv"
"strings"
"time"
)
func Items(g *gin.RouterGroup, items *items.Service) {
g.GET("", handler("items", func(c *gin.Context) (interface{}, error) {
filter := models.ItemFilter{}
if queryOwnerId := c.Query("ownerId"); queryOwnerId != "" {
filter.OwnerID = &queryOwnerId
}
queryScheduledMin := c.Query("scheduledMin")
queryScheduledMax := c.Query("scheduledMax")
if queryScheduledMin != "" && queryScheduledMax != "" {
min, err := models.ParseDate(queryScheduledMin)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "scheduledMin",
Problem: "Invalid from date: " + err.Error(),
}
}
max, err := models.ParseDate(queryScheduledMax)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "scheduledMax",
Problem: "Invalid to date: " + err.Error(),
}
}
filter.ScheduledDate = &models.Between[models.Date]{Min: min, Max: max}
}
queryCreatedMin := c.Query("createdMin")
queryCreatedMax := c.Query("createdMax")
if queryCreatedMin != "" && queryCreatedMax != "" {
min, err := time.Parse(time.RFC3339, queryCreatedMin)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "createdMin",
Problem: "Invalid from date: " + err.Error(),
}
}
max, err := time.Parse(time.RFC3339, queryCreatedMax)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "createdMax",
Problem: "Invalid to date: " + err.Error(),
}
}
filter.CreatedTime = &models.Between[time.Time]{Min: min, Max: max}
}
queryAcquiredMin := c.Query("acquiredMin")
queryAcquiredMax := c.Query("acquiredMax")
if queryAcquiredMin != "" && queryAcquiredMax != "" {
min, err := time.Parse(time.RFC3339, queryAcquiredMin)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "acquiredMin",
Problem: "Invalid from date: " + err.Error(),
}
}
max, err := time.Parse(time.RFC3339, queryAcquiredMax)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "acquiredMax",
Problem: "Invalid to date: " + err.Error(),
}
}
filter.AcquiredTime = &models.Between[time.Time]{Min: min, Max: max}
}
anyDateMin := c.Query("anyDateMin")
anyDateMax := c.Query("anyDateMax")
if anyDateMin != "" && anyDateMax != "" {
min, err := time.Parse(time.RFC3339, anyDateMin)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "anyDateMin",
Problem: "Invalid from date: " + err.Error(),
}
}
max, err := time.Parse(time.RFC3339, anyDateMax)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "anyDateMax",
Problem: "Invalid to date: " + err.Error(),
}
}
minY, minM, minD := min.Date()
maxY, maxM, maxD := max.Date()
filter.AcquiredTime = &models.Between[time.Time]{Min: min, Max: max}
filter.CreatedTime = &models.Between[time.Time]{Min: min, Max: max}
filter.ScheduledDate = &models.Between[models.Date]{
Min: models.Date{minY, int(minM), minD},
Max: models.Date{maxY, int(maxM), maxD},
}
}
if queryProjectID := c.Query("projectId"); queryProjectID != "" {
ids := strings.Split(queryProjectID, ",")
for _, id := range ids {
parsed, err := strconv.Atoi(id)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: fmt.Sprintf("projectId[%d]", len(filter.ProjectIDs)),
Problem: "Invalid number",
}
}
filter.ProjectIDs = append(filter.ProjectIDs, parsed)
}
}
if queryRequirementID := c.Query("requirementId"); queryRequirementID != "" {
if queryRequirementID != "null" {
ids := strings.Split(queryRequirementID, ",")
for _, id := range ids {
parsed, err := strconv.Atoi(id)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: fmt.Sprintf("requirementId[%d]", len(filter.RequirementIDs)),
Problem: "Invalid number",
}
}
filter.RequirementIDs = append(filter.RequirementIDs, parsed)
}
} else {
filter.Loose = true
}
}
return items.ListScoped(c.Request.Context(), filter)
}))
}

24
ports/httpapi/scopes.go

@ -3,6 +3,7 @@ package httpapi
import (
"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"
"github.com/gin-gonic/gin"
"net/http"
@ -20,7 +21,7 @@ type scopeResultMember struct {
Owner bool `json:"owner"`
}
func ScopeMiddleware(scopes *scopes.Service) gin.HandlerFunc {
func ScopeMiddleware(scopes *scopes.Service, auth *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := reqInt(c, "scope_id")
if err != nil {
@ -35,15 +36,32 @@ func ScopeMiddleware(scopes *scopes.Service) gin.HandlerFunc {
})
}
scope, _, err := scopes.Find(c.Request.Context(), id)
scope, members, err := scopes.Find(c.Request.Context(), id)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, Error{
Code: http.StatusNotFound,
Message: "Scope not found or inaccessible",
})
return
}
found := false
userID := auth.GetUser(c.Request.Context())
for _, member := range members {
if member.UserID == userID {
found = true
break
}
}
if !found {
c.AbortWithStatusJSON(http.StatusUnauthorized, Error{
Code: http.StatusNotFound,
Message: "Scope not found or inaccessible",
})
return
}
c.Request = c.Request.WithContext(scopes.ContextWithScope(c.Request.Context(), *scope))
c.Request = c.Request.WithContext(scopes.CreateContext(c.Request.Context(), userID, *scope))
}
}

131
ports/mysql/items.go

@ -6,8 +6,9 @@ import (
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/sqltypes"
"github.com/Masterminds/squirrel"
"strings"
"time"
)
type itemRepository struct {
@ -20,46 +21,110 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
panic("implement me")
}
func (r *itemRepository) ListCreated(ctx context.Context, scopeID int, from, to time.Time) ([]entities.Item, error) {
//TODO implement me
panic("implement me")
}
func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([]entities.Item, error) {
// Blank arrays are not the same as nulls
if filter.ScopeIDs != nil && len(filter.ScopeIDs) == 0 {
return []entities.Item{}, nil
}
if filter.ProjectIDs != nil && len(filter.ProjectIDs) == 0 {
return []entities.Item{}, nil
}
if filter.RequirementIDs != nil && len(filter.RequirementIDs) == 0 {
return []entities.Item{}, nil
}
func (r *itemRepository) ListAcquired(ctx context.Context, scopeID int, from, to time.Time) ([]entities.Item, error) {
//TODO implement me
panic("implement me")
}
sq := squirrel.Select(
"i.id, i.scope_id, i.project_requirement_id, pr.project_id, i.owner_id, i.name," +
" i.description, i.created_time, i.acquired_time, i.scheduled_date",
).From("item i").RightJoin("project_requirement pr ON pr.id = i.project_requirement_id")
func (r *itemRepository) ListScheduled(ctx context.Context, scopeID int, from, to models.Date) ([]entities.Item, error) {
//TODO implement me
panic("implement me")
}
dateOr := squirrel.Or{}
if filter.CreatedTime != nil {
dateOr = append(dateOr, squirrel.And{
squirrel.GtOrEq{"i.created_time": filter.CreatedTime.Min},
squirrel.Lt{"i.created_time": filter.CreatedTime.Max},
})
}
if filter.AcquiredTime != nil {
dateOr = append(dateOr, squirrel.And{
squirrel.GtOrEq{"i.acquired_time": filter.AcquiredTime.Min},
squirrel.Lt{"i.acquired_time": filter.AcquiredTime.Max},
})
}
if filter.ScheduledDate != nil {
dateOr = append(dateOr, squirrel.And{
squirrel.GtOrEq{"i.scheduled_date": filter.ScheduledDate.Min.AsTime()},
squirrel.Lt{"i.scheduled_date": filter.ScheduledDate.Max.AsTime()},
})
}
if len(dateOr) > 0 {
sq = sq.Where(dateOr)
}
func (r *itemRepository) ListRequirement(ctx context.Context, requirementID int) ([]entities.Item, error) {
//TODO implement me
panic("implement me")
}
if len(filter.ScopeIDs) > 0 {
sq = sq.Where(squirrel.Eq{"i.scope_id": filter.ScopeIDs})
}
if len(filter.RequirementIDs) > 0 {
sq = sq.Where(squirrel.Eq{"i.project_requirement_id": filter.RequirementIDs})
}
if len(filter.ProjectIDs) > 0 {
sq = sq.Where(squirrel.Eq{"pr.project_id": filter.ProjectIDs})
}
if filter.OwnerID != nil {
sq = sq.Where(squirrel.Eq{"i.owner_id": filter.OwnerID})
}
if filter.Loose {
sq = sq.Where("i.project_requirement_id IS NULL")
}
func (r *itemRepository) ListProject(ctx context.Context, projectID int) ([]entities.Item, error) {
rows, err := r.q.ListItemsByProject(ctx, projectID)
query, params, err := sq.ToSql()
if err != nil {
return nil, err
}
res := make([]entities.Item, 0, len(rows))
for _, row := range rows {
res = append(res, entities.Item{
ID: row.ID,
ScopeID: row.ScopeID,
OwnerID: row.OwnerID,
ProjectID: intPtr(row.ProjectID),
ProjectRequirementID: intPtr(row.ProjectRequirementID),
Name: row.Name,
Description: row.Description,
CreatedTime: row.CreatedTime,
AcquiredTime: timePtr(row.AcquiredTime),
ScheduledDate: row.ScheduledDate.AsPtr(),
})
rows, err := r.db.QueryContext(ctx, query, params...)
if err != nil {
if err == sql.ErrNoRows {
return []entities.Item{}, nil
}
return nil, err
}
res := make([]entities.Item, 0, 32)
for rows.Next() {
item := entities.Item{}
var projectRequirementId, projectId sql.NullInt32
var acquiredTime sql.NullTime
var scheduledDate sqltypes.NullDate
err = rows.Scan(
&item.ID,
&item.ScopeID,
&projectRequirementId,
&projectId,
&item.OwnerID,
&item.Name,
&item.Description,
&item.CreatedTime,
&acquiredTime,
&scheduledDate,
)
if err != nil {
if err == sql.ErrNoRows {
break
}
return nil, err
}
item.ProjectRequirementID = intPtr(projectRequirementId)
item.ProjectID = intPtr(projectId)
item.AcquiredTime = timePtr(acquiredTime)
item.ScheduledDate = scheduledDate.AsPtr()
res = append(res, item)
}
return res, nil

9
scripts/goose-mysql/20220514164924_item_idx_requirement_id.sql

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX item_idx_requirement_id ON item (project_requirement_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS item_idx_requirement_id;
-- +goose StatementEnd

9
scripts/goose-mysql/20220514165854_item_idx_created_time.sql

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX item_idx_created_time ON item (created_time);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS item_idx_created_time;
-- +goose StatementEnd

9
scripts/goose-mysql/20220514165858_item_idx_acquired_time.sql

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX item_idx_acquired_time ON item (acquired_time);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS item_idx_acquired_time;
-- +goose StatementEnd

9
scripts/goose-mysql/20220514165902_item_idx_scheduled_date.sql

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX item_idx_scheduled_date ON item (scheduled_date);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS item_idx_scheduled_date;
-- +goose StatementEnd

7
usecases/items/repository.go

@ -4,16 +4,11 @@ import (
"context"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"time"
)
type Repository interface {
Find(ctx context.Context, scopeID, itemID int) (*entities.Item, error)
ListCreated(ctx context.Context, scopeID int, from, to time.Time) ([]entities.Item, error)
ListAcquired(ctx context.Context, scopeID int, from, to time.Time) ([]entities.Item, error)
ListScheduled(ctx context.Context, scopeID int, from, to models.Date) ([]entities.Item, error)
ListRequirement(ctx context.Context, requirementID int) ([]entities.Item, error)
ListProject(ctx context.Context, projectID int) ([]entities.Item, error)
Fetch(ctx context.Context, filter models.ItemFilter) ([]entities.Item, error)
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

46
usecases/items/result.go

@ -0,0 +1,46 @@
package items
import "git.aiterp.net/stufflog3/stufflog3/entities"
type Result struct {
entities.Item
Stats []ResultStat `json:"stats"`
}
type ResultStat struct {
ID int `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Acquired int `json:"acquired"`
Required int `json:"required"`
}
func generateResult(item entities.Item, progresses []entities.ItemProgress, stats []entities.Stat) Result {
res := Result{
Item: item,
Stats: make([]ResultStat, 0, 8),
}
for _, prog := range progresses {
if prog.ItemID != item.ID {
continue
}
for _, stat := range stats {
if stat.ID == prog.StatID {
res.Stats = append(res.Stats, ResultStat{
ID: stat.ID,
Name: stat.Name,
Weight: stat.Weight,
Acquired: prog.Acquired,
Required: prog.Required,
})
break
}
}
}
return res
}

52
usecases/items/service.go

@ -2,7 +2,7 @@ package items
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
)
@ -13,16 +13,54 @@ type Service struct {
Repository Repository
}
func (s *Service) ListProject(ctx context.Context, requirementID int) ([]entities.Item, []entities.ItemProgress, error) {
items, err := s.Repository.ListProject(ctx, requirementID)
func (s *Service) ListScoped(ctx context.Context, filter models.ItemFilter) ([]Result, error) {
sc := s.Scopes.Context(ctx)
if sc == nil {
return nil, models.PermissionDeniedError{}
}
if filter.ScopeIDs != nil {
userScopes, err := sc.Scopes(ctx)
if err != nil {
return nil, err
}
for _, id := range filter.ScopeIDs {
found := false
for _, scope := range userScopes {
if scope.ID == id {
found = true
break
}
}
if !found {
return nil, models.ForbiddenError("Scope you do not belong to found in filter scope IDs.")
}
}
} else {
filter.ScopeIDs = []int{sc.ID}
}
items, err := s.Repository.Fetch(ctx, filter)
if err != nil {
return nil, nil, err
return nil, err
}
itemProgresses, err := s.Repository.ListProgress(ctx, items...)
progresses, err := s.Repository.ListProgress(ctx, items...)
if err != nil {
return nil, nil, err
return nil, err
}
scopeStats, err := sc.Stats(ctx)
if err != nil {
return nil, err
}
res := make([]Result, 0, len(items))
for _, item := range items {
res = append(res, generateResult(item, progresses, scopeStats))
}
return items, itemProgresses, nil
return res, nil
}

82
usecases/projects/result.go

@ -3,6 +3,7 @@ package projects
import (
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"time"
)
@ -29,12 +30,29 @@ type Result struct {
Requirements []ResultRequirement `json:"requirements"`
}
func GenerateResult(
type ResultRequirement struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status models.Status `json:"status"`
Stats []ResultRequirementStat `json:"stats"`
Items []items.Result `json:"items"`
}
type ResultRequirementStat struct {
ID int `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Acquired int `json:"acquired"`
Required int `json:"required"`
Planned int `json:"planned"`
}
func generateResult(
project entities.Project,
requirement []entities.Requirement,
requirementStats []entities.RequirementStat,
items []entities.Item,
itemProgresses []entities.ItemProgress,
projectItems []items.Result,
stats []entities.Stat,
) Result {
res := Result{
@ -53,7 +71,7 @@ func GenerateResult(
Description: req.Description,
Status: req.Status,
Stats: make([]ResultRequirementStat, 0, 8),
Items: make([]ResultRequirementItem, 0, 8),
Items: make([]items.Result, 0, 8),
}
statIndices := make(map[int]int)
for _, reqStat := range requirementStats {
@ -77,40 +95,19 @@ func GenerateResult(
statIndices[reqStat.StatID] = len(resReq.Stats) - 1
}
for _, item := range items {
for _, item := range projectItems {
if item.ProjectRequirementID == nil || *item.ProjectRequirementID != req.ID {
continue
}
resItem := ResultRequirementItem{
Item: item,
Stats: make([]ResultRequirementStat, 0, 8),
}
for _, itemProgress := range itemProgresses {
if itemProgress.ItemID != item.ID {
continue
}
resStat := ResultRequirementStat{
ID: itemProgress.StatID,
Acquired: itemProgress.Acquired,
Required: itemProgress.Required,
}
for _, stat := range stats {
if stat.ID == resStat.ID {
resStat.Name = stat.Name
resStat.Weight = stat.Weight
break
}
}
if si, ok := statIndices[resStat.ID]; ok {
resReq.Stats[si].Acquired += itemProgress.Acquired
for _, stat := range item.Stats {
if statIndex, ok := statIndices[stat.ID]; ok {
resReq.Stats[statIndex].Acquired += stat.Acquired
resReq.Stats[statIndex].Planned += stat.Required
}
resItem.Stats = append(resItem.Stats, resStat)
}
resReq.Items = append(resReq.Items, resItem)
resReq.Items = append(resReq.Items, item)
}
res.Requirements = append(res.Requirements, resReq)
@ -118,26 +115,3 @@ func GenerateResult(
return res
}
type ResultRequirement struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status models.Status `json:"status"`
Stats []ResultRequirementStat `json:"stats"`
Items []ResultRequirementItem `json:"items"`
}
type ResultRequirementStat struct {
ID int `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Acquired int `json:"acquired"`
Required int `json:"required"`
}
type ResultRequirementItem struct {
entities.Item
Stats []ResultRequirementStat `json:"stats"`
}

16
usecases/projects/service.go

@ -2,6 +2,7 @@ package projects
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
@ -16,9 +17,9 @@ type Service struct {
}
func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
scopeID := s.Scopes.ScopeFromContext(ctx).ID
sc := s.Scopes.Context(ctx)
project, err := s.Repository.Find(ctx, scopeID, id)
project, err := s.Repository.Find(ctx, sc.ID, id)
if err != nil {
return nil, err
}
@ -28,22 +29,23 @@ func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
return nil, err
}
items, itemProgresses, err := s.Items.ListProject(ctx, id)
items, err := s.Items.ListScoped(ctx, models.ItemFilter{
ProjectIDs: []int{id},
})
if err != nil {
return nil, err
}
stats, err := s.Stats.List(ctx)
stats, err := sc.Stats(ctx)
if err != nil {
return nil, err
}
result := GenerateResult(
result := generateResult(
*project,
requirements,
requirementStats,
items,
itemProgresses,
stats,
)
@ -51,7 +53,7 @@ func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
}
func (s *Service) List(ctx context.Context) ([]Entry, error) {
projects, err := s.Repository.List(ctx, s.Scopes.ScopeFromContext(ctx).ID)
projects, err := s.Repository.List(ctx, s.Scopes.Context(ctx).ID)
if err != nil {
return nil, err
}

42
usecases/scopes/context.go

@ -0,0 +1,42 @@
package scopes
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/entities"
"sync"
)
type Context struct {
scopesRepo Repository
statsRepo StatsLister
userID string
ID int
Scope entities.Scope
statsOnce sync.Once
stats []entities.Stat
statsError error
scopesOnce sync.Once
scopes []entities.Scope
scopesError error
}
// Stats lazy-loads the stats in the context of the first caller.
func (c *Context) Stats(ctx context.Context) ([]entities.Stat, error) {
c.statsOnce.Do(func() {
c.stats, c.statsError = c.statsRepo.List(ctx, c.ID)
})
return c.stats, c.statsError
}
// Scopes lazy-loads the scopes in the context of the first caller.
func (c *Context) Scopes(ctx context.Context) ([]entities.Scope, error) {
c.statsOnce.Do(func() {
c.scopes, c.scopesError = c.scopesRepo.ListUser(ctx, c.userID)
})
return c.scopes, c.scopesError
}

4
usecases/scopes/repository.go

@ -18,3 +18,7 @@ type Repository interface {
SaveMember(ctx context.Context, member entities.ScopeMember) error
DeleteMember(ctx context.Context, member entities.ScopeMember) error
}
type StatsLister interface {
List(ctx context.Context, scopeID int) ([]entities.Stat, error)
}

20
usecases/scopes/service.go

@ -8,23 +8,31 @@ import (
)
type Service struct {
Auth *auth.Service
Repository Repository
Auth *auth.Service
Repository Repository
StatsLister StatsLister
contextKey struct{}
}
func (s *Service) ContextWithScope(ctx context.Context, scope entities.Scope) context.Context {
return context.WithValue(ctx, &s.contextKey, &scope)
func (s *Service) CreateContext(ctx context.Context, userID string, scope entities.Scope) context.Context {
return context.WithValue(ctx, &s.contextKey, &Context{
scopesRepo: s.Repository,
statsRepo: s.StatsLister,
userID: userID,
ID: scope.ID,
Scope: scope,
})
}
func (s *Service) ScopeFromContext(ctx context.Context) *entities.Scope {
func (s *Service) Context(ctx context.Context) *Context {
v := ctx.Value(&s.contextKey)
if v == nil {
return nil
}
return v.(*entities.Scope)
return v.(*Context)
}
// Find finds a scope and its members, and returns it if the logged-in user is part of this list.

2
usecases/stats/service.go

@ -13,5 +13,5 @@ type Service struct {
}
func (s *Service) List(ctx context.Context) ([]entities.Stat, error) {
return s.Repository.List(ctx, s.Scopes.ScopeFromContext(ctx).ID)
return s.Repository.List(ctx, s.Scopes.Context(ctx).ID)
}
Loading…
Cancel
Save