diff --git a/database/database.go b/database/database.go index d058095..569820e 100644 --- a/database/database.go +++ b/database/database.go @@ -9,6 +9,7 @@ import ( var ErrDriverNotSupported = errors.New("driver not found or supported") type Database interface { + Activities() repositories.ActivityRepository Issues() repositories.IssueRepository Items() repositories.ItemRepository Projects() repositories.ProjectRepository diff --git a/database/drivers/mysqldriver/activities.go b/database/drivers/mysqldriver/activities.go new file mode 100644 index 0000000..92bf395 --- /dev/null +++ b/database/drivers/mysqldriver/activities.go @@ -0,0 +1,100 @@ +package mysqldriver + +import ( + "context" + "database/sql" + "errors" + "git.aiterp.net/stufflog/server/internal/generate" + "git.aiterp.net/stufflog/server/internal/xlerrors" + "git.aiterp.net/stufflog/server/models" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type activityRepository struct { + db *sqlx.DB +} + +func (r *activityRepository) Find(ctx context.Context, id string) (*models.Activity, error) { + activity := models.Activity{} + err := r.db.GetContext(ctx, &activity, "SELECT * FROM activity WHERE activity_id=?", id) + if err != nil { + if err == sql.ErrNoRows { + return nil, xlerrors.NotFound("Activity") + } + + return nil, err + } + + return &activity, nil +} + +func (r *activityRepository) List(ctx context.Context, filter models.ActivityFilter) ([]*models.Activity, error) { + q := sq.Select("*").From("activity") + if len(filter.ActivityIDs) > 0 { + q = q.Where(sq.Eq{"activity_id": filter.ActivityIDs}) + } + if len(filter.ProjectIDs) > 0 { + q = q.Where(sq.Eq{"project_id": filter.ProjectIDs}) + } + + query, args, err := q.ToSql() + if err != nil { + return nil, err + } + + results := make([]*models.Activity, 0, 16) + err = r.db.SelectContext(ctx, &results, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return []*models.Activity{}, nil + } + + return nil, err + } + + return results, nil +} + +func (r *activityRepository) Insert(ctx context.Context, activity models.Activity) (*models.Activity, error) { + if activity.ProjectID == "" { + return nil, errors.New("missing project id") + } + + activity.ID = generate.ActivityID() + + _, err := r.db.NamedExecContext(ctx, ` + INSERT INTO activity ( + activity_id, project_id, name, countable, + unit_is_time, unit_name, unit_value, base_value + ) VALUES ( + :activity_id, :project_id, :name, :countable, + :unit_is_time, :unit_name, :unit_value, :base_value + ) + `, activity) + if err != nil { + return nil, err + } + + return &activity, nil +} + +func (r *activityRepository) Save(ctx context.Context, activity models.Activity) error { + _, err := r.db.NamedExecContext(ctx, ` + UPDATE activity SET + name=:name, + countable=:countable, + unit_is_time=:unit_is_time, + unit_name=:unit_name, + unit_value=:unit_value, + base_value=:base_value + WHERE activity_id=:activity_id + `, activity) + + return err +} + +func (r *activityRepository) Delete(ctx context.Context, activity models.Activity) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM activity WHERE activity_id=? LIMIT 1;", activity.ID) + return err +} diff --git a/database/drivers/mysqldriver/db.go b/database/drivers/mysqldriver/db.go index f7837bb..5ead79a 100644 --- a/database/drivers/mysqldriver/db.go +++ b/database/drivers/mysqldriver/db.go @@ -14,6 +14,7 @@ import ( type DB struct { db *sqlx.DB + activities *activityRepository issues *issueRepository items *itemRepository projects *projectRepository @@ -22,6 +23,10 @@ type DB struct { projectStatuses *projectStatusRepository } +func (db *DB) Activities() repositories.ActivityRepository { + return db.activities +} + func (db *DB) Issues() repositories.IssueRepository { return db.issues } @@ -76,6 +81,7 @@ func Open(connectionString string) (*DB, error) { return nil, err } + activities := &activityRepository{db: db} issues := &issueRepository{db: db} items := &itemRepository{db: db} projects := &projectRepository{db: db} @@ -85,6 +91,7 @@ func Open(connectionString string) (*DB, error) { return &DB{ db: db, + activities: activities, issues: issues, items: items, projects: projects, diff --git a/database/repositories/activityrepository.go b/database/repositories/activityrepository.go new file mode 100644 index 0000000..2f6c2e6 --- /dev/null +++ b/database/repositories/activityrepository.go @@ -0,0 +1,14 @@ +package repositories + +import ( + "context" + "git.aiterp.net/stufflog/server/models" +) + +type ActivityRepository interface { + Find(ctx context.Context, id string) (*models.Activity, error) + List(ctx context.Context, filter models.ActivityFilter) ([]*models.Activity, error) + Insert(ctx context.Context, activity models.Activity) (*models.Activity, error) + Save(ctx context.Context, activity models.Activity) error + Delete(ctx context.Context, activity models.Activity) error +} diff --git a/graph/gqlgen.yml b/graph/gqlgen.yml index 23dd4a3..9d233b0 100644 --- a/graph/gqlgen.yml +++ b/graph/gqlgen.yml @@ -9,6 +9,12 @@ model: filename: graphcore/input_gen.go package: graphcore +models: + Activity: + fields: + unitName: + resolver: true + resolver: layout: follow-schema dir: resolvers diff --git a/graph/resolvers/activity.resolvers.go b/graph/resolvers/activity.resolvers.go new file mode 100644 index 0000000..404577f --- /dev/null +++ b/graph/resolvers/activity.resolvers.go @@ -0,0 +1,27 @@ +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 *activityResolver) UnitName(ctx context.Context, obj *models.Activity) (*string, error) { + if !obj.Countable || obj.UnitIsTimeSpent { + return nil, nil + } + + return &obj.UnitName, nil +} + +func (r *activityResolver) Project(ctx context.Context, obj *models.Activity) (*models.Project, error) { + return r.Database.Projects().Find(ctx, obj.ProjectID) +} + +// Activity returns graphcore.ActivityResolver implementation. +func (r *Resolver) Activity() graphcore.ActivityResolver { return &activityResolver{r} } + +type activityResolver struct{ *Resolver } diff --git a/graph/resolvers/issue.resolvers.go b/graph/resolvers/issue.resolvers.go index 0f313ca..0de9a96 100644 --- a/graph/resolvers/issue.resolvers.go +++ b/graph/resolvers/issue.resolvers.go @@ -5,6 +5,7 @@ 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" diff --git a/graph/resolvers/mutation.resolvers.go b/graph/resolvers/mutation.resolvers.go index cbfa683..7fa07e9 100644 --- a/graph/resolvers/mutation.resolvers.go +++ b/graph/resolvers/mutation.resolvers.go @@ -6,6 +6,7 @@ package resolvers import ( "context" "errors" + "git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" @@ -44,6 +45,83 @@ func (r *mutationResolver) CreateProject(ctx context.Context, input graphcore.Pr return project, nil } +func (r *mutationResolver) CreateActivity(ctx context.Context, input graphcore.ActivityCreateInput) (*models.Activity, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, xlerrors.PermissionDenied + } + + project, err := r.Database.Projects().Find(ctx, input.ProjectID) + if err != nil { + return nil, err + } + if perm, err := r.Auth.ProjectPermission(ctx, *project); err != nil || !perm.CanManageActivities() { + return nil, xlerrors.PermissionDenied + } + + activity := &models.Activity{ + ProjectID: project.ID, + Name: input.Name, + Countable: input.Countable != nil && *input.Countable, + UnitIsTimeSpent: input.UnitIsTimeSpent != nil && *input.UnitIsTimeSpent, + BaseValue: input.BaseValue, + } + if !activity.UnitIsTimeSpent && activity.Countable { + if input.UnitName != nil { + activity.UnitName = *input.UnitName + } else { + return nil, errors.New("unit name is required for countable non-time-spent activities") + } + } + if activity.UnitIsTimeSpent || activity.Countable { + if input.UnitValue != nil { + activity.UnitValue = *input.UnitValue + } + } + + return r.Database.Activities().Insert(ctx, *activity) +} + +func (r *mutationResolver) EditActivity(ctx context.Context, input graphcore.ActivityEditInput) (*models.Activity, error) { + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, xlerrors.PermissionDenied + } + + activity, err := r.Database.Activities().Find(ctx, input.ActivityID) + if err != nil { + return nil, err + } + + project, err := r.Database.Projects().Find(ctx, activity.ProjectID) + if err != nil { + return nil, err + } + if perm, err := r.Auth.ProjectPermission(ctx, *project); err != nil || !perm.CanManageActivities() { + return nil, xlerrors.PermissionDenied + } + + if input.SetName != nil { + activity.Name = *input.SetName + } + if input.SetBaseValue != nil { + activity.BaseValue = *input.SetBaseValue + } + if input.SetUnitName != nil { + activity.UnitName = *input.SetUnitName + } + if input.SetUnitValue != nil { + activity.UnitValue = *input.SetUnitValue + } + + err = r.Database.Activities().Save(ctx, *activity) + if err != nil { + return nil, err + } + + return activity, nil +} + func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.IssueCreateInput) (*models.Issue, error) { user := r.Auth.UserFromContext(ctx) if user == nil { @@ -58,14 +136,19 @@ func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.Issu return nil, xlerrors.PermissionDenied } + status, err := r.Database.ProjectStatuses().Find(ctx, project.ID, input.StatusName) + if err != nil { + return nil, err + } + issue := &models.Issue{ ProjectID: project.ID, OwnerID: user.ID, AssigneeID: "", - StatusStage: input.StatusStage, - StatusName: input.StatusName, + StatusStage: status.Stage, + StatusName: status.Name, Name: input.Name, - Title: input.Name, + Title: input.Name, // Title set below if it's in the input. Description: input.Description, } if input.Title != nil && *input.Title != "" { diff --git a/graph/resolvers/project.resolvers.go b/graph/resolvers/project.resolvers.go index b39eaf3..32f2a10 100644 --- a/graph/resolvers/project.resolvers.go +++ b/graph/resolvers/project.resolvers.go @@ -48,6 +48,10 @@ func (r *projectResolver) Statuses(ctx context.Context, obj *models.Project, fil return r.Database.ProjectStatuses().List(ctx, *filter) } +func (r *projectResolver) Activities(ctx context.Context, obj *models.Project) ([]*models.Activity, error) { + return r.Database.Activities().List(ctx, models.ActivityFilter{ProjectIDs: []string{obj.ID}}) +} + func (r *projectPermissionResolver) User(ctx context.Context, obj *models.ProjectPermission) (*models.User, error) { return loaders.UserLoaderFromContext(ctx).Load(obj.UserID) } diff --git a/graph/resolvers/query.resolvers.go b/graph/resolvers/query.resolvers.go index 087fe59..c50a658 100644 --- a/graph/resolvers/query.resolvers.go +++ b/graph/resolvers/query.resolvers.go @@ -8,14 +8,14 @@ import ( "errors" "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/models" - "git.aiterp.net/stufflog/server/services" ) func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) { user := r.Auth.UserFromContext(ctx) if user == nil { - return nil, services.ErrPermissionDenied + return nil, xlerrors.PermissionDenied } issue, err := r.Database.Issues().Find(ctx, id) @@ -33,7 +33,7 @@ func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, er func (r *queryResolver) Issues(ctx context.Context, filter *models.IssueFilter) ([]*models.Issue, error) { user := r.Auth.UserFromContext(ctx) if user == nil { - return nil, services.ErrPermissionDenied + return nil, xlerrors.PermissionDenied } if filter == nil { @@ -61,7 +61,7 @@ func (r *queryResolver) Issues(ctx context.Context, filter *models.IssueFilter) func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project, error) { user := r.Auth.UserFromContext(ctx) if user == nil { - return nil, services.ErrPermissionDenied + return nil, xlerrors.PermissionDenied } project, err := r.Database.Projects().Find(ctx, id) @@ -79,7 +79,7 @@ func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project func (r *queryResolver) Projects(ctx context.Context, filter *models.ProjectFilter) ([]*models.Project, error) { user := r.Auth.UserFromContext(ctx) if user == nil { - return nil, services.ErrPermissionDenied + return nil, xlerrors.PermissionDenied } skipCheck := false diff --git a/graph/schema/activity.gql b/graph/schema/activity.gql new file mode 100644 index 0000000..e8e29cb --- /dev/null +++ b/graph/schema/activity.gql @@ -0,0 +1,60 @@ +""" +An activity is a measurable activity for a project. It can be measured. +""" +type Activity { + "The activity ID." + id: String! + "The activity name." + name: String! + "Whether the activity is countable." + countable: Boolean! + "Whether the time spent is the unit." + unitIsTimeSpent: Boolean! + "The name of the unit. If unitIsTime or countable is true, this value should be ignored." + unitName: String + "The value per unit." + unitValue: Float! + "The base score value for any performed activity. For uncountables, this is the only points scored." + baseValue: Float! + + "Parent project." + project: Project! +} + +""" +Input for the createActivity mutation. +""" +input ActivityCreateInput { + "Project ID to associate it with." + projectId: String! + "Proejct name." + name: String! + "The base value of any activity performed. If uncountable, this should be non-zero." + baseValue: Float! + "Whether the activity is countable. Default: false" + countable: Boolean + "Whether time spent should be the unit of this activity." + unitIsTimeSpent: Boolean + "The unit name of the activity." + unitName: String + "The per-unit value of the activity." + unitValue: Float +} + +""" +Input for the editActivity mutation. Not all changes are available as they could create +some serious issues with past goals and logs. +""" +input ActivityEditInput { + "The ID of the activity to edit." + activityId: String! + + "Update the name of the activity." + setName: String + "Set the base value of the activity. This has an effect on current and past goals!" + setBaseValue: Float + "Set the unit name of the activity. This is only for countable, non time spent acitivities." + setUnitName: String + "Set the unit value of the activity. This has an effect on current and past goals!" + setUnitValue: Float +} \ No newline at end of file diff --git a/graph/schema/issue.gql b/graph/schema/issue.gql index c67c196..7858baa 100644 --- a/graph/schema/issue.gql +++ b/graph/schema/issue.gql @@ -1,51 +1,60 @@ type Issue { + "The issue ID." id: String! + "The time at which the issue is created." createdTime: Time! + "The time at which the issue was updated." updatedTime: Time! + "Optionally, when this issue is due." dueTime: Time + "The name of the issue, used in lists." name: String! + "The issue title. This can be longer than the name." title: String! + "The description of the issue, in markdown." description: String! + "Parent project." project: Project + "The issue's owner/creator." owner: User + "The issue assignee." assignee: User + "The issue status." status: ProjectStatus! } input IssueFilter { - "Filter by issue IDs (mostly used internally by data loaders)" + "Filter by issue IDs. (mostly used internally by data loaders)" issueIds: [String!] - "Filter by project IDs" + "Filter by project IDs." projectIds: [String!] - "Filter by owner IDs" + "Filter by owner IDs." ownerIds: [String!] - "Filter by assignee IDs" + "Filter by assignee IDs." assigneeIds: [String!] - "Text search" + "Text search." search: String - "Earliest stage (inclusive)" + "Earliest stage. (inclusive)" minStage: Int - "Latest stage (inclusive)" + "Latest stage. (inclusive)" maxStage: Int - "Limit the result set" + "Limit the result set." limit: Int } input IssueCreateInput { - "Project ID" + "Project ID." projectId: String! - "Status stage" - statusStage: Int! - "Status name" + "Status name." statusName: String! "A name for the issue." name: String! "Description of the issue." description: String! - "Assign this issue, will default to unassigned" + "Assign this issue, will default to unassigned." assigneeId: String - "A date when this issue is due" + "A date when this issue is due." dueTime: Time "Optional title to use instead of the name when not in a list." title: String diff --git a/graph/schema/mutation.gql b/graph/schema/mutation.gql index b8b2f5e..e471078 100644 --- a/graph/schema/mutation.gql +++ b/graph/schema/mutation.gql @@ -1,19 +1,25 @@ type Mutation { # PROJECT - "Create a new project" + "Create a new project." createProject(input: ProjectCreateInput!): Project! + # ACTIVITY + "Create an activity." + createActivity(input: ActivityCreateInput!): Activity! + "Edit an activity." + editActivity(input: ActivityEditInput!): Activity! + # ISSUE - "Create a new issue" + "Create a new issue." createIssue(input: IssueCreateInput!): Issue! # USER - "Log in" + "Log in." loginUser(input: UserLoginInput!): User! - "Log out" + "Log out." logoutUser: User! "Create a new user. This can only be done by administrators." createUser(input: UserCreateInput!): User! - "Edit an existing user. This can only be done by administrators" + "Edit an existing user. This can only be done by administrators." editUser(input: UserEditInput!): User! } \ No newline at end of file diff --git a/graph/schema/project.gql b/graph/schema/project.gql index a9a093e..76847c9 100644 --- a/graph/schema/project.gql +++ b/graph/schema/project.gql @@ -16,6 +16,8 @@ type Project { userPermissions: ProjectPermission! "List all project statuses." statuses(filter: ProjectStatusFilter): [ProjectStatus!]! + "The activities in this project." + activities: [Activity!]! } "The permissions of a user within the project." diff --git a/internal/generate/ids.go b/internal/generate/ids.go index bcc2e37..e9d086d 100644 --- a/internal/generate/ids.go +++ b/internal/generate/ids.go @@ -1,5 +1,9 @@ package generate +func ActivityID() string { + return Generate(16, "A") +} + func ItemID() string { return Generate(32, "I") } diff --git a/migrations/mysql/20200508155557_create_table_activity.sql b/migrations/mysql/20200508155557_create_table_activity.sql new file mode 100644 index 0000000..a4f6f9d --- /dev/null +++ b/migrations/mysql/20200508155557_create_table_activity.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE activity ( + activity_id CHAR(16) PRIMARY KEY, + project_id CHAR(16) NOT NULL, + name VARCHAR(255) NOT NULL, + countable BOOLEAN NOT NULL, + unit_is_time BOOLEAN NOT NULL, + unit_name VARCHAR(255) NOT NULL, + unit_value FLOAT NOT NULL, + base_value FLOAT NOT NULL, + + INDEX(Project_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE activity; +-- +goose StatementEnd diff --git a/models/activity.go b/models/activity.go index 3d24021..2bffd7c 100644 --- a/models/activity.go +++ b/models/activity.go @@ -3,10 +3,17 @@ package models // Activity is an activity within a project that can be measured and estimated, for example "writing" or "developing". // The points are for the "gamified" aspect of this. type Activity struct { - ID string `db:"activity_id"` - ProjectID string `db:"project_id"` - Countable bool `db:"countable"` - UnitName string `db:"unit_name"` - UnitValue float64 `db:"unit_value"` - BaseValue float64 `db:"base_value"` + ID string `db:"activity_id"` + ProjectID string `db:"project_id"` + Name string `db:"name"` + Countable bool `db:"countable"` + UnitIsTimeSpent bool `db:"unit_is_time"` + UnitName string `db:"unit_name"` + UnitValue float64 `db:"unit_value"` + BaseValue float64 `db:"base_value"` +} + +type ActivityFilter struct { + ActivityIDs []string + ProjectIDs []string } diff --git a/models/projectpermission.go b/models/projectpermission.go index d85466c..44681b1 100644 --- a/models/projectpermission.go +++ b/models/projectpermission.go @@ -34,3 +34,7 @@ func (permission *ProjectPermission) CanManageAnyIssue() bool { func (permission *ProjectPermission) CanManagePermissions() bool { return permission.Level >= ProjectPermissionLevelOwner } + +func (permission *ProjectPermission) CanManageActivities() bool { + return permission.Level >= ProjectPermissionLevelAdmin +}