Browse Source

add activities.

master
Gisle Aune 5 years ago
parent
commit
3039dc1879
  1. 1
      database/database.go
  2. 100
      database/drivers/mysqldriver/activities.go
  3. 7
      database/drivers/mysqldriver/db.go
  4. 14
      database/repositories/activityrepository.go
  5. 6
      graph/gqlgen.yml
  6. 27
      graph/resolvers/activity.resolvers.go
  7. 1
      graph/resolvers/issue.resolvers.go
  8. 89
      graph/resolvers/mutation.resolvers.go
  9. 4
      graph/resolvers/project.resolvers.go
  10. 10
      graph/resolvers/query.resolvers.go
  11. 60
      graph/schema/activity.gql
  12. 37
      graph/schema/issue.gql
  13. 16
      graph/schema/mutation.gql
  14. 2
      graph/schema/project.gql
  15. 4
      internal/generate/ids.go
  16. 20
      migrations/mysql/20200508155557_create_table_activity.sql
  17. 7
      models/activity.go
  18. 4
      models/projectpermission.go

1
database/database.go

@ -9,6 +9,7 @@ import (
var ErrDriverNotSupported = errors.New("driver not found or supported") var ErrDriverNotSupported = errors.New("driver not found or supported")
type Database interface { type Database interface {
Activities() repositories.ActivityRepository
Issues() repositories.IssueRepository Issues() repositories.IssueRepository
Items() repositories.ItemRepository Items() repositories.ItemRepository
Projects() repositories.ProjectRepository Projects() repositories.ProjectRepository

100
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
}

7
database/drivers/mysqldriver/db.go

@ -14,6 +14,7 @@ import (
type DB struct { type DB struct {
db *sqlx.DB db *sqlx.DB
activities *activityRepository
issues *issueRepository issues *issueRepository
items *itemRepository items *itemRepository
projects *projectRepository projects *projectRepository
@ -22,6 +23,10 @@ type DB struct {
projectStatuses *projectStatusRepository projectStatuses *projectStatusRepository
} }
func (db *DB) Activities() repositories.ActivityRepository {
return db.activities
}
func (db *DB) Issues() repositories.IssueRepository { func (db *DB) Issues() repositories.IssueRepository {
return db.issues return db.issues
} }
@ -76,6 +81,7 @@ func Open(connectionString string) (*DB, error) {
return nil, err return nil, err
} }
activities := &activityRepository{db: db}
issues := &issueRepository{db: db} issues := &issueRepository{db: db}
items := &itemRepository{db: db} items := &itemRepository{db: db}
projects := &projectRepository{db: db} projects := &projectRepository{db: db}
@ -85,6 +91,7 @@ func Open(connectionString string) (*DB, error) {
return &DB{ return &DB{
db: db, db: db,
activities: activities,
issues: issues, issues: issues,
items: items, items: items,
projects: projects, projects: projects,

14
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
}

6
graph/gqlgen.yml

@ -9,6 +9,12 @@ model:
filename: graphcore/input_gen.go filename: graphcore/input_gen.go
package: graphcore package: graphcore
models:
Activity:
fields:
unitName:
resolver: true
resolver: resolver:
layout: follow-schema layout: follow-schema
dir: resolvers dir: resolvers

27
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 }

1
graph/resolvers/issue.resolvers.go

@ -5,6 +5,7 @@ package resolvers
import ( import (
"context" "context"
"git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/graph/graphutil" "git.aiterp.net/stufflog/server/graph/graphutil"
"git.aiterp.net/stufflog/server/graph/loaders" "git.aiterp.net/stufflog/server/graph/loaders"

89
graph/resolvers/mutation.resolvers.go

@ -6,6 +6,7 @@ package resolvers
import ( import (
"context" "context"
"errors" "errors"
"git.aiterp.net/stufflog/server/graph/graphcore" "git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/internal/xlerrors" "git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models" "git.aiterp.net/stufflog/server/models"
@ -44,6 +45,83 @@ func (r *mutationResolver) CreateProject(ctx context.Context, input graphcore.Pr
return project, nil 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) { func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.IssueCreateInput) (*models.Issue, error) {
user := r.Auth.UserFromContext(ctx) user := r.Auth.UserFromContext(ctx)
if user == nil { if user == nil {
@ -58,14 +136,19 @@ func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.Issu
return nil, xlerrors.PermissionDenied return nil, xlerrors.PermissionDenied
} }
status, err := r.Database.ProjectStatuses().Find(ctx, project.ID, input.StatusName)
if err != nil {
return nil, err
}
issue := &models.Issue{ issue := &models.Issue{
ProjectID: project.ID, ProjectID: project.ID,
OwnerID: user.ID, OwnerID: user.ID,
AssigneeID: "", AssigneeID: "",
StatusStage: input.StatusStage,
StatusName: input.StatusName,
StatusStage: status.Stage,
StatusName: status.Name,
Name: input.Name, Name: input.Name,
Title: input.Name,
Title: input.Name, // Title set below if it's in the input.
Description: input.Description, Description: input.Description,
} }
if input.Title != nil && *input.Title != "" { if input.Title != nil && *input.Title != "" {

4
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) 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) { func (r *projectPermissionResolver) User(ctx context.Context, obj *models.ProjectPermission) (*models.User, error) {
return loaders.UserLoaderFromContext(ctx).Load(obj.UserID) return loaders.UserLoaderFromContext(ctx).Load(obj.UserID)
} }

10
graph/resolvers/query.resolvers.go

@ -8,14 +8,14 @@ import (
"errors" "errors"
"git.aiterp.net/stufflog/server/graph/graphcore" "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/models"
"git.aiterp.net/stufflog/server/services"
) )
func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) { func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) {
user := r.Auth.UserFromContext(ctx) user := r.Auth.UserFromContext(ctx)
if user == nil { if user == nil {
return nil, services.ErrPermissionDenied
return nil, xlerrors.PermissionDenied
} }
issue, err := r.Database.Issues().Find(ctx, id) 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) { func (r *queryResolver) Issues(ctx context.Context, filter *models.IssueFilter) ([]*models.Issue, error) {
user := r.Auth.UserFromContext(ctx) user := r.Auth.UserFromContext(ctx)
if user == nil { if user == nil {
return nil, services.ErrPermissionDenied
return nil, xlerrors.PermissionDenied
} }
if filter == nil { 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) { func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project, error) {
user := r.Auth.UserFromContext(ctx) user := r.Auth.UserFromContext(ctx)
if user == nil { if user == nil {
return nil, services.ErrPermissionDenied
return nil, xlerrors.PermissionDenied
} }
project, err := r.Database.Projects().Find(ctx, id) 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) { func (r *queryResolver) Projects(ctx context.Context, filter *models.ProjectFilter) ([]*models.Project, error) {
user := r.Auth.UserFromContext(ctx) user := r.Auth.UserFromContext(ctx)
if user == nil { if user == nil {
return nil, services.ErrPermissionDenied
return nil, xlerrors.PermissionDenied
} }
skipCheck := false skipCheck := false

60
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
}

37
graph/schema/issue.gql

@ -1,51 +1,60 @@
type Issue { type Issue {
"The issue ID."
id: String! id: String!
"The time at which the issue is created."
createdTime: Time! createdTime: Time!
"The time at which the issue was updated."
updatedTime: Time! updatedTime: Time!
"Optionally, when this issue is due."
dueTime: Time dueTime: Time
"The name of the issue, used in lists."
name: String! name: String!
"The issue title. This can be longer than the name."
title: String! title: String!
"The description of the issue, in markdown."
description: String! description: String!
"Parent project."
project: Project project: Project
"The issue's owner/creator."
owner: User owner: User
"The issue assignee."
assignee: User assignee: User
"The issue status."
status: ProjectStatus! status: ProjectStatus!
} }
input IssueFilter { input IssueFilter {
"Filter by issue IDs (mostly used internally by data loaders)"
"Filter by issue IDs. (mostly used internally by data loaders)"
issueIds: [String!] issueIds: [String!]
"Filter by project IDs"
"Filter by project IDs."
projectIds: [String!] projectIds: [String!]
"Filter by owner IDs"
"Filter by owner IDs."
ownerIds: [String!] ownerIds: [String!]
"Filter by assignee IDs"
"Filter by assignee IDs."
assigneeIds: [String!] assigneeIds: [String!]
"Text search"
"Text search."
search: String search: String
"Earliest stage (inclusive)"
"Earliest stage. (inclusive)"
minStage: Int minStage: Int
"Latest stage (inclusive)"
"Latest stage. (inclusive)"
maxStage: Int maxStage: Int
"Limit the result set"
"Limit the result set."
limit: Int limit: Int
} }
input IssueCreateInput { input IssueCreateInput {
"Project ID"
"Project ID."
projectId: String! projectId: String!
"Status stage"
statusStage: Int!
"Status name"
"Status name."
statusName: String! statusName: String!
"A name for the issue." "A name for the issue."
name: String! name: String!
"Description of the issue." "Description of the issue."
description: String! description: String!
"Assign this issue, will default to unassigned"
"Assign this issue, will default to unassigned."
assigneeId: String assigneeId: String
"A date when this issue is due"
"A date when this issue is due."
dueTime: Time dueTime: Time
"Optional title to use instead of the name when not in a list." "Optional title to use instead of the name when not in a list."
title: String title: String

16
graph/schema/mutation.gql

@ -1,19 +1,25 @@
type Mutation { type Mutation {
# PROJECT # PROJECT
"Create a new project"
"Create a new project."
createProject(input: ProjectCreateInput!): Project! createProject(input: ProjectCreateInput!): Project!
# ACTIVITY
"Create an activity."
createActivity(input: ActivityCreateInput!): Activity!
"Edit an activity."
editActivity(input: ActivityEditInput!): Activity!
# ISSUE # ISSUE
"Create a new issue"
"Create a new issue."
createIssue(input: IssueCreateInput!): Issue! createIssue(input: IssueCreateInput!): Issue!
# USER # USER
"Log in"
"Log in."
loginUser(input: UserLoginInput!): User! loginUser(input: UserLoginInput!): User!
"Log out"
"Log out."
logoutUser: User! logoutUser: User!
"Create a new user. This can only be done by administrators." "Create a new user. This can only be done by administrators."
createUser(input: UserCreateInput!): User! 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! editUser(input: UserEditInput!): User!
} }

2
graph/schema/project.gql

@ -16,6 +16,8 @@ type Project {
userPermissions: ProjectPermission! userPermissions: ProjectPermission!
"List all project statuses." "List all project statuses."
statuses(filter: ProjectStatusFilter): [ProjectStatus!]! statuses(filter: ProjectStatusFilter): [ProjectStatus!]!
"The activities in this project."
activities: [Activity!]!
} }
"The permissions of a user within the project." "The permissions of a user within the project."

4
internal/generate/ids.go

@ -1,5 +1,9 @@
package generate package generate
func ActivityID() string {
return Generate(16, "A")
}
func ItemID() string { func ItemID() string {
return Generate(32, "I") return Generate(32, "I")
} }

20
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

7
models/activity.go

@ -5,8 +5,15 @@ package models
type Activity struct { type Activity struct {
ID string `db:"activity_id"` ID string `db:"activity_id"`
ProjectID string `db:"project_id"` ProjectID string `db:"project_id"`
Name string `db:"name"`
Countable bool `db:"countable"` Countable bool `db:"countable"`
UnitIsTimeSpent bool `db:"unit_is_time"`
UnitName string `db:"unit_name"` UnitName string `db:"unit_name"`
UnitValue float64 `db:"unit_value"` UnitValue float64 `db:"unit_value"`
BaseValue float64 `db:"base_value"` BaseValue float64 `db:"base_value"`
} }
type ActivityFilter struct {
ActivityIDs []string
ProjectIDs []string
}

4
models/projectpermission.go

@ -34,3 +34,7 @@ func (permission *ProjectPermission) CanManageAnyIssue() bool {
func (permission *ProjectPermission) CanManagePermissions() bool { func (permission *ProjectPermission) CanManagePermissions() bool {
return permission.Level >= ProjectPermissionLevelOwner return permission.Level >= ProjectPermissionLevelOwner
} }
func (permission *ProjectPermission) CanManageActivities() bool {
return permission.Level >= ProjectPermissionLevelAdmin
}
Loading…
Cancel
Save