From e2c3b53d50848cb5f6e52a5841986363e848fdad Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 17 May 2020 22:49:07 +0200 Subject: [PATCH] add issue item stuff. --- database/database.go | 1 + database/drivers/mysqldriver/db.go | 7 + database/drivers/mysqldriver/issetasks.go | 16 +- database/drivers/mysqldriver/issueitems.go | 141 ++++++++++++++++++ database/repositories/issueitemrepository.go | 14 ++ graph/gqlgen.yml | 2 + graph/resolvers/issue.resolvers.go | 9 ++ graph/resolvers/issueitem.resolvers.go | 44 ++++++ graph/resolvers/mutation.resolvers.go | 63 ++++++++ graph/resolvers/query.resolvers.go | 70 +++++++++ graph/schema/issue.gql | 2 + graph/schema/issueitem.gql | 75 ++++++++++ graph/schema/mutation.gql | 4 + graph/schema/query.gql | 5 + ...20200517111706_create_table_issue_item.sql | 19 +++ models/issueitem.go | 14 +- 16 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 database/drivers/mysqldriver/issueitems.go create mode 100644 database/repositories/issueitemrepository.go create mode 100644 graph/resolvers/issueitem.resolvers.go create mode 100644 graph/schema/issueitem.gql create mode 100644 migrations/mysql/20200517111706_create_table_issue_item.sql diff --git a/database/database.go b/database/database.go index ef3d592..b3e0feb 100644 --- a/database/database.go +++ b/database/database.go @@ -12,6 +12,7 @@ type Database interface { Activities() repositories.ActivityRepository Issues() repositories.IssueRepository IssueTasks() repositories.IssueTaskRepository + IssueItems() repositories.IssueItemRepository Items() repositories.ItemRepository Projects() repositories.ProjectRepository Session() repositories.SessionRepository diff --git a/database/drivers/mysqldriver/db.go b/database/drivers/mysqldriver/db.go index 77dc1c9..7ef4ba4 100644 --- a/database/drivers/mysqldriver/db.go +++ b/database/drivers/mysqldriver/db.go @@ -17,6 +17,7 @@ type DB struct { activities *activityRepository issues *issueRepository issueTasks *issueTaskRepository + issueItems *issueItemRepository items *itemRepository projects *projectRepository sessions *sessionRepository @@ -36,6 +37,10 @@ func (db *DB) IssueTasks() repositories.IssueTaskRepository { return db.issueTasks } +func (db *DB) IssueItems() repositories.IssueItemRepository { + return db.issueItems +} + func (db *DB) Items() repositories.ItemRepository { return db.items } @@ -95,12 +100,14 @@ func Open(connectionString string) (*DB, error) { sessions := &sessionRepository{db: db} projectStatuses := &projectStatusRepository{db: db} issueTasks := &issueTaskRepository{db: db} + issueItems := &issueItemRepository{db: db} return &DB{ db: db, activities: activities, issues: issues, issueTasks: issueTasks, + issueItems: issueItems, items: items, projects: projects, users: users, diff --git a/database/drivers/mysqldriver/issetasks.go b/database/drivers/mysqldriver/issetasks.go index 0cdadc1..94541bd 100644 --- a/database/drivers/mysqldriver/issetasks.go +++ b/database/drivers/mysqldriver/issetasks.go @@ -90,15 +90,15 @@ func (r *issueTaskRepository) Insert(ctx context.Context, task models.IssueTask) _, err = tx.NamedExecContext(ctx, ` INSERT INTO issue_task ( - issue_task_id, issue_id, activity_id, created_time, - updated_time, due_time, status_stage, status_name, - name, description, estimated_time, estimated_units, - points_multiplier + issue_task_id, issue_id, activity_id, created_time, + updated_time, due_time, status_stage, status_name, + name, description, estimated_time, estimated_units, + points_multiplier ) VALUES ( - :issue_task_id, :issue_id, :activity_id, :created_time, - :updated_time, :due_time, :status_stage, :status_name, - :name, :description, :estimated_time, :estimated_units, - :points_multiplier + :issue_task_id, :issue_id, :activity_id, :created_time, + :updated_time, :due_time, :status_stage, :status_name, + :name, :description, :estimated_time, :estimated_units, + :points_multiplier ) `, task) if err != nil { diff --git a/database/drivers/mysqldriver/issueitems.go b/database/drivers/mysqldriver/issueitems.go new file mode 100644 index 0000000..042301c --- /dev/null +++ b/database/drivers/mysqldriver/issueitems.go @@ -0,0 +1,141 @@ +package mysqldriver + +import ( + "context" + "database/sql" + "errors" + "fmt" + "git.aiterp.net/stufflog/server/internal/slerrors" + "git.aiterp.net/stufflog/server/models" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type issueItemRepository struct { + db *sqlx.DB +} + +func (r *issueItemRepository) Find(ctx context.Context, id string) (*models.IssueItem, error) { + issueItem := models.IssueItem{} + err := r.db.GetContext(ctx, &issueItem, "SELECT * FROM issue_item WHERE issue_item_id=?", id) + if err != nil { + if err == sql.ErrNoRows { + return nil, slerrors.NotFound("Issue item") + } + + return nil, err + } + + return &issueItem, nil +} + +func (r *issueItemRepository) List(ctx context.Context, filter models.IssueItemFilter) ([]*models.IssueItem, error) { + q := sq.Select("issue_item.*").From("issue_item").GroupBy("issue_item.issue_item_id") + if len(filter.IssueItemIDs) > 0 { + q = q.Where(sq.Eq{"issue_item_id": filter.IssueIDs}) + } + if len(filter.IssueIDs) > 0 { + q = q.Where(sq.Eq{"issue_id": filter.IssueIDs}) + } + if len(filter.IssueAssignees) > 0 || len(filter.IssueOwners) > 0 || filter.IssueMinStage != nil || filter.IssueMaxStage != nil { + q = q.Join("issue ON issue.issue_id = issue_item.issue_id") + } + if len(filter.IssueAssignees) > 0 { + q = q.Where(sq.Eq{"issue.assignee_id": filter.IssueAssignees}) + } + if len(filter.IssueOwners) > 0 { + q = q.Where(sq.Eq{"issue.owner_id": filter.IssueOwners}) + } + if filter.IssueMinStage != nil && filter.IssueMaxStage != nil && *filter.IssueMinStage == *filter.IssueMaxStage { + q = q.Where(sq.Eq{"issue.status_stage": *filter.IssueMinStage}) + } else { + if filter.IssueMinStage != nil { + q = q.Where(sq.GtOrEq{"issue.status_stage": *filter.IssueMinStage}) + } + if filter.IssueMaxStage != nil { + q = q.Where(sq.LtOrEq{"issue.status_stage": *filter.IssueMaxStage}) + } + } + if len(filter.ItemIDs) > 0 { + q = q.Where(sq.Eq{"item_id": filter.IssueIDs}) + } + if len(filter.ItemTags) > 0 { + q = q.Join("item_tag ON item_tag.item_id = issue_item.item_id").Where( + sq.Eq{"item_tag.tag": filter.ItemTags}, + ) + } + if filter.Acquired != nil { + q = q.Where(sq.Eq{"acquired": *filter.Acquired}) + } + + query, args, err := q.ToSql() + if err != nil { + return nil, err + } + + results := make([]*models.IssueItem, 0, 16) + err = r.db.SelectContext(ctx, &results, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return []*models.IssueItem{}, nil + } + + return nil, err + } + + return results, nil +} + +func (r *issueItemRepository) Insert(ctx context.Context, item models.IssueItem) (*models.IssueItem, error) { + if item.IssueID == "" { + return nil, errors.New("missing issue id") + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + + nextID, err := incCounter(ctx, tx, counterKindIssueSubID, item.IssueID) + if err != nil { + _ = tx.Rollback() + return nil, err + } + item.ID = fmt.Sprintf("%s-%d", item.IssueID, nextID) + + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO issue_item ( + issue_item_id, issue_id, item_id, quantity, acquired + ) VALUES ( + :issue_item_id, :issue_id, :item_id, :quantity, :acquired + ); + `, item) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + err = tx.Commit() + if err != nil { + _ = tx.Rollback() + return nil, err + } + + return &item, nil +} + +func (r *issueItemRepository) Save(ctx context.Context, item models.IssueItem) error { + _, err := r.db.NamedExecContext(ctx, ` + UPDATE issue_item SET + acquired=:acquired, + quantity=:quantity + WHERE issue_item_id=:issue_item_id + `, item) + + return err +} + +func (r *issueItemRepository) Delete(ctx context.Context, item models.IssueItem) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM issue_item WHERE issue_item_id=? LIMIT 1;", item.ID) + return err +} diff --git a/database/repositories/issueitemrepository.go b/database/repositories/issueitemrepository.go new file mode 100644 index 0000000..2ad2984 --- /dev/null +++ b/database/repositories/issueitemrepository.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "context" + "git.aiterp.net/stufflog/server/models" +) + +type IssueItemRepository interface { + Find(ctx context.Context, id string) (*models.IssueItem, error) + List(ctx context.Context, filter models.IssueItemFilter) ([]*models.IssueItem, error) + Insert(ctx context.Context, item models.IssueItem) (*models.IssueItem, error) + Save(ctx context.Context, item models.IssueItem) error + Delete(ctx context.Context, item models.IssueItem) error +} diff --git a/graph/gqlgen.yml b/graph/gqlgen.yml index b5500eb..96093c1 100644 --- a/graph/gqlgen.yml +++ b/graph/gqlgen.yml @@ -20,6 +20,8 @@ models: resolver: true Duration: model: git.aiterp.net/stufflog/server/graph/scalars.Duration + IssueIssueItemFilter: + model: git.aiterp.net/stufflog/server/models.IssueItemFilter resolver: layout: follow-schema diff --git a/graph/resolvers/issue.resolvers.go b/graph/resolvers/issue.resolvers.go index 5fd2adb..658f971 100644 --- a/graph/resolvers/issue.resolvers.go +++ b/graph/resolvers/issue.resolvers.go @@ -85,6 +85,15 @@ func (r *issueResolver) Tasks(ctx context.Context, obj *models.Issue, filter *mo return r.Database.IssueTasks().List(ctx, *filter) } +func (r *issueResolver) Items(ctx context.Context, obj *models.Issue, filter *models.IssueItemFilter) ([]*models.IssueItem, error) { + if filter == nil { + filter = &models.IssueItemFilter{} + } + filter.IssueIDs = []string{obj.ID} + + return r.Database.IssueItems().List(ctx, *filter) +} + // Issue returns graphcore.IssueResolver implementation. func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} } diff --git a/graph/resolvers/issueitem.resolvers.go b/graph/resolvers/issueitem.resolvers.go new file mode 100644 index 0000000..3107521 --- /dev/null +++ b/graph/resolvers/issueitem.resolvers.go @@ -0,0 +1,44 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. + +import ( + "context" + "fmt" + + "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/models" +) + +func (r *issueItemResolver) Issue(ctx context.Context, obj *models.IssueItem) (*models.Issue, error) { + return r.Database.Issues().Find(ctx, obj.IssueID) +} + +func (r *issueItemResolver) Item(ctx context.Context, obj *models.IssueItem) (*models.Item, error) { + return r.Database.Items().Find(ctx, obj.ItemID) +} + +func (r *issueItemResolver) Remaining(ctx context.Context, obj *models.IssueItem) (int, error) { + if obj.Acquired { + return 0, nil + } + + // TODO: Use logs + return obj.Quantity, nil +} + +// IssueItem returns graphcore.IssueItemResolver implementation. +func (r *Resolver) IssueItem() graphcore.IssueItemResolver { return &issueItemResolver{r} } + +type issueItemResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +func (r *issueItemResolver) Quanity(ctx context.Context, obj *models.IssueItem) (int, error) { + panic(fmt.Errorf("not implemented")) +} diff --git a/graph/resolvers/mutation.resolvers.go b/graph/resolvers/mutation.resolvers.go index a9c78a9..71bbb48 100644 --- a/graph/resolvers/mutation.resolvers.go +++ b/graph/resolvers/mutation.resolvers.go @@ -412,6 +412,69 @@ func (r *mutationResolver) EditIssueTask(ctx context.Context, input graphcore.Is return task, nil } +func (r *mutationResolver) CreateIssueItem(ctx context.Context, input graphcore.IssueItemCreateInput) (*models.IssueItem, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + issue, err := r.Database.Issues().Find(ctx, input.IssueID) + if err != nil { + return nil, err + } + if perm, err := r.Auth.IssuePermission(ctx, *issue); err != nil || !perm.CanManageOwnIssue() { + return nil, slerrors.PermissionDenied + } + + item, err := r.Database.Items().Find(ctx, input.ItemID) + if err != nil { + return nil, err + } + + issueItem := &models.IssueItem{ + IssueID: issue.ID, + ItemID: item.ID, + Quantity: input.Quanitty, + Acquired: input.Acquired != nil && *input.Acquired, + } + + return r.Database.IssueItems().Insert(ctx, *issueItem) +} + +func (r *mutationResolver) EditIssueItem(ctx context.Context, input graphcore.IssueItemEditInput) (*models.IssueItem, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + item, err := r.Database.IssueItems().Find(ctx, input.IssueItemID) + if err != nil { + return nil, err + } + + issue, err := r.Database.Issues().Find(ctx, item.IssueID) + if err != nil { + return nil, err + } + if perm, err := r.Auth.IssuePermission(ctx, *issue); err != nil || !perm.CanManageOwnIssue() { + return nil, slerrors.PermissionDenied + } + + if input.SetAcquired != nil { + item.Acquired = *input.SetAcquired + } + if input.SetQuanitty != nil { + item.Quantity = *input.SetQuanitty + } + + err = r.Database.IssueItems().Save(ctx, *item) + if err != nil { + return nil, err + } + + return item, nil +} + func (r *mutationResolver) LoginUser(ctx context.Context, input graphcore.UserLoginInput) (*models.User, error) { return r.Auth.Login(ctx, input.Username, input.Password) } diff --git a/graph/resolvers/query.resolvers.go b/graph/resolvers/query.resolvers.go index 32c1c1d..3c3337d 100644 --- a/graph/resolvers/query.resolvers.go +++ b/graph/resolvers/query.resolvers.go @@ -89,6 +89,76 @@ func (r *queryResolver) ItemTags(ctx context.Context) ([]string, error) { return r.Database.Items().GetTags(ctx) } +func (r *queryResolver) IssueItem(ctx context.Context, id string) (*models.IssueItem, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + item, err := r.Database.IssueItems().Find(ctx, id) + if err != nil { + return nil, err + } + + issue, err := r.Database.Issues().Find(ctx, item.IssueID) + if err != nil { + return nil, err + } + + _, err = r.Auth.IssuePermission(ctx, *issue) + if err != nil { + return nil, err + } + + return item, nil +} + +func (r *queryResolver) IssueItems(ctx context.Context, filter *models.IssueItemFilter) ([]*models.IssueItem, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + if filter == nil { + filter = &models.IssueItemFilter{} + } + + items, err := r.Database.IssueItems().List(ctx, *filter) + if err != nil { + return nil, err + } + + accessMap := make(map[string]bool) + deleteList := make([]int, 0, len(items)) + for i, item := range items { + if access, ok := accessMap[item.IssueID]; ok && access { + continue + } else if ok && !access { + deleteList = append(deleteList, i-len(deleteList)) + continue + } + + issue, err := r.Database.Issues().Find(ctx, item.IssueID) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + accessMap[item.IssueID] = true + continue + } + + _, err = r.Auth.IssuePermission(ctx, *issue) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + } + + accessMap[issue.ID] = err != nil + } + for _, index := range deleteList { + items = append(items[:index], items[index+1:]...) + } + + return items, nil +} + func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project, error) { user := r.Auth.UserFromContext(ctx) if user == nil { diff --git a/graph/schema/issue.gql b/graph/schema/issue.gql index 5a272a0..7be8344 100644 --- a/graph/schema/issue.gql +++ b/graph/schema/issue.gql @@ -24,6 +24,8 @@ type Issue { status: ProjectStatus! "Issue tasks." tasks(filter: IssueTaskFilter): [IssueTask!]! + "Issue items." + items(filter: IssueIssueItemFilter): [IssueItem!]! #"Logs related to this issue." #logs: [Log!]! } diff --git a/graph/schema/issueitem.gql b/graph/schema/issueitem.gql new file mode 100644 index 0000000..546861d --- /dev/null +++ b/graph/schema/issueitem.gql @@ -0,0 +1,75 @@ +""" +An issue item is a requirement of an item under an issue. +""" +type IssueItem { + "ID of the issue item listing." + id: String! + "The amount of the item associated with an issue." + quantity: Int! + "Whether the full quantity of item has been acquired." + acquired: Boolean! + + "Parent issue of the issue item." + issue: Issue! + "The item associated with the issue." + item: Item! + "The amount of items remaining." + remaining: Int! +} + +"Input for the items query." +input IssueItemFilter { + "Filter to only these IDs, used primarily by IDs." + issueItemIds: [String!] + "Filter to only these issues." + issueIds: [String!] + "Filter to only issues where these are the asignees." + issueAssignees: [String!] + "Filter to only issues where these are the owners." + issueOwners: [String!] + "Filter by issue minimum stage (inclusive)." + issueMinStage: Int + "Filter by issue maximum stage (inclusive)." + issueMaxStage: Int + "Filter to only list issue items with these items." + itemIds: [String!] + "Filter to only list issue items where the item has these tags." + itemTags: [String!] + "Only listed acquired or non-acquired items." + acquired: Boolean +} + +"Input for the items query." +input IssueIssueItemFilter { + "Filter to only these IDs, used primarily by IDs." + issueItemIds: [String!] + "Filter to only list issue items with these items." + itemIds: [String!] + "Filter to only list issue items where the item has these tags." + itemTags: [String!] + "Only listed acquired or non-acquired items." + acquired: Boolean +} + +"Input for the createIssueItem mutation." +input IssueItemCreateInput { + "Parent issue." + issueId: String! + "Item to associate with." + itemId: String! + "Quantity of the item." + quanitty: Int! + "Whether the item has already been acquired." + acquired: Boolean +} + +"Input for the editIssueItem mutation." +input IssueItemEditInput { + "The ID of the issue item to edit." + issueItemId: String! + "Update the quantity of the item." + setQuanitty: Int + "Update whether the item has been acquired." + setAcquired: Boolean +} + diff --git a/graph/schema/mutation.gql b/graph/schema/mutation.gql index 11864fb..c5d7044 100644 --- a/graph/schema/mutation.gql +++ b/graph/schema/mutation.gql @@ -24,6 +24,10 @@ type Mutation { "Edit an issue task." editIssueTask(input: IssueTaskEditInput!): IssueTask! # ISSUE ITEM + "Create an issue item." + createIssueItem(input: IssueItemCreateInput!): IssueItem! + "Edit an issue item." + editIssueItem(input: IssueItemEditInput!): IssueItem! # LOG diff --git a/graph/schema/query.gql b/graph/schema/query.gql index f1073d3..fed22a0 100644 --- a/graph/schema/query.gql +++ b/graph/schema/query.gql @@ -11,6 +11,11 @@ type Query { "List item tags." itemTags: [String!]! + "Find issue item." + issueItem(id: String!): IssueItem! + "List issue items." + issueItems(filter: IssueItemFilter): [IssueItem!]! + "Find project." project(id: String!): Project! "List projects." diff --git a/migrations/mysql/20200517111706_create_table_issue_item.sql b/migrations/mysql/20200517111706_create_table_issue_item.sql new file mode 100644 index 0000000..479cb08 --- /dev/null +++ b/migrations/mysql/20200517111706_create_table_issue_item.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE issue_item ( + issue_item_id CHAR(48) NOT NULL PRIMARY KEY, + issue_id CHAR(32) NOT NULL, + item_id CHAR(32) NOT NULL, + quantity INTEGER NOT NULL, + acquired BOOLEAN NOT NULL, + + INDEX (acquired), + INDEX (issue_id), + INDEX (item_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE issue_item; +-- +goose StatementEnd diff --git a/models/issueitem.go b/models/issueitem.go index 0a7de91..c63650f 100644 --- a/models/issueitem.go +++ b/models/issueitem.go @@ -6,5 +6,17 @@ type IssueItem struct { IssueID string `db:"issue_id"` ItemID string `db:"item_id"` Quantity int `db:"quantity"` - Acquired bool `db:"resolved"` + Acquired bool `db:"acquired"` +} + +type IssueItemFilter struct { + IssueItemIDs []string + IssueIDs []string + IssueAssignees []string + IssueOwners []string + IssueMinStage *int + IssueMaxStage *int + ItemIDs []string + ItemTags []string + Acquired *bool }