From a4c019e67b471574964d60aa2b70051428a51c5c Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 24 May 2020 16:30:25 +0200 Subject: [PATCH] add logging. --- database/database.go | 1 + database/drivers/mysqldriver/db.go | 7 + database/drivers/mysqldriver/logs.go | 281 ++++++++++++++++++ database/repositories/logrepository.go | 14 + go.mod | 3 +- go.sum | 11 + graph/resolvers/issue.resolvers.go | 11 +- graph/resolvers/log.resolvers.go | 44 +++ graph/resolvers/mutation.resolvers.go | 156 ++++++++++ graph/resolvers/query.resolvers.go | 40 +++ graph/schema/issue.gql | 4 +- graph/schema/log.gql | 110 +++++++ graph/schema/mutation.gql | 6 + graph/schema/query.gql | 5 + internal/generate/ids.go | 4 + main.go | 17 +- ...20200517111706_create_table_issue_item.sql | 16 +- .../mysql/20200523151259_create_table_log.sql | 17 ++ .../20200523151309_create_table_log_item.sql | 18 ++ .../20200524122544_create_table_log_task.sql | 19 ++ models/log.go | 39 ++- services/auth.go | 90 ++++++ 22 files changed, 883 insertions(+), 30 deletions(-) create mode 100644 database/drivers/mysqldriver/logs.go create mode 100644 database/repositories/logrepository.go create mode 100644 graph/resolvers/log.resolvers.go create mode 100644 graph/schema/log.gql create mode 100644 migrations/mysql/20200523151259_create_table_log.sql create mode 100644 migrations/mysql/20200523151309_create_table_log_item.sql create mode 100644 migrations/mysql/20200524122544_create_table_log_task.sql diff --git a/database/database.go b/database/database.go index b3e0feb..c95f2e7 100644 --- a/database/database.go +++ b/database/database.go @@ -18,6 +18,7 @@ type Database interface { Session() repositories.SessionRepository Users() repositories.UserRepository ProjectStatuses() repositories.ProjectStatusRepository + Logs() repositories.LogRepository // Migrate the database. Migrate() error } diff --git a/database/drivers/mysqldriver/db.go b/database/drivers/mysqldriver/db.go index 7ef4ba4..0879a72 100644 --- a/database/drivers/mysqldriver/db.go +++ b/database/drivers/mysqldriver/db.go @@ -23,6 +23,7 @@ type DB struct { sessions *sessionRepository users *userRepository projectStatuses *projectStatusRepository + logs *logRepository } func (db *DB) Activities() repositories.ActivityRepository { @@ -61,6 +62,10 @@ func (db *DB) ProjectStatuses() repositories.ProjectStatusRepository { return db.projectStatuses } +func (db *DB) Logs() repositories.LogRepository { + return db.logs +} + func (db *DB) Migrate() error { err := goose.SetDialect("mysql") if err != nil { @@ -101,6 +106,7 @@ func Open(connectionString string) (*DB, error) { projectStatuses := &projectStatusRepository{db: db} issueTasks := &issueTaskRepository{db: db} issueItems := &issueItemRepository{db: db} + logs := &logRepository{db: db} return &DB{ db: db, @@ -113,6 +119,7 @@ func Open(connectionString string) (*DB, error) { users: users, sessions: sessions, projectStatuses: projectStatuses, + logs: logs, }, nil } diff --git a/database/drivers/mysqldriver/logs.go b/database/drivers/mysqldriver/logs.go new file mode 100644 index 0000000..e2d643d --- /dev/null +++ b/database/drivers/mysqldriver/logs.go @@ -0,0 +1,281 @@ +package mysqldriver + +import ( + "context" + "database/sql" + "git.aiterp.net/stufflog/server/internal/generate" + "git.aiterp.net/stufflog/server/internal/slerrors" + "git.aiterp.net/stufflog/server/models" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type logRepository struct { + db *sqlx.DB +} + +func (r *logRepository) Find(ctx context.Context, id string) (*models.Log, error) { + log := models.Log{} + err := r.db.GetContext(ctx, &log, "SELECT * FROM log WHERE log_id=?", id) + if err != nil { + if err == sql.ErrNoRows { + return nil, slerrors.NotFound("Log") + } + + return nil, err + } + + err = r.db.SelectContext(ctx, &log.Items, "SELECT * FROM log_item WHERE log_id=?", id) + if err != nil { + return nil, err + } + err = r.db.SelectContext(ctx, &log.Tasks, "SELECT * FROM log_task WHERE log_id=?", id) + if err != nil { + return nil, err + } + + return &log, nil +} + +func (r *logRepository) List(ctx context.Context, filter models.LogFilter) ([]*models.Log, error) { + q := sq.Select("log.*").From("log").GroupBy("log.log_id").OrderBy("log.date") + if len(filter.IssueItemIDs) > 0 || len(filter.IssueIDs) > 0 { + q = q.LeftJoin("log_item ON log_item.log_id = log.log_id") + } + if len(filter.IssueTaskIDs) > 0 || len(filter.IssueIDs) > 0 { + q = q.LeftJoin("log_task ON log_task.log_id = log.log_id") + } + if len(filter.IssueIDs) > 0 { + q = q.Where(sq.Or{ + sq.Eq{"log_task.issue_id": filter.IssueIDs}, + sq.Eq{"log_item.issue_id": filter.IssueIDs}, + }) + } + if len(filter.IssueItemIDs) > 0 { + q = q.Where(sq.Eq{"log_item.issue_item_id": filter.IssueItemIDs}) + } + if len(filter.IssueTaskIDs) > 0 { + q = q.Where(sq.Eq{"log_task.issue_task_id": filter.IssueTaskIDs}) + } + if len(filter.LogIDs) > 0 { + q = q.Where(sq.Eq{"log.log_id": filter.LogIDs}) + } + if len(filter.UserIDs) > 0 { + q = q.Where(sq.Eq{"log.user_id": filter.UserIDs}) + } + if filter.FromDate != nil { + q = q.Where(sq.GtOrEq{"log.date": *filter.FromDate}) + } + if filter.ToDate != nil { + q = q.Where(sq.LtOrEq{"log.date": *filter.ToDate}) + } + + query, args, err := q.ToSql() + if err != nil { + return nil, err + } + + results := make([]*models.Log, 0, 16) + err = r.db.SelectContext(ctx, &results, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return []*models.Log{}, nil + } + + return nil, err + } + + err = r.fill(ctx, results) + if err != nil { + return nil, err + } + + return results, nil +} + +func (r *logRepository) Insert(ctx context.Context, log models.Log) (*models.Log, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + + log.ID = generate.LogID() + + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO log ( + log_id, user_id, date, description + ) VALUES ( + :log_id, :user_id, :date, :description + ) + `, log) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + for _, item := range log.Items { + item.LogID = log.ID + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO log_item ( + log_id, issue_id, issue_item_id, amount + ) VALUES ( + :log_id, :issue_id, :issue_item_id, :amount + ) + `, item) + if err != nil { + _ = tx.Rollback() + return nil, err + } + } + for _, task := range log.Tasks { + task.LogID = log.ID + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO log_task ( + log_id, issue_id, issue_task_id, units, duration + ) VALUES ( + :log_id, :issue_id, :issue_task_id, :units, :duration + ) + `, task) + if err != nil { + _ = tx.Rollback() + return nil, err + } + } + + err = tx.Commit() + if err != nil { + _ = tx.Rollback() + return nil, err + } + + return &log, nil +} + +func (r *logRepository) Save(ctx context.Context, log models.Log) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + + _, err = tx.NamedExecContext(ctx, ` + UPDATE log SET + date=:date, + description=:description + WHERE log_id=:log_id + `, log) + if err != nil { + _ = tx.Rollback() + return err + } + + _, err = tx.ExecContext(ctx, "DELETE FROM log_item WHERE log_id=?", log.ID) + if err != nil { + _ = tx.Rollback() + return err + } + _, err = tx.ExecContext(ctx, "DELETE FROM log_task WHERE log_id=?", log.ID) + if err != nil { + _ = tx.Rollback() + return err + } + + for _, item := range log.Items { + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO log_item ( + log_id, issue_id, issue_item_id, amount + ) VALUES ( + :log_id, :issue_id, :issue_item_id, :amount + ) + `, item) + if err != nil { + _ = tx.Rollback() + return err + } + } + for _, task := range log.Tasks { + _, err = tx.NamedExecContext(ctx, ` + INSERT INTO log_task ( + log_id, issue_id, issue_task_id, units, duration + ) VALUES ( + :log_id, :issue_id, :issue_task_id, :units, :duration + ) + `, task) + if err != nil { + _ = tx.Rollback() + return err + } + } + + err = tx.Commit() + if err != nil { + _ = tx.Rollback() + return err + } + + return nil +} + +func (r *logRepository) Delete(ctx context.Context, log models.Log) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, "DELETE FROM log WHERE log_id=?", log.ID) + if err != nil { + _ = tx.Rollback() + return err + } + _, err = tx.ExecContext(ctx, "DELETE FROM log_item WHERE log_id=?", log.ID) + if err != nil { + _ = tx.Rollback() + return err + } + _, err = tx.ExecContext(ctx, "DELETE FROM log_task WHERE log_id=?", log.ID) + if err != nil { + _ = tx.Rollback() + return err + } + + return tx.Commit() +} + +func (r *logRepository) fill(ctx context.Context, logs []*models.Log) error { + logMap := make(map[string]int, len(logs)) + ids := make([]string, len(logs)) + for i, log := range logs { + ids[i] = log.ID + logMap[log.ID] = i + } + + itemsQuery, itemsArgs, err := sq.Select("*").From("log_item").Where(sq.Eq{"log_id": ids}).ToSql() + if err != nil { + return err + } + tasksQuery, tasksArgs, err := sq.Select("*").From("log_task").Where(sq.Eq{"log_id": ids}).ToSql() + if err != nil { + return err + } + + items := make([]models.LogItem, 0, len(logs)*3) + err = r.db.SelectContext(ctx, &items, itemsQuery, itemsArgs...) + if err != nil { + return err + } + for _, item := range items { + log := logs[logMap[item.LogID]] + log.Items = append(log.Items, item) + } + + tasks := make([]models.LogTask, 0, len(logs)*3) + err = r.db.SelectContext(ctx, &tasks, tasksQuery, tasksArgs...) + if err != nil { + return err + } + for _, task := range tasks { + log := logs[logMap[task.LogID]] + log.Tasks = append(log.Tasks, task) + } + + return nil +} diff --git a/database/repositories/logrepository.go b/database/repositories/logrepository.go new file mode 100644 index 0000000..5436abd --- /dev/null +++ b/database/repositories/logrepository.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "context" + "git.aiterp.net/stufflog/server/models" +) + +type LogRepository interface { + Find(ctx context.Context, id string) (*models.Log, error) + List(ctx context.Context, filter models.LogFilter) ([]*models.Log, error) + Insert(ctx context.Context, log models.Log) (*models.Log, error) + Save(ctx context.Context, log models.Log) error + Delete(ctx context.Context, log models.Log) error +} diff --git a/go.mod b/go.mod index 5fa39c0..4fd8a62 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,10 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/pkg/errors v0.9.1 github.com/pressly/goose v2.6.0+incompatible + github.com/smartystreets/goconvey v1.6.4 // indirect github.com/stretchr/testify v1.5.1 github.com/urfave/cli/v2 v2.2.0 - github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e github.com/vektah/gqlparser/v2 v2.0.1 golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 + gopkg.in/ini.v1 v1.56.0 // indirect ) diff --git a/go.sum b/go.sum index a9a5274..a32c769 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ= @@ -49,6 +51,8 @@ github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 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/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -98,6 +102,10 @@ github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -137,6 +145,7 @@ golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 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= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -144,6 +153,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.56.0 h1:DPMeDvGTM54DXbPkVIZsp19fp/I2K7zwA/itHYHKo8Y= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= diff --git a/graph/resolvers/issue.resolvers.go b/graph/resolvers/issue.resolvers.go index 658f971..b54c165 100644 --- a/graph/resolvers/issue.resolvers.go +++ b/graph/resolvers/issue.resolvers.go @@ -5,7 +5,6 @@ package resolvers import ( "context" - "git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/graph/graphutil" "git.aiterp.net/stufflog/server/graph/loaders" @@ -94,6 +93,16 @@ func (r *issueResolver) Items(ctx context.Context, obj *models.Issue, filter *mo return r.Database.IssueItems().List(ctx, *filter) } +func (r *issueResolver) Logs(ctx context.Context, obj *models.Issue) ([]*models.Log, error) { + logs, err := r.Database.Logs().List(ctx, models.LogFilter{IssueIDs: []string{obj.ID}}) + if err != nil { + return nil, err + } + r.Auth.FilterLogList(ctx, &logs) + + return logs, nil +} + // Issue returns graphcore.IssueResolver implementation. func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} } diff --git a/graph/resolvers/log.resolvers.go b/graph/resolvers/log.resolvers.go new file mode 100644 index 0000000..3ef9c6e --- /dev/null +++ b/graph/resolvers/log.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" + + "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/models" +) + +func (r *logResolver) User(ctx context.Context, obj *models.Log) (*models.User, error) { + return r.Database.Users().Find(ctx, obj.UserID) +} + +func (r *logItemResolver) Issue(ctx context.Context, obj *models.LogItem) (*models.Issue, error) { + return r.Database.Issues().Find(ctx, obj.IssueID) +} + +func (r *logItemResolver) Item(ctx context.Context, obj *models.LogItem) (*models.IssueItem, error) { + return r.Database.IssueItems().Find(ctx, obj.IssueItemID) +} + +func (r *logTaskResolver) Issue(ctx context.Context, obj *models.LogTask) (*models.Issue, error) { + return r.Database.Issues().Find(ctx, obj.IssueID) +} + +func (r *logTaskResolver) Task(ctx context.Context, obj *models.LogTask) (*models.IssueTask, error) { + return r.Database.IssueTasks().Find(ctx, obj.IssueTaskID) +} + +// Log returns graphcore.LogResolver implementation. +func (r *Resolver) Log() graphcore.LogResolver { return &logResolver{r} } + +// LogItem returns graphcore.LogItemResolver implementation. +func (r *Resolver) LogItem() graphcore.LogItemResolver { return &logItemResolver{r} } + +// LogTask returns graphcore.LogTaskResolver implementation. +func (r *Resolver) LogTask() graphcore.LogTaskResolver { return &logTaskResolver{r} } + +type logResolver struct{ *Resolver } +type logItemResolver struct{ *Resolver } +type logTaskResolver struct{ *Resolver } diff --git a/graph/resolvers/mutation.resolvers.go b/graph/resolvers/mutation.resolvers.go index 71bbb48..a3131d1 100644 --- a/graph/resolvers/mutation.resolvers.go +++ b/graph/resolvers/mutation.resolvers.go @@ -475,6 +475,162 @@ func (r *mutationResolver) EditIssueItem(ctx context.Context, input graphcore.Is return item, nil } +func (r *mutationResolver) CreateLog(ctx context.Context, input graphcore.LogCreateInput) (*models.Log, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + log := &models.Log{ + UserID: user.ID, + Date: input.Date, + Description: input.Description, + Items: make([]models.LogItem, 0, len(input.Items)), + Tasks: make([]models.LogTask, 0, len(input.Tasks)), + } + + for _, itemInput := range input.Items { + item, err := r.Database.IssueItems().Find(ctx, itemInput.IssueItemID) + 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 + } + + log.Items = append(log.Items, models.LogItem{ + IssueID: item.IssueID, + IssueItemID: item.ID, + Amount: itemInput.Amount, + }) + } + for _, taskInput := range input.Tasks { + task, err := r.Database.IssueTasks().Find(ctx, taskInput.IssueTaskID) + if err != nil { + return nil, err + } + + issue, err := r.Database.Issues().Find(ctx, task.IssueID) + if err != nil { + return nil, err + } + _, err = r.Auth.IssuePermission(ctx, *issue) + if err != nil { + return nil, err + } + + log.Tasks = append(log.Tasks, models.LogTask{ + IssueID: task.IssueID, + IssueTaskID: task.ID, + Duration: taskInput.Duration, + Units: taskInput.Units, + }) + } + + return r.Database.Logs().Insert(ctx, *log) +} + +func (r *mutationResolver) EditLog(ctx context.Context, input graphcore.LogEditInput) (*models.Log, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + log, err := r.Database.Logs().Find(ctx, input.LogID) + if err != nil { + return nil, err + } + + removeItems := append([]string{}, input.RemoveItems...) + for _, itemInput := range input.UpdateItems { + removeItems = append(removeItems, itemInput.IssueItemID) + } + removeTasks := append([]string{}, input.RemoveTasks...) + for _, taskInput := range input.UpdateTasks { + removeTasks = append(removeTasks, taskInput.IssueTaskID) + } + + if input.SetDate != nil { + log.Date = *input.SetDate + } + if input.SetDescription != nil { + log.Description = *input.SetDescription + } + for _, remove := range removeItems { + for i, item := range log.Items { + if remove == item.IssueItemID { + log.Items = append(log.Items[:i], log.Items[i+1:]...) + } + } + } + for _, remove := range removeTasks { + for i, task := range log.Tasks { + if remove == task.IssueTaskID { + log.Tasks = append(log.Tasks[:i], log.Tasks[i+1:]...) + } + } + } + + for _, itemInput := range input.UpdateItems { + item, err := r.Database.IssueItems().Find(ctx, itemInput.IssueItemID) + 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 + } + + log.Items = append(log.Items, models.LogItem{ + LogID: log.ID, + IssueID: item.IssueID, + IssueItemID: item.ID, + Amount: itemInput.Amount, + }) + } + for _, taskInput := range input.UpdateTasks { + task, err := r.Database.IssueTasks().Find(ctx, taskInput.IssueTaskID) + if err != nil { + return nil, err + } + + issue, err := r.Database.Issues().Find(ctx, task.IssueID) + if err != nil { + return nil, err + } + _, err = r.Auth.IssuePermission(ctx, *issue) + if err != nil { + return nil, err + } + + log.Tasks = append(log.Tasks, models.LogTask{ + LogID: log.ID, + IssueID: task.IssueID, + IssueTaskID: task.ID, + Duration: taskInput.Duration, + Units: taskInput.Units, + }) + } + + err = r.Database.Logs().Save(ctx, *log) + if err != nil { + return nil, err + } + + return log, 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 3c3337d..6183c97 100644 --- a/graph/resolvers/query.resolvers.go +++ b/graph/resolvers/query.resolvers.go @@ -215,6 +215,46 @@ func (r *queryResolver) Projects(ctx context.Context, filter *models.ProjectFilt return projects, nil } +func (r *queryResolver) Log(ctx context.Context, id string) (*models.Log, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + log, err := r.Database.Logs().Find(ctx, id) + if err != nil { + return nil, err + } + + r.Auth.FilterLog(ctx, log) + if log.Empty() { + return nil, slerrors.NotFound("Log") + } + + return log, nil +} + +func (r *queryResolver) Logs(ctx context.Context, filter *models.LogFilter) ([]*models.Log, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + if filter == nil { + filter = &models.LogFilter{ + UserIDs: []string{user.ID}, + } + } + + logs, err := r.Database.Logs().List(ctx, *filter) + if err != nil { + return nil, err + } + r.Auth.FilterLogList(ctx, &logs) + + return logs, nil +} + func (r *queryResolver) Session(ctx context.Context) (*models.User, error) { user := r.Auth.UserFromContext(ctx) if user == nil { diff --git a/graph/schema/issue.gql b/graph/schema/issue.gql index 7be8344..13c15f9 100644 --- a/graph/schema/issue.gql +++ b/graph/schema/issue.gql @@ -26,8 +26,8 @@ type Issue { tasks(filter: IssueTaskFilter): [IssueTask!]! "Issue items." items(filter: IssueIssueItemFilter): [IssueItem!]! - #"Logs related to this issue." - #logs: [Log!]! + "Logs related to this issue." + logs: [Log!]! } input IssueFilter { diff --git a/graph/schema/log.gql b/graph/schema/log.gql new file mode 100644 index 0000000..ca5939a --- /dev/null +++ b/graph/schema/log.gql @@ -0,0 +1,110 @@ +""" +A log is a chunk of logged work, on one or more issues. +""" +type Log { + "The log's ID." + id: String! + "When the log is taking place." + date: Time! + "A description of the log." + description: String! + + "The user that logged the work." + user: User! + "The tasks logged." + tasks: [LogTask!]! + "The items changed." + items: [LogItem!]! +} + +type LogTask { + "Parent issue of the task." + issue: Issue! + "The issue task logged." + task: IssueTask! + "How many units of work is done, if applicable." + units: Int + "Time spent on the issue." + duration: Duration! +} + +""" +Log items are item changes related to a log. +""" +type LogItem { + "Parent issue of the item." + issue: Issue! + "The item that has been acquired." + item: IssueItem! + "The amount of items acquired." + amount: Int! +} + +"Filter for the logs query." +input LogFilter { + "Log IDs to select." + logIds: [String!] + "Limit to the user IDs." + userIds: [String!] + "The issue IDs to limit to." + issueIds: [String!] + "The issue task IDs to limit to." + issueTaskIds: [String!] + "The issue item IDs to limit to." + issueItemIds: [String!] + "Earliest date to get logs from (inclusive)." + fromDate: Time + "Latest date to get logs from (inclusive)." + toDate: Time +} + +"Input for the createLog mutation." +input LogCreateInput { + "When did it take place." + date: Time! + "Describe the logged work." + description: String! + + "Add issue items to the log." + items: [LogItemInput!] + "Add issue tasks to the log." + tasks: [LogTaskInput!] +} + +"Input for the editLog mutation." +input LogEditInput { + "The log to update." + logId: String! + + "Update the time of the log." + setDate: Time + "Update the description of the log." + setDescription: String + + "Add/update one or more items to the log." + updateItems: [LogItemInput!] + "Remove one or more items from the log." + removeItems: [String!] + "Add/update one or more items to the log." + updateTasks: [LogTaskInput!] + "Remove one or more items from the log." + removeTasks: [String!] +} + +"Sub-input for the createLog and editLog mutation." +input LogItemInput { + "The issue item to log ID." + issueItemId: String! + "The amount of items acquired." + amount: Int! +} + +"Sub-input for the createLog and editLog mutation." +input LogTaskInput { + "The issue item to log ID." + issueTaskId: String! + "The amount of units done, if applicable." + units: Int + "How long did it take?" + duration: Duration! +} \ No newline at end of file diff --git a/graph/schema/mutation.gql b/graph/schema/mutation.gql index c5d7044..22deceb 100644 --- a/graph/schema/mutation.gql +++ b/graph/schema/mutation.gql @@ -30,6 +30,12 @@ type Mutation { editIssueItem(input: IssueItemEditInput!): IssueItem! # LOG + "Create a log." + createLog(input: LogCreateInput!): Log! + "Edit a log." + editLog(input: LogEditInput!): Log! + + # GOAL # USER "Log in." diff --git a/graph/schema/query.gql b/graph/schema/query.gql index fed22a0..023d90c 100644 --- a/graph/schema/query.gql +++ b/graph/schema/query.gql @@ -21,6 +21,11 @@ type Query { "List projects." projects(filter: ProjectFilter): [Project!]! + "Find log." + log(id: String!): Log! + "List logs." + logs(filter: LogFilter): [Log!]! + "Check the user session." session: User } \ No newline at end of file diff --git a/internal/generate/ids.go b/internal/generate/ids.go index e9d086d..0b22292 100644 --- a/internal/generate/ids.go +++ b/internal/generate/ids.go @@ -8,6 +8,10 @@ func ItemID() string { return Generate(32, "I") } +func LogID() string { + return Generate(24, "L") +} + func SessionID() string { return Generate(32, "S") } diff --git a/main.go b/main.go index 856e2eb..5da45de 100644 --- a/main.go +++ b/main.go @@ -63,7 +63,7 @@ func main() { }, &cli.StringFlag{ Name: "s3-host", - Value: "localhost", + Value: "", Usage: "S3 Host (without https://)", EnvVars: []string{"S3_HOST"}, Destination: &s3Host, @@ -201,12 +201,15 @@ func main() { return errors.Wrap(err, "Failed to connect to database") } - s3, err := space.Connect( - s3Host, s3AccessKey, s3SecretKey, s3BucketName, - s3Secure, s3MaxFileSize, s3RootDirectory, s3UrlRoot, - ) - if err != nil { - return err + var s3 *space.Space + if s3Host != "" { + s3, err = space.Connect( + s3Host, s3AccessKey, s3SecretKey, s3BucketName, + s3Secure, s3MaxFileSize, s3RootDirectory, s3UrlRoot, + ) + if err != nil { + return err + } } bundle := services.NewBundle(db, s3) diff --git a/migrations/mysql/20200517111706_create_table_issue_item.sql b/migrations/mysql/20200517111706_create_table_issue_item.sql index 479cb08..dad1246 100644 --- a/migrations/mysql/20200517111706_create_table_issue_item.sql +++ b/migrations/mysql/20200517111706_create_table_issue_item.sql @@ -1,15 +1,15 @@ -- +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, + 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) + INDEX (acquired), + INDEX (issue_id), + INDEX (item_id) ); -- +goose StatementEnd diff --git a/migrations/mysql/20200523151259_create_table_log.sql b/migrations/mysql/20200523151259_create_table_log.sql new file mode 100644 index 0000000..e4567d7 --- /dev/null +++ b/migrations/mysql/20200523151259_create_table_log.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE log ( + log_id CHAR(24) NOT NULL PRIMARY KEY, + user_id CHAR(32) NOT NULL, + date TIMESTAMP NOT NULL, + description TEXT NOT NULL, + + INDEX (user_id), + INDEX (date) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE log; +-- +goose StatementEnd diff --git a/migrations/mysql/20200523151309_create_table_log_item.sql b/migrations/mysql/20200523151309_create_table_log_item.sql new file mode 100644 index 0000000..dab3a16 --- /dev/null +++ b/migrations/mysql/20200523151309_create_table_log_item.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE log_item ( + log_id CHAR(24) NOT NULL, + issue_id CHAR(32) NOT NULL, + issue_item_id CHAR(48) NOT NULL, + amount INTEGER NOT NULL, + + PRIMARY KEY(log_id, issue_item_id), + INDEX (issue_item_id), + INDEX (issue_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE log_item; +-- +goose StatementEnd diff --git a/migrations/mysql/20200524122544_create_table_log_task.sql b/migrations/mysql/20200524122544_create_table_log_task.sql new file mode 100644 index 0000000..618e275 --- /dev/null +++ b/migrations/mysql/20200524122544_create_table_log_task.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE log_task ( + log_id CHAR(24) NOT NULL, + issue_id CHAR(32) NOT NULL, + issue_task_id CHAR(48) NOT NULL, + units INTEGER, + duration BIGINT NOT NULL, + + PRIMARY KEY(log_id, issue_task_id), + INDEX (issue_task_id), + INDEX (issue_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE log_task; +-- +goose StatementEnd diff --git a/models/log.go b/models/log.go index 22bf80b..a7177b1 100644 --- a/models/log.go +++ b/models/log.go @@ -3,23 +3,40 @@ package models import "time" type Log struct { - ID string `db:"log_id"` - IssueID string `db:"issue_id"` - UserID string `db:"user_id"` - IssueTaskID *string `db:"issue_task_id"` - UnitsDone *int `db:"units_done"` - Duration time.Duration `db:"duration"` - Description []string `db:"description"` + ID string `db:"log_id"` + UserID string `db:"user_id"` + Date time.Time `db:"date"` + Description string `db:"description"` + + Items []LogItem + Tasks []LogTask } -type LogFilter struct { - LogIDs []string - IssueIDs []string - IssueTaskIDs []string +func (log *Log) Empty() bool { + return len(log.Items) == 0 && len(log.Tasks) == 0 +} + +type LogTask struct { + LogID string `db:"log_id"` + IssueID string `db:"issue_id"` + IssueTaskID string `db:"issue_task_id"` + Units *int `db:"units"` + Duration time.Duration `db:"duration"` } type LogItem struct { LogID string `db:"log_id"` + IssueID string `db:"issue_id"` IssueItemID string `db:"issue_item_id"` Amount int `db:"amount"` } + +type LogFilter struct { + LogIDs []string + UserIDs []string + IssueIDs []string + IssueTaskIDs []string + IssueItemIDs []string + FromDate *time.Time + ToDate *time.Time +} diff --git a/services/auth.go b/services/auth.go index 9589c83..46b5100 100644 --- a/services/auth.go +++ b/services/auth.go @@ -26,6 +26,7 @@ type Auth struct { users repositories.UserRepository session repositories.SessionRepository projects repositories.ProjectRepository + issues repositories.IssueRepository } func (auth *Auth) Login(ctx context.Context, username, password string) (*models.User, error) { @@ -225,3 +226,92 @@ func (auth *Auth) EditUser(ctx context.Context, username string, setName *string return user, nil } + +func (auth *Auth) FilterLogList(ctx context.Context, logs *[]*models.Log) { + user := auth.UserFromContext(ctx) + if user == nil { + panic("Auth.FilterLogList called without user") + } + + auth.FilterLog(ctx, *logs...) + deleteList := make([]int, 0, len(*logs)/2) + for i, log := range *logs { + if log.Empty() && log.UserID != user.ID { + deleteList = append(deleteList, i-len(deleteList)) + } + } + + list := *logs + for _, index := range deleteList { + list = append(list[:index], list[index+1:]...) + } + *logs = list +} + +func (auth *Auth) FilterLog(ctx context.Context, logs ...*models.Log) { + userID := "" + if user := auth.UserFromContext(ctx); user != nil { + userID = user.ID + } + + accessMap := make(map[string]bool) + deleteList := make([]int, 0, 16) + for _, log := range logs { + if userID == log.UserID { + continue + } + + deleteList = deleteList[:0] + for i, item := range log.Items { + if access, ok := accessMap[item.IssueID]; ok && access { + continue + } else if ok && !access { + deleteList = append(deleteList, i-len(deleteList)) + continue + } + + issue, err := auth.issues.Find(ctx, item.IssueID) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + accessMap[item.IssueID] = true + continue + } + + _, err = auth.IssuePermission(ctx, *issue) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + } + + accessMap[issue.ID] = err != nil + } + for _, index := range deleteList { + log.Items = append(log.Items[:index], log.Items[index+1:]...) + } + deleteList = deleteList[:0] + for i, task := range log.Tasks { + if access, ok := accessMap[task.IssueID]; ok && access { + continue + } else if ok && !access { + deleteList = append(deleteList, i-len(deleteList)) + continue + } + + issue, err := auth.issues.Find(ctx, task.IssueID) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + accessMap[task.IssueID] = true + continue + } + + _, err = auth.IssuePermission(ctx, *issue) + if err != nil { + deleteList = append(deleteList, i-len(deleteList)) + } + + accessMap[issue.ID] = err != nil + } + for _, index := range deleteList { + log.Tasks = append(log.Tasks[:index], log.Tasks[index+1:]...) + } + } +}