From 388ec4f687c52a7253d10b91063f2ead56c97812 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 17 Jul 2021 17:16:15 +0200 Subject: [PATCH] add project groups to backend. --- api/project.go | 7 + api/projectgroup.go | 93 ++++++++++++ cmd/stufflog2-lambda/main.go | 1 + cmd/stufflog2-local/main.go | 1 + database/database.go | 1 + database/postgres/db.go | 4 + database/postgres/project.go | 13 +- database/postgres/projectgroups.go | 140 ++++++++++++++++++ internal/generate/ids.go | 4 + ...10418180353_create_table_project_group.sql | 16 ++ ...20_add_project_column_project_group_id.sql | 11 ++ models/project.go | 10 ++ models/projectgroup.go | 65 +++++++- services/loader.go | 83 +++++++++++ 14 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 api/projectgroup.go create mode 100644 database/postgres/projectgroups.go create mode 100644 migrations/postgres/20210418180353_create_table_project_group.sql create mode 100644 migrations/postgres/20210717135720_add_project_column_project_group_id.sql diff --git a/api/project.go b/api/project.go index 30f12e1..7375e5d 100644 --- a/api/project.go +++ b/api/project.go @@ -8,6 +8,7 @@ import ( "github.com/gissleh/stufflog/internal/slerrors" "github.com/gissleh/stufflog/models" "github.com/gissleh/stufflog/services" + "strings" "time" ) @@ -30,6 +31,12 @@ func Project(g *gin.RouterGroup, db database.Database) { favorite := setting == "true" filter.Favorite = &favorite } + if setting := c.Query("ungrouped"); setting != "" { + filter.Ungrouped = setting == "true" + } + if setting := c.Query("groups"); setting != "" { + filter.ProjectGroupIDs = strings.Split(setting, ",") + } return l.ListProjects(c, filter) })) diff --git a/api/projectgroup.go b/api/projectgroup.go new file mode 100644 index 0000000..9826b0d --- /dev/null +++ b/api/projectgroup.go @@ -0,0 +1,93 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/gissleh/stufflog/database" + "github.com/gissleh/stufflog/internal/auth" + "github.com/gissleh/stufflog/internal/generate" + "github.com/gissleh/stufflog/internal/slerrors" + "github.com/gissleh/stufflog/models" + "github.com/gissleh/stufflog/services" +) + +func ProjectGroup(g *gin.RouterGroup, db database.Database) { + l := services.Loader{DB: db} + + g.GET("/", handler("projectGroups", func(c *gin.Context) (interface{}, error) { + return l.ListProjectGroups(c) + })) + + g.GET("/:id", handler("projectGroup", func(c *gin.Context) (interface{}, error) { + return l.FindProject(c, c.Param("id")) + })) + + g.POST("/", handler("projectGroup", func(c *gin.Context) (interface{}, error) { + group := models.ProjectGroup{} + err := c.BindJSON(&group) + if err != nil { + return nil, slerrors.BadRequest("Invalid JSON") + } + if group.Abbreviation == "" || group.Name == "" { + return nil, slerrors.BadRequest("Abbreviation and name cannot be left empty.") + } + + group.ID = generate.ProjectGroupID() + group.UserID = auth.UserID(c) + if group.CategoryNames == nil { + group.CategoryNames = make(map[string]string) + } + + err = db.ProjectGroups().Insert(c.Request.Context(), group) + if err != nil { + return nil, err + } + + return &group, nil + })) + + g.PUT("/:id", handler("projectGroup", func(c *gin.Context) (interface{}, error) { + update := models.ProjectGroupUpdate{} + err := c.BindJSON(&update) + if err != nil { + return nil, slerrors.BadRequest("Invalid JSON") + } + if (update.Name != nil && *update.Name == "") || (update.Abbreviation != nil && *update.Abbreviation == "") { + return nil, slerrors.BadRequest("Abbreviation and name cannot be left empty.") + } + + group, err := db.ProjectGroups().Find(c, c.Param("id")) + if err != nil { + return nil, err + } + if group.UserID != auth.UserID(c) { + return nil, slerrors.NotFound("Project group") + } + + group.Update(update) + + err = db.ProjectGroups().Update(c.Request.Context(), *group) + if err != nil { + return nil, err + } + + return group, nil + })) + + g.DELETE("/:id", handler("project", func(c *gin.Context) (interface{}, error) { + group, err := l.FindProjectGroup(c.Request.Context(), c.Param("id")) + if err != nil { + return nil, err + } + + if len(group.Projects) > 0 { + return nil, slerrors.Forbidden("You cannot delete non-empty project group.") + } + + err = db.ProjectGroups().Delete(c.Request.Context(), group.ProjectGroup) + if err != nil { + return nil, err + } + + return group, nil + })) +} diff --git a/cmd/stufflog2-lambda/main.go b/cmd/stufflog2-lambda/main.go index c7eb84c..93b1e09 100644 --- a/cmd/stufflog2-lambda/main.go +++ b/cmd/stufflog2-lambda/main.go @@ -40,6 +40,7 @@ func main() { api.Group(server.Group("/api/group"), db) api.Item(server.Group("/api/item"), db) api.Project(server.Group("/api/project"), db) + api.ProjectGroup(server.Group("/api/projectgroup"), db) api.Task(server.Group("/api/task"), db) api.Log(server.Group("/api/log"), db) api.Goal(server.Group("/api/goal"), db) diff --git a/cmd/stufflog2-local/main.go b/cmd/stufflog2-local/main.go index e4e00fc..a4ac647 100644 --- a/cmd/stufflog2-local/main.go +++ b/cmd/stufflog2-local/main.go @@ -45,6 +45,7 @@ func main() { api.Group(server.Group("/api/group"), db) api.Item(server.Group("/api/item"), db) api.Project(server.Group("/api/project"), db) + api.ProjectGroup(server.Group("/api/projectgroup"), db) api.Task(server.Group("/api/task"), db) api.Log(server.Group("/api/log"), db) api.Goal(server.Group("/api/goal"), db) diff --git a/database/database.go b/database/database.go index 4a5af86..09dac6f 100644 --- a/database/database.go +++ b/database/database.go @@ -16,6 +16,7 @@ type Database interface { Logs() models.LogRepository Projects() models.ProjectRepository Tasks() models.TaskRepository + ProjectGroups() models.ProjectGroupRepository } func Open(ctx context.Context, driver string, connect string) (Database, error) { diff --git a/database/postgres/db.go b/database/postgres/db.go index ddcb30a..e72bb2d 100644 --- a/database/postgres/db.go +++ b/database/postgres/db.go @@ -36,6 +36,10 @@ func (d *Database) Tasks() models.TaskRepository { return &taskRepository{db: d.db} } +func (d *Database) ProjectGroups() models.ProjectGroupRepository { + return &projectGroupRepository{db: d.db} +} + func Setup(ctx context.Context, connect string) (*Database, error) { db, err := sqlx.ConnectContext(ctx, "postgres", connect) if err != nil { diff --git a/database/postgres/project.go b/database/postgres/project.go index f820690..10dca63 100644 --- a/database/postgres/project.go +++ b/database/postgres/project.go @@ -54,6 +54,11 @@ func (r *projectRepository) List(ctx context.Context, filter models.ProjectFilte if filter.Favorite != nil { sq = sq.Where(squirrel.Eq{"favorite": *filter.Favorite}) } + if filter.Ungrouped == true { + sq = sq.Where("project_group_id is NULL") + } else if filter.ProjectGroupIDs != nil { + sq = sq.Where(squirrel.Eq{"project_group_id": filter.ProjectGroupIDs}) + } sq = sq.OrderBy("active", "created_time DESC") @@ -86,9 +91,9 @@ func (r *projectRepository) Insert(ctx context.Context, project models.Project) _, err := r.db.NamedExecContext(ctx, ` INSERT INTO project( - project_id, user_id, name, description, icon, active, created_time, start_time, end_time, subtract_amount, status_tag, favorite, tags + project_id, user_id, project_group_id, name, description, icon, active, created_time, start_time, end_time, subtract_amount, status_tag, favorite, tags ) VALUES ( - :project_id, :user_id, :name, :description, :icon, :active, :created_time, :start_time, :end_time, :subtract_amount, :status_tag, :favorite, :tags + :project_id, :user_id, :project_group_id, :name, :description, :icon, :active, :created_time, :start_time, :end_time, :subtract_amount, :status_tag, :favorite, :tags ) `, toProjectDBO(project)) if err != nil { @@ -107,6 +112,7 @@ func (r *projectRepository) Update(ctx context.Context, project models.Project) UPDATE project SET name = :name, description = :description, + project_group_id = :project_group_id, icon = :icon, active = :active, start_time = :start_time, @@ -144,6 +150,7 @@ func (r *projectRepository) Delete(ctx context.Context, project models.Project) type projectDBO struct { ID string `json:"id" db:"project_id"` UserID string `json:"-" db:"user_id"` + ProjectGroupID *string `json:"projectGroupID" db:"project_group_id"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Icon string `json:"icon" db:"icon"` @@ -161,6 +168,7 @@ func toProjectDBO(project models.Project) *projectDBO { return &projectDBO{ ID: project.ID, UserID: project.UserID, + ProjectGroupID: project.GroupID, Name: project.Name, Description: project.Description, Icon: project.Icon, @@ -179,6 +187,7 @@ func (d *projectDBO) ToProject() *models.Project { return &models.Project{ ID: d.ID, UserID: d.UserID, + GroupID: d.ProjectGroupID, Name: d.Name, Description: d.Description, Icon: d.Icon, diff --git a/database/postgres/projectgroups.go b/database/postgres/projectgroups.go new file mode 100644 index 0000000..a3d441d --- /dev/null +++ b/database/postgres/projectgroups.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "github.com/gissleh/stufflog/internal/slerrors" + "github.com/gissleh/stufflog/models" + "github.com/jmoiron/sqlx" +) + +type projectGroupRepository struct { + db *sqlx.DB +} + +func (r *projectGroupRepository) Find(ctx context.Context, id string) (*models.ProjectGroup, error) { + res := projectGroupDBO{} + err := r.db.GetContext(ctx, &res, "SELECT * FROM project_group WHERE project_group_id=$1", id) + if err != nil { + if err == sql.ErrNoRows { + return nil, slerrors.NotFound("Project group") + } + + return nil, err + } + + return res.ToProjectGroup(), nil +} + +func (r *projectGroupRepository) List(ctx context.Context, filter models.ProjectGroupFilter) ([]*models.ProjectGroup, error) { + res := make([]*projectGroupDBO, 0, 16) + err := r.db.SelectContext(ctx, &res, "SELECT * FROM project_group WHERE user_id=$1", filter.UserID) + if err != nil { + if err == sql.ErrNoRows { + return []*models.ProjectGroup{}, nil + } + + return nil, err + } + + res2 := make([]*models.ProjectGroup, 0, len(res)) + for _, pdo := range res { + res2 = append(res2, pdo.ToProjectGroup()) + } + + return res2, nil +} + +func (r *projectGroupRepository) Insert(ctx context.Context, group models.ProjectGroup) error { + _, err := r.db.NamedExecContext(ctx, ` + INSERT INTO project_group ( + project_group_id, user_id, name, abbreviation, description, category_names + ) VALUES ( + :project_group_id, :user_id, :name, :abbreviation, :description, :category_names + ) + `, toProjectGroupDBO(group)) + if err != nil { + return err + } + + return nil +} + +func (r *projectGroupRepository) Update(ctx context.Context, group models.ProjectGroup) error { + if group.CategoryNames == nil { + group.CategoryNames = make(map[string]string) + } + + _, err := r.db.NamedExecContext(ctx, ` + UPDATE project_group SET + name = :name, + abbreviation = :abbreviation, + description = :description, + category_names = :category_names + WHERE project_group_id = :project_group_id + `, toProjectGroupDBO(group)) + return err +} + +func (r *projectGroupRepository) Delete(ctx context.Context, group models.ProjectGroup) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM project_group WHERE project_group_id=$1`, group.ID) + if err != nil { + return err + } + + _, err = r.db.ExecContext(ctx, "UPDATE project SET project_group_id=NULL where project_group_id=$1", group.ID) + if err != nil && err != sql.ErrNoRows { + return err + } + + return nil +} + +type projectGroupDBO struct { + ID string `db:"project_group_id"` + UserID string `db:"user_id"` + Name string `db:"name"` + Description string `db:"description"` + Abbreviation string `db:"abbreviation"` + CategoryNames jsonMap `db:"category_names"` +} + +func (dbo *projectGroupDBO) ToProjectGroup() *models.ProjectGroup { + return &models.ProjectGroup{ + ID: dbo.ID, + UserID: dbo.UserID, + Name: dbo.Name, + Description: dbo.Description, + Abbreviation: dbo.Abbreviation, + CategoryNames: dbo.CategoryNames, + } +} + +func toProjectGroupDBO(group models.ProjectGroup) projectGroupDBO { + return projectGroupDBO{ + ID: group.ID, + UserID: group.UserID, + Name: group.Name, + Description: group.Description, + Abbreviation: group.Abbreviation, + CategoryNames: group.CategoryNames, + } +} + +type jsonMap map[string]string + +func (m jsonMap) Value() (driver.Value, error) { + return json.Marshal(m) +} + +func (m *jsonMap) Scan(value interface{}) error { + b, ok := value.([]byte) + if !ok { + return errors.New("type assertion to []byte failed") + } + + return json.Unmarshal(b, m) +} diff --git a/internal/generate/ids.go b/internal/generate/ids.go index d4f71d6..e842e4a 100644 --- a/internal/generate/ids.go +++ b/internal/generate/ids.go @@ -39,6 +39,10 @@ func ProjectID() string { return id("P", 16) } +func ProjectGroupID() string { + return id("C", 16) +} + func TaskID() string { return id("T", 16) } diff --git a/migrations/postgres/20210418180353_create_table_project_group.sql b/migrations/postgres/20210418180353_create_table_project_group.sql new file mode 100644 index 0000000..83ece8f --- /dev/null +++ b/migrations/postgres/20210418180353_create_table_project_group.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE project_group ( + project_group_id CHAR(16) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + name TEXT NOT NULL, + abbreviation TEXT NOT NULL, + description TEXT NOT NULL, + category_names JSONB NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS project_group; +-- +goose StatementEnd diff --git a/migrations/postgres/20210717135720_add_project_column_project_group_id.sql b/migrations/postgres/20210717135720_add_project_column_project_group_id.sql new file mode 100644 index 0000000..d39b3d2 --- /dev/null +++ b/migrations/postgres/20210717135720_add_project_column_project_group_id.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE project + ADD COLUMN project_group_id CHAR(16) DEFAULT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE project + DROP COLUMN IF EXISTS project_group_id; +-- +goose StatementEnd diff --git a/models/project.go b/models/project.go index 894d84d..f398233 100644 --- a/models/project.go +++ b/models/project.go @@ -8,6 +8,7 @@ import ( type Project struct { ID string `json:"id" db:"project_id"` UserID string `json:"-" db:"user_id"` + GroupID *string `json:"groupId" db:"project_group_id"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Icon string `json:"icon" db:"icon"` @@ -22,6 +23,12 @@ type Project struct { } func (project *Project) Update(update ProjectUpdate) { + if update.GroupID != nil { + project.GroupID = update.GroupID + if *project.GroupID == "" { + project.GroupID = nil + } + } if update.Name != nil { project.Name = *update.Name } @@ -73,6 +80,7 @@ func (project *Project) Update(update ProjectUpdate) { } type ProjectUpdate struct { + GroupID *string `json:"groupId"` Name *string `json:"name"` Description *string `json:"description"` Icon *string `json:"icon"` @@ -95,11 +103,13 @@ type ProjectResult struct { type ProjectFilter struct { UserID string + ProjectGroupIDs []string Active *bool Favorite *bool Expiring bool IncludeSemiActive bool IDs []string + Ungrouped bool } type ProjectRepository interface { diff --git a/models/projectgroup.go b/models/projectgroup.go index b0a7fd8..ef4e342 100644 --- a/models/projectgroup.go +++ b/models/projectgroup.go @@ -3,24 +3,79 @@ package models import "context" type ProjectGroup struct { - ID string `json:"id" db:"id"` + ID string `json:"id" db:"project_group_id"` UserID string `json:"-" db:"user_id"` Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` Abbreviation string `json:"abbreviation" db:"abbreviation"` CategoryNames map[string]string `json:"categoryNames" db:"category_names"` } +func (group *ProjectGroup) Update(update ProjectGroupUpdate) { + for key, value := range update.SetCategoryNames { + if value == "" { + delete(group.CategoryNames, key) + } else { + group.CategoryNames[key] = value + } + } + + if update.Name != nil && *update.Name != "" { + group.Name = *update.Name + } + if update.Abbreviation != nil && *update.Abbreviation != "" { + group.Abbreviation = *update.Abbreviation + } + if update.Description != nil { + group.Description = *update.Description + } +} + type ProjectGroupUpdate struct { - Name *string `json:"name"` - Abbreviation *string `json:"abbreviation"` - SetCategoryNames map[string]string `json:"setCategoryNames"` - ResetCategoryName *string `json:"resetCategoryName"` + Name *string `json:"name"` + Abbreviation *string `json:"abbreviation"` + Description *string `json:"description"` + SetCategoryNames map[string]string `json:"setCategoryNames"` } type ProjectGroupFilter struct { UserID string `json:"userId"` } +type ProjectGroupResult struct { + ProjectGroup + + Projects []*ProjectResult `json:"projects"` + ProjectCounts map[string]int `json:"projectCounts"` + TaskCounts map[string]int `json:"taskCounts"` +} + +func (r *ProjectGroupResult) RecountTasks() { + r.ProjectCounts = make(map[string]int) + r.TaskCounts = make(map[string]int) + + r.ProjectCounts["total"] = 0 + r.TaskCounts["total"] = 0 + + for _, project := range r.Projects { + r.ProjectCounts["total"] += 1 + if project.StatusTag == nil { + r.ProjectCounts["active"] += 1 + } else { + r.ProjectCounts[*project.StatusTag] += 1 + } + + for _, task := range project.Tasks { + r.TaskCounts["total"] += 1 + if task.StatusTag == nil { + r.TaskCounts["active"] += 1 + } else { + r.TaskCounts[*task.StatusTag] += 1 + } + } + } +} + type ProjectGroupRepository interface { Find(ctx context.Context, id string) (*ProjectGroup, error) List(ctx context.Context, filter ProjectGroupFilter) ([]*ProjectGroup, error) diff --git a/services/loader.go b/services/loader.go index 3cb0756..bdd8f17 100644 --- a/services/loader.go +++ b/services/loader.go @@ -373,6 +373,89 @@ func (l *Loader) ListProjects(ctx context.Context, filter models.ProjectFilter) return results, nil } +func (l *Loader) FindProjectGroup(ctx context.Context, id string) (*models.ProjectGroupResult, error) { + group, err := l.DB.ProjectGroups().Find(ctx, id) + if err != nil { + return nil, err + } + + projects, err := l.ListProjects(ctx, models.ProjectFilter{ + UserID: auth.UserID(ctx), + ProjectGroupIDs: []string{group.ID}, + }) + if err != nil { + return nil, err + } + + result := &models.ProjectGroupResult{ + ProjectGroup: *group, + Projects: projects, + } + result.RecountTasks() + + return result, nil +} + +func (l *Loader) ListProjectGroups(ctx context.Context) ([]*models.ProjectGroupResult, error) { + groups, err := l.DB.ProjectGroups().List(ctx, models.ProjectGroupFilter{UserID: auth.UserID(ctx)}) + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(groups)) + for _, group := range groups { + ids = append(ids, group.ID) + } + + projects, err := l.ListProjects(ctx, models.ProjectFilter{ + UserID: auth.UserID(ctx), + ProjectGroupIDs: ids, + }) + + results := make([]*models.ProjectGroupResult, 0, len(groups)+1) + + for _, group := range groups { + matchingProjects := make([]*models.ProjectResult, 0, len(projects)/len(groups)) + for _, project := range projects { + if *project.GroupID == group.ID { + matchingProjects = append(matchingProjects, project) + } + } + + result := &models.ProjectGroupResult{ + ProjectGroup: *group, + Projects: matchingProjects, + } + result.RecountTasks() + + results = append(results, result) + } + + ungroupedProjects, err := l.ListProjects(ctx, models.ProjectFilter{ + UserID: auth.UserID(ctx), + Ungrouped: true, + }) + if err != nil { + return nil, err + } + if len(ungroupedProjects) > 0 { + result := &models.ProjectGroupResult{ + ProjectGroup: models.ProjectGroup{ + ID: "META_UNGROUPED", + Name: "Ungrouped", + Abbreviation: "OTHER", + CategoryNames: map[string]string{}, + }, + Projects: ungroupedProjects, + } + result.RecountTasks() + + results = append(results, result) + } + + return results, nil +} + func (l *Loader) FindTask(ctx context.Context, id string) (*models.TaskResult, error) { task, err := l.DB.Tasks().Find(ctx, id) if err != nil {