add issue tasks.
This commit is contained in:
@@ -11,6 +11,7 @@ var ErrDriverNotSupported = errors.New("driver not found or supported")
|
|||||||
type Database interface {
|
type Database interface {
|
||||||
Activities() repositories.ActivityRepository
|
Activities() repositories.ActivityRepository
|
||||||
Issues() repositories.IssueRepository
|
Issues() repositories.IssueRepository
|
||||||
|
IssueTasks() repositories.IssueTaskRepository
|
||||||
Items() repositories.ItemRepository
|
Items() repositories.ItemRepository
|
||||||
Projects() repositories.ProjectRepository
|
Projects() repositories.ProjectRepository
|
||||||
Session() repositories.SessionRepository
|
Session() repositories.SessionRepository
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type DB struct {
|
|||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
activities *activityRepository
|
activities *activityRepository
|
||||||
issues *issueRepository
|
issues *issueRepository
|
||||||
|
issueTasks *issueTaskRepository
|
||||||
items *itemRepository
|
items *itemRepository
|
||||||
projects *projectRepository
|
projects *projectRepository
|
||||||
sessions *sessionRepository
|
sessions *sessionRepository
|
||||||
@@ -31,6 +32,10 @@ func (db *DB) Issues() repositories.IssueRepository {
|
|||||||
return db.issues
|
return db.issues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) IssueTasks() repositories.IssueTaskRepository {
|
||||||
|
return db.issueTasks
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) Items() repositories.ItemRepository {
|
func (db *DB) Items() repositories.ItemRepository {
|
||||||
return db.items
|
return db.items
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,7 @@ func Open(connectionString string) (*DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup repositories
|
||||||
activities := &activityRepository{db: db}
|
activities := &activityRepository{db: db}
|
||||||
issues := &issueRepository{db: db}
|
issues := &issueRepository{db: db}
|
||||||
items := &itemRepository{db: db}
|
items := &itemRepository{db: db}
|
||||||
@@ -88,11 +94,13 @@ func Open(connectionString string) (*DB, error) {
|
|||||||
users := &userRepository{db: db}
|
users := &userRepository{db: db}
|
||||||
sessions := &sessionRepository{db: db}
|
sessions := &sessionRepository{db: db}
|
||||||
projectStatuses := &projectStatusRepository{db: db}
|
projectStatuses := &projectStatusRepository{db: db}
|
||||||
|
issueTasks := &issueTaskRepository{db: db}
|
||||||
|
|
||||||
return &DB{
|
return &DB{
|
||||||
db: db,
|
db: db,
|
||||||
activities: activities,
|
activities: activities,
|
||||||
issues: issues,
|
issues: issues,
|
||||||
|
issueTasks: issueTasks,
|
||||||
items: items,
|
items: items,
|
||||||
projects: projects,
|
projects: projects,
|
||||||
users: users,
|
users: users,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package mysqldriver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/stufflog/server/internal/xlerrors"
|
||||||
|
"git.aiterp.net/stufflog/server/models"
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type issueTaskRepository struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskRepository) Find(ctx context.Context, id string) (*models.IssueTask, error) {
|
||||||
|
issueTask := models.IssueTask{}
|
||||||
|
err := r.db.GetContext(ctx, &issueTask, "SELECT * FROM issue_task WHERE issue_task_id=?", id)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, xlerrors.NotFound("Issue task")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issueTask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskRepository) List(ctx context.Context, filter models.IssueTaskFilter) ([]*models.IssueTask, error) {
|
||||||
|
q := sq.Select("*").From("issue_task")
|
||||||
|
if filter.ActivityIDs != nil {
|
||||||
|
q = q.Where(sq.Eq{"activity_id": filter.ActivityIDs})
|
||||||
|
}
|
||||||
|
if filter.IssueTaskIDs != nil {
|
||||||
|
q = q.Where(sq.Eq{"issue_id": filter.IssueTaskIDs})
|
||||||
|
}
|
||||||
|
if filter.IssueIDs != nil {
|
||||||
|
q = q.Where(sq.Eq{"issue_id": filter.IssueIDs})
|
||||||
|
}
|
||||||
|
if filter.MinStage != nil {
|
||||||
|
q = q.Where(sq.GtOrEq{"status_stage": *filter.MinStage})
|
||||||
|
}
|
||||||
|
if filter.MaxStage != nil {
|
||||||
|
q = q.Where(sq.LtOrEq{"status_stage": *filter.MaxStage})
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := q.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]*models.IssueTask, 0, 16)
|
||||||
|
err = r.db.SelectContext(ctx, &results, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return []*models.IssueTask{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskRepository) Insert(ctx context.Context, task models.IssueTask) (*models.IssueTask, error) {
|
||||||
|
if task.IssueID == "" {
|
||||||
|
return nil, errors.New("missing issue id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.CreatedTime.IsZero() {
|
||||||
|
task.CreatedTime = time.Now().Truncate(time.Second)
|
||||||
|
task.UpdatedTime = task.CreatedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextID, err := incCounter(ctx, tx, counterKindIssueSubID, task.IssueID)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task.ID = fmt.Sprintf("%s-%d", task.IssueID, nextID)
|
||||||
|
|
||||||
|
_, 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
|
||||||
|
) 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
|
||||||
|
)
|
||||||
|
`, task)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskRepository) Save(ctx context.Context, task models.IssueTask) error {
|
||||||
|
_, err := r.db.NamedExecContext(ctx, `
|
||||||
|
UPDATE issue_task SET
|
||||||
|
due_time=:due_time,
|
||||||
|
status_stage=:status_stage,
|
||||||
|
status_name=:status_name,
|
||||||
|
name=:name,
|
||||||
|
description=:description,
|
||||||
|
estimated_time=:estimated_time,
|
||||||
|
estimated_units=:estimated_units,
|
||||||
|
points_multiplier=:points_multiplier
|
||||||
|
WHERE issue_task_id=:issue_task_id
|
||||||
|
`, task)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskRepository) Delete(ctx context.Context, task models.IssueTask) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, "DELETE FROM issue_task WHERE issue_task_id=? LIMIT 1;", task.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var counterKindIssueID = "NextIssueID"
|
var counterKindIssueID = "NextIssueID"
|
||||||
|
var counterKindIssueSubID = "NextIssueSubID"
|
||||||
|
|
||||||
type issueRepository struct {
|
type issueRepository struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/stufflog/server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueTaskRepository interface {
|
||||||
|
Find(ctx context.Context, id string) (*models.IssueTask, error)
|
||||||
|
List(ctx context.Context, filter models.IssueTaskFilter) ([]*models.IssueTask, error)
|
||||||
|
Insert(ctx context.Context, task models.IssueTask) (*models.IssueTask, error)
|
||||||
|
Save(ctx context.Context, task models.IssueTask) error
|
||||||
|
Delete(ctx context.Context, task models.IssueTask) error
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ models:
|
|||||||
fields:
|
fields:
|
||||||
unitName:
|
unitName:
|
||||||
resolver: true
|
resolver: true
|
||||||
|
IssueTask:
|
||||||
|
fields:
|
||||||
|
estimatedUnits:
|
||||||
|
resolver: true
|
||||||
|
Duration:
|
||||||
|
model: git.aiterp.net/stufflog/server/graph/scalars.Duration
|
||||||
|
|
||||||
resolver:
|
resolver:
|
||||||
layout: follow-schema
|
layout: follow-schema
|
||||||
|
|||||||
@@ -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/models"
|
"git.aiterp.net/stufflog/server/models"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ func (r *issueResolver) Status(ctx context.Context, obj *models.Issue) (*models.
|
|||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *issueResolver) Tasks(ctx context.Context, obj *models.Issue, filter *models.IssueTaskFilter) ([]*models.IssueTask, error) {
|
||||||
|
if filter == nil {
|
||||||
|
filter = &models.IssueTaskFilter{}
|
||||||
|
}
|
||||||
|
filter.IssueTaskIDs = []string{obj.ID}
|
||||||
|
|
||||||
|
return r.Database.IssueTasks().List(ctx, *filter)
|
||||||
|
}
|
||||||
|
|
||||||
// Issue returns graphcore.IssueResolver implementation.
|
// Issue returns graphcore.IssueResolver implementation.
|
||||||
func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} }
|
func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.aiterp.net/stufflog/server/graph/graphcore"
|
||||||
|
"git.aiterp.net/stufflog/server/graph/graphutil"
|
||||||
|
"git.aiterp.net/stufflog/server/internal/xlerrors"
|
||||||
|
"git.aiterp.net/stufflog/server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) EstimatedUnits(ctx context.Context, obj *models.IssueTask) (*int, error) {
|
||||||
|
// TODO: Data loader
|
||||||
|
activity, err := r.Database.Activities().Find(ctx, obj.ActivityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !activity.Countable || activity.UnitIsTimeSpent {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &obj.EstimatedUnits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) Issue(ctx context.Context, obj *models.IssueTask) (*models.Issue, error) {
|
||||||
|
return r.Database.Issues().Find(ctx, obj.IssueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) Activity(ctx context.Context, obj *models.IssueTask) (*models.Activity, error) {
|
||||||
|
return r.Database.Activities().Find(ctx, obj.ActivityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) Status(ctx context.Context, obj *models.IssueTask) (*models.ProjectStatus, error) {
|
||||||
|
// The project ID is always the prefix before the first dash in the issue ID.
|
||||||
|
split := strings.SplitN(obj.IssueID, "-", 2)
|
||||||
|
if len(split) == 0 {
|
||||||
|
return nil, errors.New("invalid issue ID")
|
||||||
|
}
|
||||||
|
projectID := split[0]
|
||||||
|
|
||||||
|
// Shortcut: if description isn't needed, resolve this with issue's properties.
|
||||||
|
if !graphutil.SelectsAnyField(ctx, "description") {
|
||||||
|
return &models.ProjectStatus{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Stage: obj.StatusStage,
|
||||||
|
Name: obj.StatusName,
|
||||||
|
Description: "FAKE",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find it in the database. TODO: DataLoader
|
||||||
|
status, err := r.Database.ProjectStatuses().Find(ctx, projectID, obj.StatusName)
|
||||||
|
if xlerrors.IsNotFound(err) {
|
||||||
|
return &models.ProjectStatus{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Stage: obj.StatusStage,
|
||||||
|
Name: obj.StatusName,
|
||||||
|
Description: "(Deleted or unknown status)",
|
||||||
|
}, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the stage doesn't match, sneakily correct it for next time.
|
||||||
|
if status.Stage != obj.StatusStage {
|
||||||
|
updatedTask := *obj
|
||||||
|
updatedTask.StatusStage = status.Stage
|
||||||
|
_ = r.Database.IssueTasks().Save(ctx, updatedTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) RemainingTime(ctx context.Context, obj *models.IssueTask) (time.Duration, error) {
|
||||||
|
panic(fmt.Errorf("not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *issueTaskResolver) RemainingUnits(ctx context.Context, obj *models.IssueTask) (*int, error) {
|
||||||
|
panic(fmt.Errorf("not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTask returns graphcore.IssueTaskResolver implementation.
|
||||||
|
func (r *Resolver) IssueTask() graphcore.IssueTaskResolver { return &issueTaskResolver{r} }
|
||||||
|
|
||||||
|
type issueTaskResolver struct{ *Resolver }
|
||||||
@@ -6,6 +6,7 @@ package resolvers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -169,6 +170,120 @@ func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.Issu
|
|||||||
return issue, nil
|
return issue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CreateIssueTask(ctx context.Context, input graphcore.IssueTaskCreateInput) (*models.IssueTask, error) {
|
||||||
|
user := r.Auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, xlerrors.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, xlerrors.PermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := r.Database.ProjectStatuses().Find(ctx, issue.ProjectID, input.StatusName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := r.Database.Activities().Find(ctx, input.ActivityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if activity.ProjectID != issue.ProjectID {
|
||||||
|
return nil, xlerrors.NotFound("Activity")
|
||||||
|
}
|
||||||
|
|
||||||
|
issueTask := &models.IssueTask{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
ActivityID: activity.ID,
|
||||||
|
CreatedTime: time.Now(),
|
||||||
|
UpdatedTime: time.Now(),
|
||||||
|
StatusStage: status.Stage,
|
||||||
|
StatusName: status.Name,
|
||||||
|
Name: input.Name,
|
||||||
|
Description: input.Description,
|
||||||
|
PointsMultiplier: 1.0,
|
||||||
|
}
|
||||||
|
if input.EstimatedUnits != nil && activity.Countable && !activity.UnitIsTimeSpent {
|
||||||
|
issueTask.EstimatedUnits = *input.EstimatedUnits
|
||||||
|
}
|
||||||
|
if input.PointsMultiplier != nil && *input.PointsMultiplier > 0 {
|
||||||
|
issueTask.PointsMultiplier = *input.PointsMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
issueTask, err = r.Database.IssueTasks().Insert(ctx, *issueTask)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return issueTask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) EditIssueTask(ctx context.Context, input graphcore.IssueTaskEditInput) (*models.IssueTask, error) {
|
||||||
|
user := r.Auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, xlerrors.PermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := r.Database.IssueTasks().Find(ctx, input.IssueTaskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := r.Database.Issues().Find(ctx, task.IssueID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if perm, err := r.Auth.IssuePermission(ctx, *issue); err != nil || !perm.CanManageOwnIssue() {
|
||||||
|
return nil, xlerrors.PermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.SetName != nil {
|
||||||
|
task.Name = *input.SetName
|
||||||
|
}
|
||||||
|
if input.SetDescription != nil {
|
||||||
|
task.Description = *input.SetDescription
|
||||||
|
}
|
||||||
|
if input.SetDueTime != nil {
|
||||||
|
task.DueTime = input.SetDueTime
|
||||||
|
}
|
||||||
|
if input.SetEstimatedTime != nil {
|
||||||
|
task.EstimatedTime = *input.SetEstimatedTime
|
||||||
|
}
|
||||||
|
if input.SetEstimatedUnits != nil {
|
||||||
|
activity, err := r.Database.Activities().Find(ctx, task.ActivityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if activity.Countable && !activity.UnitIsTimeSpent {
|
||||||
|
task.EstimatedUnits = *input.SetEstimatedUnits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.SetPointsMultiplier != nil && *input.SetPointsMultiplier > 0 {
|
||||||
|
task.PointsMultiplier = *input.SetPointsMultiplier
|
||||||
|
}
|
||||||
|
if input.SetStatusName != nil {
|
||||||
|
status, err := r.Database.ProjectStatuses().Find(ctx, issue.ProjectID, *input.SetStatusName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
task.StatusName = status.Name
|
||||||
|
task.StatusStage = status.Stage
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.Database.IssueTasks().Save(ctx, *task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) LoginUser(ctx context.Context, input graphcore.UserLoginInput) (*models.User, error) {
|
func (r *mutationResolver) LoginUser(ctx context.Context, input graphcore.UserLoginInput) (*models.User, error) {
|
||||||
return r.Auth.Login(ctx, input.Username, input.Password)
|
return r.Auth.Login(ctx, input.Username, input.Password)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package scalars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MarshalDuration(d time.Duration) graphql.Marshaler {
|
||||||
|
return graphql.WriterFunc(func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte(strconv.Itoa(int(d.Milliseconds()))))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalDuration(v interface{}) (time.Duration, error) {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return time.ParseDuration(v)
|
||||||
|
case int:
|
||||||
|
return time.Millisecond * time.Duration(v), nil
|
||||||
|
case int64:
|
||||||
|
return time.Millisecond * time.Duration(v), nil
|
||||||
|
case float64:
|
||||||
|
return time.Millisecond * time.Duration(v), nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("%T is not a bool", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,14 @@ type Issue {
|
|||||||
project: Project
|
project: Project
|
||||||
"The issue's owner/creator."
|
"The issue's owner/creator."
|
||||||
owner: User
|
owner: User
|
||||||
"The issue assignee."
|
"The issue's assignee."
|
||||||
assignee: User
|
assignee: User
|
||||||
"The issue status."
|
"Current status of the issue."
|
||||||
status: ProjectStatus!
|
status: ProjectStatus!
|
||||||
|
"Issue tasks."
|
||||||
|
tasks(filter: IssueTaskFilter): [IssueTask!]!
|
||||||
|
#"Logs related to this issue."
|
||||||
|
#logs: [Log!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input IssueFilter {
|
input IssueFilter {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
An issue task is a the main part of an issue. They contain estimates, and which activity
|
||||||
|
is to be performed. They don't get their own page in UI and should have all their information
|
||||||
|
presented in a concise manner.
|
||||||
|
"""
|
||||||
|
type IssueTask {
|
||||||
|
"The issue task ID."
|
||||||
|
id: String!
|
||||||
|
"The time when the task was created."
|
||||||
|
createdTime: Time!
|
||||||
|
"The time when the task was updated."
|
||||||
|
updatedTime: Time!
|
||||||
|
"The time when the task is due."
|
||||||
|
dueTime: Time
|
||||||
|
"A name for the task."
|
||||||
|
name: String!
|
||||||
|
"A short description of the task."
|
||||||
|
description: String!
|
||||||
|
"The estimated time."
|
||||||
|
estimatedTime: Duration!
|
||||||
|
"The estimated units."
|
||||||
|
estimatedUnits: Int
|
||||||
|
"A multiplier for the points earned."
|
||||||
|
pointsMultiplier: Float!
|
||||||
|
|
||||||
|
"Parent issue."
|
||||||
|
issue: Issue!
|
||||||
|
"Activity the task performs."
|
||||||
|
activity: Activity!
|
||||||
|
"The status of this task."
|
||||||
|
status: ProjectStatus!
|
||||||
|
#"Logs related to this task."
|
||||||
|
#logs: [Log!]!
|
||||||
|
|
||||||
|
"Remaining time from the logs."
|
||||||
|
remainingTime: Duration!
|
||||||
|
"Remaining units (if countable)"
|
||||||
|
remainingUnits: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
A subset of the filter for Issue.tasks
|
||||||
|
"""
|
||||||
|
input IssueTaskFilter {
|
||||||
|
"The activity IDs to limit the task list with."
|
||||||
|
activityIds: [String!]
|
||||||
|
"The lowest stage (inclusive)."
|
||||||
|
minStage: Int
|
||||||
|
"The highest stage (inclusive)."
|
||||||
|
maxStage: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
Input for the createIssueTask mutation.
|
||||||
|
"""
|
||||||
|
input IssueTaskCreateInput {
|
||||||
|
"The issue ID to parent to."
|
||||||
|
issueId: String!
|
||||||
|
|
||||||
|
"The activity ID this task is about."
|
||||||
|
activityId: String!
|
||||||
|
"The name of the task."
|
||||||
|
name: String!
|
||||||
|
"The description of the task."
|
||||||
|
description: String!
|
||||||
|
"Estimated time to perform the task."
|
||||||
|
estimatedTime: Duration!
|
||||||
|
"Task status."
|
||||||
|
statusName: String!
|
||||||
|
|
||||||
|
"Estimate an amount of units. This is required for issues with a countable activity."
|
||||||
|
estimatedUnits: Int
|
||||||
|
"Set an optional multiplier for the issue."
|
||||||
|
pointsMultiplier: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
Input for the editIssueTask mutation.
|
||||||
|
"""
|
||||||
|
input IssueTaskEditInput {
|
||||||
|
"The issue task to edit."
|
||||||
|
issueTaskId: String!
|
||||||
|
|
||||||
|
"Update the status."
|
||||||
|
setStatusName: String
|
||||||
|
"Set the name."
|
||||||
|
setName: String
|
||||||
|
"Set description."
|
||||||
|
setDescription: String
|
||||||
|
"Set estimated time."
|
||||||
|
setEstimatedTime: Duration
|
||||||
|
"Set estimated units."
|
||||||
|
setEstimatedUnits: Int
|
||||||
|
"Set points multiplier."
|
||||||
|
setPointsMultiplier: Float
|
||||||
|
"Set due time."
|
||||||
|
setDueTime: Time
|
||||||
|
}
|
||||||
@@ -9,9 +9,19 @@ type Mutation {
|
|||||||
"Edit an activity."
|
"Edit an activity."
|
||||||
editActivity(input: ActivityEditInput!): Activity!
|
editActivity(input: ActivityEditInput!): Activity!
|
||||||
|
|
||||||
|
# ITEM
|
||||||
|
|
||||||
# ISSUE
|
# ISSUE
|
||||||
"Create a new issue."
|
"Create a new issue."
|
||||||
createIssue(input: IssueCreateInput!): Issue!
|
createIssue(input: IssueCreateInput!): Issue!
|
||||||
|
# ISSUE TASK
|
||||||
|
"Create a new issue task."
|
||||||
|
createIssueTask(input: IssueTaskCreateInput!): IssueTask!
|
||||||
|
"Edit an issue task."
|
||||||
|
editIssueTask(input: IssueTaskEditInput!): IssueTask!
|
||||||
|
# ISSUE ITEM
|
||||||
|
|
||||||
|
# LOG
|
||||||
|
|
||||||
# USER
|
# USER
|
||||||
"Log in."
|
"Log in."
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ProjectPermission {
|
|||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"The project's statuses for issues and issue tasks."
|
||||||
type ProjectStatus {
|
type ProjectStatus {
|
||||||
"The stage of the status. 0=inactive, 1=pending, 2=active, 3=review, 4=completed, 5=failed, 6=postponed"
|
"The stage of the status. 0=inactive, 1=pending, 2=active, 3=review, 4=completed, 5=failed, 6=postponed"
|
||||||
stage: Int!
|
stage: Int!
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
|
"""
|
||||||
|
A ISO3339 timestamp.
|
||||||
|
"""
|
||||||
scalar Time
|
scalar Time
|
||||||
|
|
||||||
|
"""
|
||||||
|
Duration in milliseconds. It will output as an integer, but can be supplied as a float, integer
|
||||||
|
or a string accepted by the Go time.Duration parser.
|
||||||
|
"""
|
||||||
|
scalar Duration
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
-- +goose StatementBegin
|
-- +goose StatementBegin
|
||||||
CREATE TABLE project_status (
|
CREATE TABLE project_status (
|
||||||
project_id CHAR(16) NOT NULL,
|
project_id CHAR(16) NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
name CHAR(32) NOT NULL,
|
||||||
stage INT NOT NULL,
|
stage INT NOT NULL,
|
||||||
description VARCHAR(255) NOT NULL,
|
description VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE issue_task (
|
||||||
|
issue_task_id CHAR(48) PRIMARY KEY,
|
||||||
|
issue_id CHAR(32) NOT NULL,
|
||||||
|
activity_id CHAR(16) NOT NULL,
|
||||||
|
created_time TIMESTAMP NOT NULL,
|
||||||
|
updated_time TIMESTAMP NOT NULL,
|
||||||
|
due_time TIMESTAMP,
|
||||||
|
status_stage INT NOT NULL,
|
||||||
|
status_name CHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
estimated_time BIGINT NOT NULL,
|
||||||
|
estimated_units INT NOT NULL,
|
||||||
|
points_multiplier FLOAT NOT NULL,
|
||||||
|
|
||||||
|
INDEX (issue_id),
|
||||||
|
INDEX (activity_id)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE issue_task;
|
||||||
|
-- +goose StatementEnd
|
||||||
+10
-2
@@ -4,12 +4,12 @@ import "time"
|
|||||||
|
|
||||||
// An IssueTask is a task within an issue.
|
// An IssueTask is a task within an issue.
|
||||||
type IssueTask struct {
|
type IssueTask struct {
|
||||||
TaskID string `db:"task_id"`
|
ID string `db:"issue_task_id"`
|
||||||
IssueID string `db:"issue_id"`
|
IssueID string `db:"issue_id"`
|
||||||
ActivityID string `db:"activity_id"`
|
ActivityID string `db:"activity_id"`
|
||||||
CreatedTime time.Time `db:"created_time"`
|
CreatedTime time.Time `db:"created_time"`
|
||||||
UpdatedTime time.Time `db:"updated_time"`
|
UpdatedTime time.Time `db:"updated_time"`
|
||||||
DueTime time.Time `db:"due_time"`
|
DueTime *time.Time `db:"due_time"`
|
||||||
StatusStage int `db:"status_stage"`
|
StatusStage int `db:"status_stage"`
|
||||||
StatusName string `db:"status_name"`
|
StatusName string `db:"status_name"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
@@ -18,3 +18,11 @@ type IssueTask struct {
|
|||||||
EstimatedUnits int `db:"estimated_units"`
|
EstimatedUnits int `db:"estimated_units"`
|
||||||
PointsMultiplier float64 `db:"points_multiplier"`
|
PointsMultiplier float64 `db:"points_multiplier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IssueTaskFilter struct {
|
||||||
|
IssueTaskIDs []string
|
||||||
|
IssueIDs []string
|
||||||
|
ActivityIDs []string
|
||||||
|
MinStage *int
|
||||||
|
MaxStage *int
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,3 @@ type ItemFilter struct {
|
|||||||
ItemIDs []string
|
ItemIDs []string
|
||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
SELECT i.item_id, i.name FROM item i
|
|
||||||
LEFT JOIN tag AS t ON t.item_id = i.item_id
|
|
||||||
WHERE t.tag_name IN ("Groceries")
|
|
||||||
GROUP by i.item_id;
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type Log struct {
|
type Log struct {
|
||||||
ID string `db:"log_id"`
|
ID string `db:"log_id"`
|
||||||
IssueID string `db:"issue_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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogFilter struct {
|
||||||
|
LogIDs []string
|
||||||
|
IssueIDs []string
|
||||||
|
IssueTaskIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogItem struct {
|
||||||
|
LogID string `db:"log_id"`
|
||||||
|
IssueItemID string `db:"issue_item_id"`
|
||||||
|
Amount int `db:"amount"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user