Browse Source

> be fist commit

master
Gisle Aune 4 years ago
commit
4cce632708
  1. 8
      .idea/.gitignore
  2. 5
      .idea/codeStyles/codeStyleConfig.xml
  3. 11
      .idea/dataSources.xml
  4. 13
      .idea/dictionaries/gisle.xml
  5. 6
      .idea/misc.xml
  6. 8
      .idea/modules.xml
  7. 7
      .idea/sqldialects.xml
  8. 29
      .idea/watcherTasks.xml
  9. 8
      .idea/xiaoli.iml
  10. 29
      database/database.go
  11. 103
      database/drivers/mysqldriver/db.go
  12. 78
      database/drivers/mysqldriver/db_test.go
  13. 169
      database/drivers/mysqldriver/issues.go
  14. 188
      database/drivers/mysqldriver/issues_test.go
  15. 215
      database/drivers/mysqldriver/items.go
  16. 142
      database/drivers/mysqldriver/items_test.go
  17. 139
      database/drivers/mysqldriver/projects.go
  18. 165
      database/drivers/mysqldriver/projects_test.go
  19. 49
      database/drivers/mysqldriver/session.go
  20. 99
      database/drivers/mysqldriver/users.go
  21. 117
      database/drivers/mysqldriver/users_test.go
  22. 14
      database/repositories/issuerepository.go
  23. 15
      database/repositories/itemrepository.go
  24. 17
      database/repositories/projectrepository.go
  25. 13
      database/repositories/sessionrepository.go
  26. 14
      database/repositories/userrepository.go
  27. 17
      go.mod
  28. 143
      go.sum
  29. 18
      graph/gqlgen.yml
  30. 30
      graph/graph.go
  31. 4983
      graph/graphcore/exec_gen.go
  32. 38
      graph/graphcore/input_gen.go
  33. 29
      graph/resolvers/issue.resolvers.go
  34. 25
      graph/resolvers/mutation.resolvers.go
  35. 29
      graph/resolvers/project.resolvers.go
  36. 37
      graph/resolvers/query.resolvers.go
  37. 11
      graph/resolvers/resolver.go
  38. 37
      graph/schema/issue.gql
  39. 4
      graph/schema/mutation.gql
  40. 70
      graph/schema/project.gql
  41. 14
      graph/schema/query.gql
  42. 1
      graph/schema/scalars.gql
  43. 18
      graph/schema/user.gql
  44. 48
      internal/generate/generate.go
  45. 9
      internal/generate/ids.go
  46. 18
      internal/xlerrors/notfound.go
  47. 75
      main.go
  48. 16
      migrations/mysql/20200405173553_create_table_project.sql
  49. 30
      migrations/mysql/20200406105917_create_table_issue.sql
  50. 16
      migrations/mysql/20200406110301_create_table_item.sql
  51. 15
      migrations/mysql/20200406110546_create_table_item_tag.sql
  52. 15
      migrations/mysql/20200406114528_create_table_user.sql
  53. 15
      migrations/mysql/20200406160317_create_table_counters.sql
  54. 16
      migrations/mysql/20200409113154_create_table_project_permission.sql
  55. 15
      migrations/mysql/20200412174220_create_table_session.sql
  56. 12
      models/activity.go
  57. 21
      models/goal.go
  58. 39
      models/issue.go
  59. 9
      models/issueitem.go
  60. 20
      models/issuetask.go
  61. 21
      models/item.go
  62. 6
      models/log.go
  63. 40
      models/project.go
  64. 36
      models/projectpermission.go
  65. 9
      models/projectstatus.go
  66. 9
      models/session.go
  67. 39
      models/user.go
  68. 133
      services/auth.go
  69. 5
      services/bundle.go

8
.idea/.gitignore

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

5
.idea/codeStyles/codeStyleConfig.xml

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

11
.idea/dataSources.xml

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="xiaoli@localhost" uuid="58ed4679-da13-4e36-bc48-70b2cb7bb1fe">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306/xiaoli</jdbc-url>
</data-source>
</component>
</project>

13
.idea/dictionaries/gisle.xml

@ -0,0 +1,13 @@
<component name="ProjectDictionaryState">
<dictionary name="gisle">
<words>
<w>blargh</w>
<w>blendings</w>
<w>graphcore</w>
<w>qpos</w>
<w>sourcream</w>
<w>stufflog</w>
<w>xiaoli</w>
</words>
</dictionary>
</component>

6
.idea/misc.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
</project>

8
.idea/modules.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/xiaoli.iml" filepath="$PROJECT_DIR$/.idea/xiaoli.iml" />
</modules>
</component>
</project>

7
.idea/sqldialects.xml

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/mysql/20200405173553_create_table_project.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="MariaDB" />
</component>
</project>

29
.idea/watcherTasks.xml

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="fmt $FilePath$" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="go" />
<option name="immediateSync" value="false" />
<option name="name" value="go fmt" />
<option name="output" value="$FilePath$" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="$GoExecPath$" />
<option name="runOnExternalChanges" value="false" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$ProjectFileDir$" />
<envs>
<env name="GOROOT" value="$GOROOT$" />
<env name="GOPATH" value="$GOPATH$" />
<env name="PATH" value="$GoBinDirs$" />
</envs>
</TaskOptions>
</component>
</project>

8
.idea/xiaoli.iml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

29
database/database.go

@ -0,0 +1,29 @@
package database
import (
"errors"
"git.aiterp.net/stufflog/server/database/drivers/mysqldriver"
"git.aiterp.net/stufflog/server/database/repositories"
)
var ErrDriverNotSupported = errors.New("driver not found or supported")
type Database interface {
Issues() repositories.IssueRepository
Items() repositories.ItemRepository
Projects() repositories.ProjectRepository
Session() repositories.SessionRepository
Users() repositories.UserRepository
// Migrate the database.
Migrate() error
}
func Open(driver, connect string) (Database, error) {
switch driver {
case "mysql":
return mysqldriver.Open(connect)
default:
return nil, ErrDriverNotSupported
}
}

103
database/drivers/mysqldriver/db.go

@ -0,0 +1,103 @@
package mysqldriver
import (
"context"
"database/sql"
"git.aiterp.net/stufflog/server/database/repositories"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose"
"strings"
// Mysql Driver
_ "github.com/go-sql-driver/mysql"
)
type DB struct {
db *sqlx.DB
issues *issueRepository
items *itemRepository
projects *projectRepository
sessions *sessionRepository
users *userRepository
}
func (db *DB) Issues() repositories.IssueRepository {
return db.issues
}
func (db *DB) Items() repositories.ItemRepository {
return db.items
}
func (db *DB) Projects() repositories.ProjectRepository {
return db.projects
}
func (db *DB) Session() repositories.SessionRepository {
return db.sessions
}
func (db *DB) Users() repositories.UserRepository {
return db.users
}
func (db *DB) Migrate() error {
err := goose.SetDialect("mysql")
if err != nil {
return err
}
return goose.Up(db.db.DB, "migrations/mysql")
}
func Open(connectionString string) (*DB, error) {
// Ensure parseTime is true
qpos := strings.LastIndexByte(connectionString, '?')
if qpos != -1 {
connectionString += "&parseTime=true"
} else {
connectionString += "?parseTime=true"
}
// Connect to the database
db, err := sqlx.Connect("mysql", connectionString)
if err != nil {
return nil, err
}
// Test the connection
err = db.Ping()
if err != nil {
return nil, err
}
issues := &issueRepository{db: db}
items := &itemRepository{db: db}
projects := &projectRepository{db: db}
users := &userRepository{db: db}
return &DB{
db: db,
issues: issues,
items: items,
projects: projects,
users: users,
}, nil
}
func incCounter(ctx context.Context, tx *sqlx.Tx, kind, name string) (int, error) {
value := 1
err := tx.GetContext(ctx, &value, `
SELECT value FROM counters WHERE kind=? AND name=? FOR UPDATE
`, kind, name)
if err != nil && err != sql.ErrNoRows {
return -1, err
}
_, err = tx.ExecContext(ctx, "REPLACE INTO counters (kind, name, value) VALUES (?, ?, ?)", kind, name, value+1)
if err != nil {
return -1, err
}
return value, nil
}

78
database/drivers/mysqldriver/db_test.go

@ -0,0 +1,78 @@
package mysqldriver
import (
"github.com/pressly/goose"
"log"
"os"
"testing"
"time"
)
var testDB *DB
func TestMain(m *testing.M) {
testDbConnect := os.Getenv("DB_TEST_CONNECT")
if testDbConnect == "" {
testDbConnect = "xiaoli_test:test1234@(localhost:3306)/xiaoli_test"
}
db, err := Open(testDbConnect)
if err != nil {
log.Println("DB ERROR", err)
os.Exit(1)
return
}
err = goose.SetDialect("mysql")
if err != nil {
log.Println("GOOSE ERROR", err)
os.Exit(1)
return
}
for {
err = goose.Down(db.db.DB, "../../../migrations/mysql")
if err != nil {
break
}
}
err = goose.Up(db.db.DB, "../../../migrations/mysql")
if err != nil && err != goose.ErrNoNextVersion && err != goose.ErrNoCurrentVersion {
log.Println("UP ERROR", err)
os.Exit(3)
return
}
testDB = db
code := m.Run()
os.Exit(code)
}
func clearTable(tableName string) error {
// If you really want to SQL inject the test DB, go right ahead!
_, err := testDB.db.Exec("DELETE FROM " + tableName + " WHERE TRUE")
return err
}
func mustParseTime(str string) time.Time {
d, err := time.Parse(time.RFC3339Nano, str)
if err != nil {
panic(err)
}
return d.UTC()
}
func ptrInt(v int) *int {
return &v
}
func ptrString(v string) *string {
return &v
}
func ptrBool(v bool) *bool {
return &v
}

169
database/drivers/mysqldriver/issues.go

@ -0,0 +1,169 @@
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"
)
var counterKindIssueID = "NextIssueID"
type issueRepository struct {
db *sqlx.DB
}
func (r *issueRepository) Find(ctx context.Context, id string) (*models.Issue, error) {
issue := models.Issue{}
err := r.db.GetContext(ctx, &issue, "SELECT * FROM issue WHERE issue_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("Issue")
}
return nil, err
}
return &issue, nil
}
func (r *issueRepository) List(ctx context.Context, filter models.IssueFilter) ([]*models.Issue, error) {
q := sq.Select("*").From("issue").OrderBy("updated_time DESC")
if len(filter.IssueIDs) > 0 {
q = q.Where(sq.Eq{"issue_id": filter.IssueIDs})
}
if len(filter.ProjectIDs) > 0 {
q = q.Where(sq.Eq{"project_id": filter.ProjectIDs})
}
if len(filter.OwnerIDs) > 0 {
q = q.Where(sq.Eq{"owner_id": filter.OwnerIDs})
}
if len(filter.AssigneeIDs) > 0 {
q = q.Where(sq.Eq{"assignee_id": filter.AssigneeIDs})
}
if filter.Search != nil && *filter.Search != "" {
q = q.Where("MATCH (name, title, description) AGAINST (?)", *filter.Search)
}
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})
}
if filter.Limit != nil && *filter.Limit > 0 {
q = q.Limit(uint64(*filter.Limit))
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
results := make([]*models.Issue, 0, 16)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return []*models.Issue{}, nil
}
return nil, err
}
return results, nil
}
func (r *issueRepository) Insert(ctx context.Context, issue models.Issue) (*models.Issue, error) {
if issue.ProjectID == "" {
return nil, errors.New("missing project id")
}
if issue.CreatedTime.IsZero() {
issue.CreatedTime = time.Now().Truncate(time.Second)
issue.UpdatedTime = issue.CreatedTime
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
nextID, err := incCounter(ctx, tx, counterKindIssueID, issue.ProjectID)
if err != nil {
_ = tx.Rollback()
return nil, err
}
issue.ID = fmt.Sprintf("%s-%d", issue.ProjectID, nextID)
_, err = tx.NamedExecContext(ctx, `
INSERT INTO issue (
issue_id, project_id, owner_id, assignee_id,
status_stage, status_name, created_time,
updated_time, due_time, name, title, description
) VALUES (
:issue_id, :project_id, :owner_id, :assignee_id,
:status_stage, :status_name, :created_time,
:updated_time, :due_time, :name, :title, :description
)
`, issue)
if err != nil {
_ = tx.Rollback()
return nil, err
}
err = tx.Commit()
if err != nil {
_ = tx.Rollback()
return nil, err
}
return &issue, nil
}
func (r *issueRepository) Save(ctx context.Context, issue models.Issue) error {
_, err := r.db.NamedExecContext(ctx, `
UPDATE issue
SET assignee_id=:assignee_id,
status_stage=:status_stage,
status_name=:status_name,
created_time=:created_time,
updated_time=:updated_time,
due_time=:due_time,
name=:name,
title=:title,
description=:description
WHERE issue_id=:issue_id
`, issue)
if err != nil {
return err
}
return nil
}
func (r *issueRepository) Delete(ctx context.Context, issue models.Issue) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "DELETE FROM issue WHERE issue_id=?", issue.ID)
if err != nil {
_ = tx.Rollback()
return err
}
// TODO: delete from issue_*
err = tx.Commit()
if err != nil {
_ = tx.Rollback()
return err
}
return nil
}

188
database/drivers/mysqldriver/issues_test.go

@ -0,0 +1,188 @@
package mysqldriver
import (
"context"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
var issue1 = models.Issue{
ProjectID: "STUFF",
OwnerID: "Admin",
AssigneeID: "",
StatusStage: models.IssueStageActive,
StatusName: "IN PROGRESS",
DueTime: time.Now().UTC().Add(time.Hour * 72).Truncate(time.Hour * 24).Add(time.Hour * 16),
Name: "Do Stuff",
Title: "Do some important stuff.",
Description: "Stuff and things, items and artifacts, objects and creations.",
}
var issue2 = models.Issue{
ProjectID: "MODELING",
OwnerID: "Test",
AssigneeID: "Test",
StatusStage: models.IssueStagePostponed,
StatusName: "TOO HARD",
Name: "Hard Surface Course",
Title: "Run through the Hard Surface modeling course.",
Description: "maek robit",
}
var issue3 = models.Issue{
ProjectID: "MODELING",
OwnerID: "Test",
AssigneeID: "",
StatusStage: models.IssueStagePending,
StatusName: "TO DO",
Name: "Isometric Room Scene",
Title: "Create an isometric room-box scene.",
Description: "furniture and stuff.",
}
var issue3Updated = models.Issue{
ProjectID: "MODELING",
OwnerID: "Test",
AssigneeID: "Admin",
StatusStage: models.IssueStageActive,
StatusName: "WORK IN PROGRESS",
Name: "Room Scene",
Title: "Create a room-box scene.",
Description: "THREE DIMENSIONAL DESK CLUTTER",
}
var issue4 = models.Issue{
ProjectID: "DINNER",
OwnerID: "Test",
AssigneeID: "Test",
StatusStage: models.IssueStagePending,
StatusName: "SHOPPING LIST",
DueTime: mustParseTime("2020-04-22T17:30:00.000+02:00"),
Name: "Spaghetti Carbonara",
Title: "WEDNESDAY 2020-04-22: Spaghetti Carbonara",
Description: "See shopping list",
}
func TestIssueRepository(t *testing.T) {
issues := testDB.issues
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
assert.NoError(t, clearTable("issue"))
// INSERT
result, err := issues.Insert(ctx, issue1)
assert.NoError(t, err)
assert.Equal(t, "STUFF-1", result.ID)
if result != nil {
issue1.ID = result.ID
issue1.CreatedTime = result.CreatedTime.UTC()
issue1.UpdatedTime = result.UpdatedTime.UTC()
}
result, err = issues.Insert(ctx, issue2)
assert.NoError(t, err)
assert.Equal(t, "MODELING-1", result.ID)
if result != nil {
issue2.ID = result.ID
issue2.CreatedTime = result.CreatedTime.UTC()
issue2.UpdatedTime = result.UpdatedTime.UTC()
}
result, err = issues.Insert(ctx, issue3)
assert.NoError(t, err)
assert.Equal(t, "MODELING-2", result.ID)
if result != nil {
issue3.ID = result.ID
issue3.CreatedTime = result.CreatedTime.UTC()
issue3.UpdatedTime = result.UpdatedTime.UTC()
issue3Updated.ID = result.ID
issue3Updated.CreatedTime = result.CreatedTime.UTC()
issue3Updated.UpdatedTime = result.UpdatedTime.UTC()
}
result, err = issues.Insert(ctx, issue4)
assert.NoError(t, err)
assert.Equal(t, "DINNER-1", result.ID)
if result != nil {
issue4.ID = result.ID
issue4.CreatedTime = result.CreatedTime.UTC()
issue4.UpdatedTime = result.UpdatedTime.UTC()
}
// FIND
result, err = issues.Find(ctx, issue1.ID)
assert.NoError(t, err)
assert.Equal(t, &issue1, result)
result, err = issues.Find(ctx, issue3.ID)
assert.NoError(t, err)
assert.Equal(t, &issue3, result)
// FIND't
result, err = issues.Find(ctx, "NONEXISTENT-666")
assert.Error(t, err)
assert.Nil(t, result)
assert.True(t, xlerrors.IsNotFound(err))
// LIST
results, err := issues.List(ctx, models.IssueFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue4, &issue2, &issue3, &issue1}, results)
results, err = issues.List(ctx, models.IssueFilter{
ProjectIDs: []string{"DINNER", "MODELING"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue4, &issue2, &issue3}, results)
results, err = issues.List(ctx, models.IssueFilter{
IssueIDs: []string{"MODELING-2", "DINNER-1"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue4, &issue3}, results)
results, err = issues.List(ctx, models.IssueFilter{
OwnerIDs: []string{"Test"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue4, &issue2, &issue3}, results)
results, err = issues.List(ctx, models.IssueFilter{
AssigneeIDs: []string{"", "Admin"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue3, &issue1}, results)
results, err = issues.List(ctx, models.IssueFilter{
MinStage: ptrInt(models.IssueStageActive),
MaxStage: ptrInt(models.IssueStageReview),
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue1}, results)
results, err = issues.List(ctx, models.IssueFilter{
Limit: ptrInt(2),
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue4, &issue2}, results)
results, err = issues.List(ctx, models.IssueFilter{
OwnerIDs: []string{"Admin"},
AssigneeIDs: []string{""},
Search: ptrString("stuff"),
})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue1}, results)
// SAVE
issue3Updated.UpdatedTime = time.Now().UTC().Truncate(time.Second)
err = issues.Save(ctx, issue3Updated)
assert.NoError(t, err)
// FIND after SAVE
result, err = issues.Find(ctx, issue3.ID)
assert.NoError(t, err)
assert.Equal(t, &issue3Updated, result)
// DELETE
err = issues.Delete(ctx, issue4)
assert.NoError(t, err)
// LIST after DELETE and SAVE
results, err = issues.List(ctx, models.IssueFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Issue{&issue2, &issue3Updated, &issue1}, results)
}

215
database/drivers/mysqldriver/items.go

@ -0,0 +1,215 @@
package mysqldriver
import (
"context"
"database/sql"
"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 itemRepository struct {
db *sqlx.DB
}
func (r *itemRepository) Find(ctx context.Context, id string) (*models.Item, error) {
item := models.Item{}
err := r.db.GetContext(ctx, &item, "SELECT * FROM item WHERE item_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("Project")
}
return nil, err
}
err = r.db.SelectContext(ctx, &item.Tags, "SELECT tag FROM item_tag WHERE item_id=? ORDER BY tag", id)
if err != nil {
return nil, err
}
return &item, nil
}
func (r *itemRepository) List(ctx context.Context, filter models.ItemFilter) ([]*models.Item, error) {
q := sq.Select("item.*").From("item").OrderBy("name")
if len(filter.ItemIDs) > 0 {
q = q.Where(sq.Eq{"item_id": filter.ItemIDs})
}
if len(filter.Tags) > 0 {
q = q.LeftJoin("item_tag ON item_tag.item_id = item.item_id").
Where(sq.Eq{"item_tag.tag": filter.Tags}).
GroupBy("item.item_id")
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
results := make([]*models.Item, 0, 16)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return []*models.Item{}, nil
}
return nil, err
}
err = r.fillTags(ctx, results)
if err != nil {
return nil, err
}
return results, nil
}
func (r *itemRepository) Insert(ctx context.Context, item models.Item) (*models.Item, error) {
item.ID = generate.ItemID()
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
_, err = tx.NamedExecContext(ctx, `
INSERT INTO item (item_id, name, description, image_url)
VALUES (:item_id, :name, :description, :image_url)
`, item)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if len(item.Tags) > 0 {
q := sq.Insert("item_tag").Columns("item_id", "tag")
for _, tag := range item.Tags {
q = q.Values(item.ID, tag)
}
tagQuery, args, err := q.ToSql()
if err != nil {
_ = tx.Rollback()
return nil, err
}
_, err = r.db.ExecContext(ctx, tagQuery, args...)
if err != nil {
_ = tx.Rollback()
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, err
}
return &item, nil
}
func (r *itemRepository) Save(ctx context.Context, item models.Item) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
_, err = tx.NamedExecContext(ctx, `
UPDATE item
SET name=:name, description=:description, image_url=:image_url
WHERE item_id=:item_id
`, item)
if err != nil {
_ = tx.Rollback()
return err
}
_, err = r.db.ExecContext(ctx, "DELETE FROM item_tag WHERE item_id=?", item.ID)
if err != nil && err != sql.ErrNoRows {
_ = tx.Rollback()
return err
}
if len(item.Tags) > 0 {
q := sq.Insert("item_tag").Columns("item_id", "tag")
for _, tag := range item.Tags {
q = q.Values(item.ID, tag)
}
tagQuery, args, err := q.ToSql()
if err != nil {
_ = tx.Rollback()
return err
}
_, err = r.db.ExecContext(ctx, tagQuery, args...)
if err != nil {
_ = tx.Rollback()
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (r *itemRepository) Delete(ctx context.Context, item models.Item) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM item WHERE item_id=?", item.ID)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, "DELETE FROM item_tag WHERE item_id=?", item.ID)
if err != nil {
return err
}
return err
}
func (r *itemRepository) GetTags(ctx context.Context) ([]string, error) {
tags := make([]string, 0, 16)
err := r.db.SelectContext(ctx, &tags, "SELECT DISTINCT(tag) FROM item_tag")
if err != nil {
return nil, err
}
return tags, nil
}
func (r *itemRepository) fillTags(ctx context.Context, items []*models.Item) error {
ids := make([]string, len(items))
idMap := make(map[string]int, len(items))
for i, item := range items {
ids[i] = item.ID
idMap[item.ID] = i
}
query, args, err := sq.Select("*").From("item_tag").Where(sq.Eq{"item_id": ids}).ToSql()
if err != nil {
return err
}
results := make([]struct {
ItemID string `db:"item_id"`
Tag string `db:"tag"`
}, 0, len(items)*4)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
return err
}
for _, result := range results {
item := items[idMap[result.ItemID]]
item.Tags = append(item.Tags, result.Tag)
}
return nil
}

142
database/drivers/mysqldriver/items_test.go

@ -0,0 +1,142 @@
package mysqldriver
import (
"context"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
var item1ImageURL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
var item1 = models.Item{
Name: "Salmon Fillet - 500g",
Description: "Best fish",
Tags: []string{"Groceries"},
ImageURL: &item1ImageURL,
}
var item1Updated = models.Item{
Name: "Salmon Fillet - 450g",
Description: "Do not handle under suspicious circumstances.",
Tags: []string{"Groceries"},
ImageURL: nil,
}
var item2 = models.Item{
Name: "Tape - Basic",
Description: "",
Tags: []string{"Groceries", "Hardware", "Office Supplies"},
ImageURL: nil,
}
var item3 = models.Item{
Name: "Flour - Wheat - 1kg",
Description: "For bread and stuff",
Tags: []string{"Groceries"},
ImageURL: nil,
}
var item4 = models.Item{
Name: "Flour - Wheat - 2kg",
Description: "For more bread and stuff",
Tags: []string{"Groceries"},
ImageURL: nil,
}
func TestItemRepository(t *testing.T) {
items := testDB.items
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
assert.NoError(t, clearTable("item"))
assert.NoError(t, clearTable("item_tag"))
// INSERT
// IDs are random, so test data needs to be changed before comparison.
result, err := items.Insert(ctx, item1)
assert.NoError(t, err)
if result != nil {
item1.ID = result.ID
item1Updated.ID = result.ID
}
assert.Equal(t, item1, *result)
result, err = items.Insert(ctx, item2)
assert.NoError(t, err)
if result != nil {
item2.ID = result.ID
}
assert.Equal(t, item2, *result)
result, err = items.Insert(ctx, item3)
assert.NoError(t, err)
if result != nil {
item3.ID = result.ID
}
assert.Equal(t, item3, *result)
result, err = items.Insert(ctx, item4)
assert.NoError(t, err)
if result != nil {
item4.ID = result.ID
}
assert.Equal(t, item4, *result)
if t.Failed() {
return
}
// FIND
result, err = items.Find(ctx, item1.ID)
assert.NoError(t, err)
assert.Equal(t, &item1, result)
result, err = items.Find(ctx, item2.ID)
assert.NoError(t, err)
assert.Equal(t, &item2, result)
// FIND't
result, err = items.Find(ctx, "Iobviouslyinvalidid")
assert.Error(t, err)
assert.True(t, xlerrors.IsNotFound(err))
assert.Nil(t, result)
// LIST
results, err := items.List(ctx, models.ItemFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Item{&item3, &item4, &item1, &item2}, results)
results, err = items.List(ctx, models.ItemFilter{
Tags: []string{"Hardware"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Item{&item2}, results)
results, err = items.List(ctx, models.ItemFilter{
ItemIDs: []string{item1.ID, item2.ID},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Item{&item1, &item2}, results)
// UPDATE
err = items.Save(ctx, item1Updated)
assert.NoError(t, err)
// FIND after UPDATE
result, err = items.Find(ctx, item1.ID)
assert.NoError(t, err)
assert.NotEqual(t, &item1, result)
assert.Equal(t, &item1Updated, result)
// TAGS
allTags, err := items.GetTags(ctx)
assert.NoError(t, err)
assert.Equal(t, item2.Tags, allTags)
// DELETE
err = items.Delete(ctx, item2)
assert.NoError(t, err)
// LIST after DELETE
results, err = items.List(ctx, models.ItemFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Item{&item3, &item4, &item1Updated}, results)
// TAGS after DELETE
allTags, err = items.GetTags(ctx)
assert.NoError(t, err)
assert.Equal(t, item1.Tags, allTags)
}

139
database/drivers/mysqldriver/projects.go

@ -0,0 +1,139 @@
package mysqldriver
import (
"context"
"database/sql"
"errors"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
)
type projectRepository struct {
db *sqlx.DB
}
func (r *projectRepository) Find(ctx context.Context, id string) (*models.Project, error) {
project := models.Project{}
err := r.db.GetContext(ctx, &project, "SELECT * FROM project WHERE project_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("Project")
}
return nil, err
}
return &project, nil
}
func (r *projectRepository) List(ctx context.Context, filter models.ProjectFilter) ([]*models.Project, error) {
q := sq.Select("project.*").From("project").OrderBy("project_id")
if len(filter.ProjectIDs) > 0 {
q = q.Where(sq.Eq{"project_id": filter.ProjectIDs})
}
if filter.Search != nil {
q = q.Where("MATCH (name, description) AGAINST (?)", *filter.Search)
}
if filter.Permission != nil && filter.Permission.Valid() {
q = q.LeftJoin("project_permission ON project.project_id = project_permission.project_id AND project_permission.user_id = ?", filter.Permission.UserID)
if filter.Permission.MaxLevel >= filter.Permission.MinLevel {
q = q.Where(sq.And{
sq.GtOrEq{"project_permission.access_level": filter.Permission.MinLevel},
sq.LtOrEq{"project_permission.access_level": filter.Permission.MaxLevel},
})
} else {
q = q.Where(sq.GtOrEq{"project_permission.access_level": filter.Permission.MinLevel})
}
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
results := make([]*models.Project, 0, 16)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return []*models.Project{}, nil
}
return nil, err
}
return results, nil
}
func (r *projectRepository) Insert(ctx context.Context, project models.Project) (*models.Project, error) {
if !project.ValidKey() {
return nil, errors.New("invalid project id")
}
_, err := r.db.NamedExecContext(ctx, `
INSERT INTO project (project_id, name, description, daily_points)
VALUES (:project_id, :name, :description, :daily_points)
`, project)
return &project, err
}
func (r *projectRepository) Save(ctx context.Context, project models.Project) error {
_, err := r.db.NamedExecContext(ctx, `
UPDATE project
SET name=:name, description=:description, daily_points=:daily_points
WHERE project_id=:project_id
`, project)
return err
}
func (r *projectRepository) GetPermission(ctx context.Context, project models.Project, user models.User) (*models.ProjectPermission, error) {
permission := models.ProjectPermission{}
err := r.db.GetContext(ctx, &permission, "SELECT * FROM project_permission WHERE project_id=? AND user_id=?", project.ID, user.ID)
if err != nil {
if err == sql.ErrNoRows {
return &models.ProjectPermission{
ProjectID: project.ID,
UserID: user.ID,
}, nil
}
return nil, err
}
return &permission, nil
}
func (r *projectRepository) GetIssuePermission(ctx context.Context, issue models.Issue, user models.User) (*models.ProjectPermission, error) {
return r.GetPermission(ctx, models.Project{ID: issue.ProjectID}, user)
}
func (r *projectRepository) SetPermission(ctx context.Context, permission models.ProjectPermission) error {
_, err := r.db.NamedExecContext(ctx, `
REPLACE INTO project_permission (project_id, user_id, access_level)
VALUES (:project_id, :user_id, :access_level)
`, permission)
return err
}
func (r *projectRepository) Delete(ctx context.Context, project models.Project) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM project WHERE project_id=?", project.ID)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, "DELETE FROM project_permission WHERE project_id=?", project.ID)
if err != nil {
return err
}
//_, err = r.db.ExecContext(ctx, "DELETE FROM project_status WHERE project_id=?", project.ID)
//if err != nil {
// return err
//}
return nil
}

165
database/drivers/mysqldriver/projects_test.go

@ -0,0 +1,165 @@
package mysqldriver
import (
"context"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
var project1 = models.Project{
ID: "STUFF",
Name: "Stuff and things",
Description: "Items and artifacts",
DailyPoints: 200,
}
var project2 = models.Project{
ID: "MODELING",
Name: "3D Modelling",
Description: "Making stuff.",
DailyPoints: 250,
}
var project3 = models.Project{
ID: "DINNER",
Name: "Dinner",
Description: "Shopping lists and meals.",
DailyPoints: 0,
}
var projectSearch1 = "stuff"
func TestProjectRepository(t *testing.T) {
projects := testDB.projects
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
assert.NoError(t, clearTable("project"))
// INSERT
result, err := projects.Insert(ctx, project1)
assert.NoError(t, err)
assert.Equal(t, project1, *result)
result, err = projects.Insert(ctx, project2)
assert.NoError(t, err)
assert.Equal(t, project2, *result)
result, err = projects.Insert(ctx, project3)
assert.NoError(t, err)
assert.Equal(t, project3, *result)
if t.Failed() {
return
}
// FIND
result, err = projects.Find(ctx, "MODELING")
assert.NoError(t, err)
assert.Equal(t, &project2, result)
// FIND't
result, err = projects.Find(ctx, "BLARGH")
assert.Error(t, err)
assert.True(t, xlerrors.IsNotFound(err))
assert.Nil(t, result)
// LIST
results, err := projects.List(ctx, models.ProjectFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{&project3, &project2, &project1}, results)
results, err = projects.List(ctx, models.ProjectFilter{
Search: &projectSearch1,
})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{&project2, &project1}, results)
results, err = projects.List(ctx, models.ProjectFilter{
ProjectIDs: []string{"DINNER", "MODELING"},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{&project3, &project2}, results)
if t.Failed() {
return
}
// UPDATE
project2fix := project2
project2fix.Name = "3D Modeling"
project2fix.Description = "Modeling 3D stuff."
project2fix.DailyPoints = 150
err = projects.Save(ctx, project2fix)
assert.NoError(t, err)
if t.Failed() {
return
}
// FIND after UPDATE
result, err = projects.Find(ctx, "MODELING")
assert.NoError(t, err)
assert.Equal(t, &project2fix, result)
// DELETE
err = projects.Delete(ctx, project3)
assert.NoError(t, err)
if t.Failed() {
return
}
// LIST after DELETE + UPDATE
results, err = projects.List(ctx, models.ProjectFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{&project2fix, &project1}, results)
// SET PERMISSION
err = projects.SetPermission(ctx, models.ProjectPermission{
ProjectID: project1.ID,
UserID: user1.ID,
Level: models.ProjectPermissionLevelOwner,
})
assert.NoError(t, err)
err = projects.SetPermission(ctx, models.ProjectPermission{
ProjectID: project2fix.ID,
UserID: user1.ID,
Level: models.ProjectPermissionLevelMember,
})
assert.NoError(t, err)
// GET PERMISSIONS
permission, err := projects.GetPermission(ctx, project1, user1)
assert.NoError(t, err)
assert.Equal(t, project1.ID, permission.ProjectID)
assert.Equal(t, user1.ID, permission.UserID)
assert.Equal(t, models.ProjectPermissionLevelOwner, permission.Level)
permission, err = projects.GetPermission(ctx, project2fix, user1)
assert.NoError(t, err)
assert.Equal(t, models.ProjectPermissionLevelMember, permission.Level)
// GET PERMISSION (default)
permission, err = projects.GetPermission(ctx, project1, user2)
assert.NoError(t, err)
assert.Equal(t, project1.ID, permission.ProjectID)
assert.Equal(t, user2.ID, permission.UserID)
assert.Equal(t, models.ProjectPermissionLevelNoAccess, permission.Level)
// LIST after SET PERMISSION
results, err = projects.List(ctx, models.ProjectFilter{
Permission: &models.ProjectFilterPermission{
UserID: user1.ID,
MinLevel: models.ProjectPermissionLevelMember,
},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{&project2fix, &project1}, results)
results, err = projects.List(ctx, models.ProjectFilter{
Permission: &models.ProjectFilterPermission{
UserID: user2.ID,
MinLevel: models.ProjectPermissionLevelMember,
},
})
assert.NoError(t, err)
assert.Equal(t, []*models.Project{}, results)
}

49
database/drivers/mysqldriver/session.go

@ -0,0 +1,49 @@
package mysqldriver
import (
"context"
"database/sql"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
"github.com/jmoiron/sqlx"
"time"
)
type sessionRepository struct {
db *sqlx.DB
}
func (r *sessionRepository) Find(ctx context.Context, id string) (*models.Session, error) {
session := models.Session{}
err := r.db.GetContext(ctx, &session, "SELECT * FROM session WHERE session_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("Session")
}
return nil, err
} else if time.Now().After(session.ExpiryTime) {
return nil, xlerrors.NotFound("Session")
}
return &session, nil
}
func (r *sessionRepository) Save(ctx context.Context, session models.Session) error {
_, err := r.db.NamedExecContext(ctx, `
REPLACE INTO session (session_id, user_id, expiry_time)
VALUES (:session_id, :user_id, :expiry_time)
`, session)
return err
}
func (r *sessionRepository) Delete(ctx context.Context, session models.Session) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM session WHERE session_id=?", session.ID)
return err
}
func (r *sessionRepository) DeleteExpired(ctx context.Context) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM session WHERE expiry_time<?", time.Now())
return err
}

99
database/drivers/mysqldriver/users.go

@ -0,0 +1,99 @@
package mysqldriver
import (
"context"
"database/sql"
"errors"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
)
type userRepository struct {
db *sqlx.DB
}
func (r *userRepository) Find(ctx context.Context, id string) (*models.User, error) {
user := models.User{}
err := r.db.GetContext(ctx, &user, "SELECT * FROM user WHERE user_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("User")
}
return nil, err
}
return &user, nil
}
func (r *userRepository) List(ctx context.Context, filter models.UserFilter) ([]*models.User, error) {
q := sq.Select("user.*").From("user").OrderBy("user_id")
if len(filter.UserIDs) > 0 {
q = q.Where(sq.Eq{"user_id": filter.UserIDs})
}
if filter.Active != nil {
q = q.Where(sq.Eq{"active": *filter.Active})
}
if filter.Admin != nil {
q = q.Where(sq.Eq{"admin": *filter.Admin})
}
if filter.Limit != nil && *filter.Limit > 0 {
q = q.Limit(uint64(*filter.Limit))
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
results := make([]*models.User, 0, 16)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return []*models.User{}, nil
}
return nil, err
}
return results, nil
}
func (r *userRepository) Insert(ctx context.Context, user models.User) (*models.User, error) {
if len(user.Name) < 1 || len([]byte(user.Name)) > 32 {
return nil, errors.New("user id is not valid")
}
_, err := r.db.NamedExecContext(ctx, `
INSERT INTO user (
user_id, name, active, admin, hash
) VALUES (
:user_id, :name, :active, :admin, :hash
)
`, user)
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) Save(ctx context.Context, user models.User) error {
_, err := r.db.NamedExecContext(ctx, `
UPDATE user
SET name=:name,
hash=:hash,
admin=:admin,
active=:active
WHERE user_id=:user_id
`, user)
return err
}
func (r *userRepository) Delete(ctx context.Context, user models.User) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM user WHERE user_id=?", user.ID)
return err
}

117
database/drivers/mysqldriver/users_test.go

@ -0,0 +1,117 @@
package mysqldriver
import (
"context"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
var user1 = models.User{
ID: "Test",
Name: "Testy Tester",
Active: true,
Admin: false,
Hash: []byte("$2y$12$tj/R/zDHrGy1Jsi57DUuSeCISYvEHb/F37p.9HGlyf72cIXnppeQK"),
}
var user2 = models.User{
ID: "Admin",
Name: "Administrator",
Active: true,
Admin: true,
Hash: []byte("$2y$12$tj/R/zDHrGy1Jsi57DUuSeCISYvEHb/F37p.9HGlyf72cIXnppeQK"),
}
var user2Updated = models.User{
ID: user2.ID,
Name: "Dethroned Dictator",
Active: false,
Admin: false,
Hash: []byte("$2y$12$7MuqYzV59HCtHJlRJCd/vOQIrFcMEMVhyySzJX.WlVtEJH3qHVPU2\n"),
}
func TestUserRepository(t *testing.T) {
users := testDB.users
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
assert.NoError(t, clearTable("user"))
// INSERT
result, err := users.Insert(ctx, user1)
assert.NoError(t, err)
assert.Equal(t, user1, *result)
result, err = users.Insert(ctx, user2)
assert.NoError(t, err)
assert.Equal(t, user2, *result)
// FIND
result, err = users.Find(ctx, user1.ID)
assert.NoError(t, err)
assert.Equal(t, &user1, result)
result, err = users.Find(ctx, user2.ID)
assert.NoError(t, err)
assert.Equal(t, &user2, result)
// FIND't
result, err = users.Find(ctx, "NonExistent")
assert.Error(t, err)
assert.True(t, xlerrors.IsNotFound(err))
assert.Nil(t, result)
// LIST
results, err := users.List(ctx, models.UserFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2, &user1}, results)
results, err = users.List(ctx, models.UserFilter{
Admin: ptrBool(true),
})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2}, results)
results, err = users.List(ctx, models.UserFilter{
Active: ptrBool(true),
})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2, &user1}, results)
results, err = users.List(ctx, models.UserFilter{
Active: ptrBool(false),
})
assert.NoError(t, err)
assert.Equal(t, []*models.User{}, results)
results, err = users.List(ctx, models.UserFilter{
UserIDs: []string{user1.ID},
})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user1}, results)
results, err = users.List(ctx, models.UserFilter{
Limit: ptrInt(1),
})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2}, results)
// UPDATE
err = users.Save(ctx, user2Updated)
assert.NoError(t, err)
// LIST after UPDATE
results, err = users.List(ctx, models.UserFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2Updated, &user1}, results)
// DELETE
err = users.Delete(ctx, user1)
assert.NoError(t, err)
// LIST after DELETE
results, err = users.List(ctx, models.UserFilter{})
assert.NoError(t, err)
assert.Equal(t, []*models.User{&user2Updated}, results)
// INSERT after DELETE
result, err = users.Insert(ctx, user1)
assert.NoError(t, err)
assert.Equal(t, user1, *result)
}

14
database/repositories/issuerepository.go

@ -0,0 +1,14 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type IssueRepository interface {
Find(ctx context.Context, id string) (*models.Issue, error)
List(ctx context.Context, filter models.IssueFilter) ([]*models.Issue, error)
Insert(ctx context.Context, issue models.Issue) (*models.Issue, error)
Save(ctx context.Context, issue models.Issue) error
Delete(ctx context.Context, issue models.Issue) error
}

15
database/repositories/itemrepository.go

@ -0,0 +1,15 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type ItemRepository interface {
Find(ctx context.Context, id string) (*models.Item, error)
List(ctx context.Context, filter models.ItemFilter) ([]*models.Item, error)
Insert(ctx context.Context, item models.Item) (*models.Item, error)
Save(ctx context.Context, item models.Item) error
Delete(ctx context.Context, item models.Item) error
GetTags(ctx context.Context) ([]string, error)
}

17
database/repositories/projectrepository.go

@ -0,0 +1,17 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type ProjectRepository interface {
Find(ctx context.Context, id string) (*models.Project, error)
List(ctx context.Context, filter models.ProjectFilter) ([]*models.Project, error)
Insert(ctx context.Context, project models.Project) (*models.Project, error)
Save(ctx context.Context, project models.Project) error
GetPermission(ctx context.Context, project models.Project, user models.User) (*models.ProjectPermission, error)
GetIssuePermission(ctx context.Context, issue models.Issue, user models.User) (*models.ProjectPermission, error)
SetPermission(ctx context.Context, permission models.ProjectPermission) error
Delete(ctx context.Context, project models.Project) error
}

13
database/repositories/sessionrepository.go

@ -0,0 +1,13 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type SessionRepository interface {
Find(ctx context.Context, id string) (*models.Session, error)
Save(ctx context.Context, session models.Session) error
Delete(ctx context.Context, session models.Session) error
DeleteExpired(ctx context.Context) error
}

14
database/repositories/userrepository.go

@ -0,0 +1,14 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type UserRepository interface {
Find(ctx context.Context, id string) (*models.User, error)
List(ctx context.Context, filter models.UserFilter) ([]*models.User, error)
Insert(ctx context.Context, user models.User) (*models.User, error)
Save(ctx context.Context, user models.User) error
Delete(ctx context.Context, user models.User) error
}

17
go.mod

@ -0,0 +1,17 @@
module git.aiterp.net/stufflog/server
go 1.14
require (
github.com/99designs/gqlgen v0.11.3
github.com/Masterminds/squirrel v1.2.0
github.com/gin-gonic/gin v1.6.2
github.com/go-sql-driver/mysql v1.5.0
github.com/jmoiron/sqlx v1.2.0
github.com/pkg/errors v0.9.1
github.com/pressly/goose v2.6.0+incompatible
github.com/stretchr/testify v1.5.1
github.com/urfave/cli/v2 v2.2.0
github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8
)

143
go.sum

@ -0,0 +1,143 @@
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c h1:TUuUh0Xgj97tLMNtWtNvI9mIV6isjEb9lBMNv+77IGM=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.2 h1:88crIK23zO6TqlQBt+f9FrPJNKm9ZEr7qjp9vl/d5TM=
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose v2.6.0+incompatible h1:3f8zIQ8rfgP9tyI0Hmcs2YNAqUCL1c+diLe3iU8Qd/k=
github.com/pressly/goose v2.6.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 h1:fpnn/HnJONpIu6hkXi1u/7rR0NzilgWr4T0JmWkEitk=
golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

18
graph/gqlgen.yml

@ -0,0 +1,18 @@
schema:
- schema/*.gql
exec:
filename: graphcore/exec_gen.go
package: graphcore
model:
filename: graphcore/input_gen.go
package: graphcore
resolver:
layout: follow-schema
dir: resolvers
package: resolvers
autobind:
- git.aiterp.net/stufflog/server/models

30
graph/graph.go

@ -0,0 +1,30 @@
package graph
import (
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/graph/resolvers"
"git.aiterp.net/stufflog/server/services"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/gin-gonic/gin"
)
//go:generate go run github.com/99designs/gqlgen --verbose --config gqlgen.yml
// New creates a new GraphQL schema.
func New(s services.Bundle) graphql.ExecutableSchema {
return graphcore.NewExecutableSchema(graphcore.Config{
Resolvers: &resolvers.Resolver{S: s},
})
}
func Gin(s services.Bundle) gin.HandlerFunc {
schema := New(s)
gqlHandler := handler.NewDefaultServer(schema)
return func(c *gin.Context) {
s.Auth.CheckGinSession(c)
gqlHandler.ServeHTTP(c.Writer, c.Request)
}
}

4983
graph/graphcore/exec_gen.go
File diff suppressed because it is too large
View File

38
graph/graphcore/input_gen.go

@ -0,0 +1,38 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package graphcore
import (
"git.aiterp.net/stufflog/server/models"
)
// A limited filter for constraining the list of issues within a project.
type ProjectIssueFilter struct {
// Filter by assignee IDs
AssigneeIds []string `json:"assigneeIds"`
// Text search
Search *string `json:"search"`
// Earliest stage (inclusive)
MinStage *int `json:"minStage"`
// Latest stage (inclusive)
MaxStage *int `json:"maxStage"`
// Limit the result set
Limit *int `json:"limit"`
}
// The permissions of a user within the project.
type ProjectPermissions struct {
// User ID.
UserID string `json:"userId"`
// Access level.
AccessLevel int `json:"accessLevel"`
// The user whose permissions it is. Can be null if the user no longer exists.
User *models.User `json:"user"`
}
// Input for loginUser.
type UserLoginInput struct {
UserID string `json:"userId"`
Password string `json:"password"`
RememberMe bool `json:"rememberMe"`
}

29
graph/resolvers/issue.resolvers.go

@ -0,0 +1,29 @@
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"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)
func (r *issueResolver) Project(ctx context.Context, obj *models.Issue) (*models.Project, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *issueResolver) Owner(ctx context.Context, obj *models.Issue) (*models.User, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *issueResolver) Assignee(ctx context.Context, obj *models.Issue) (*models.User, error) {
panic(fmt.Errorf("not implemented"))
}
// Issue returns graphcore.IssueResolver implementation.
func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} }
type issueResolver struct{ *Resolver }

25
graph/resolvers/mutation.resolvers.go

@ -0,0 +1,25 @@
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"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)
func (r *mutationResolver) LoginUser(ctx context.Context, input *graphcore.UserLoginInput) (*models.User, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *mutationResolver) LogoutUser(ctx context.Context) (*models.User, error) {
panic(fmt.Errorf("not implemented"))
}
// Mutation returns graphcore.MutationResolver implementation.
func (r *Resolver) Mutation() graphcore.MutationResolver { return &mutationResolver{r} }
type mutationResolver struct{ *Resolver }

29
graph/resolvers/project.resolvers.go

@ -0,0 +1,29 @@
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"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)
func (r *projectResolver) Issues(ctx context.Context, obj *models.Project, filter *graphcore.ProjectIssueFilter) ([]*models.Issue, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *projectResolver) Permissions(ctx context.Context, obj *models.Project) ([]*graphcore.ProjectPermissions, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *projectResolver) UserPermissions(ctx context.Context, obj *models.Project) (*graphcore.ProjectPermissions, error) {
panic(fmt.Errorf("not implemented"))
}
// Project returns graphcore.ProjectResolver implementation.
func (r *Resolver) Project() graphcore.ProjectResolver { return &projectResolver{r} }
type projectResolver struct{ *Resolver }

37
graph/resolvers/query.resolvers.go

@ -0,0 +1,37 @@
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"
"fmt"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)
func (r *queryResolver) Issue(ctx context.Context, id string) (*models.Issue, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *queryResolver) Issues(ctx context.Context, filter *models.IssueFilter) ([]*models.Issue, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *queryResolver) Project(ctx context.Context, id string) (*models.Project, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *queryResolver) Projects(ctx context.Context, filter *models.ProjectFilter) ([]*models.Project, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *queryResolver) Session(ctx context.Context) (*models.User, error) {
panic(fmt.Errorf("not implemented"))
}
// Query returns graphcore.QueryResolver implementation.
func (r *Resolver) Query() graphcore.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }

11
graph/resolvers/resolver.go

@ -0,0 +1,11 @@
package resolvers
import "git.aiterp.net/stufflog/server/services"
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
S services.Bundle
}

37
graph/schema/issue.gql

@ -0,0 +1,37 @@
type Issue {
id: String!
projectId: String!
ownerId: String!
assigneeId: String!
statusStage: Int!
statusName: String!
createdTime: Time!
updatedTime: Time!
dueTime: Time
name: String!
title: String!
description: String!
project: Project
owner: User
assignee: User
}
input IssueFilter {
"Filter by issue IDs (mostly used internally by data loaders)"
issueIds: [String!]
"Filter by project IDs"
projectIds: [String!]
"Filter by owner IDs"
ownerIds: [String!]
"Filter by assignee IDs"
assigneeIds: [String!]
"Text search"
search: String
"Earliest stage (inclusive)"
minStage: Int
"Latest stage (inclusive)"
maxStage: Int
"Limit the result set"
limit: Int
}

4
graph/schema/mutation.gql

@ -0,0 +1,4 @@
type Mutation {
loginUser(input: UserLoginInput): User!
logoutUser: User!
}

70
graph/schema/project.gql

@ -0,0 +1,70 @@
type Project {
"The ID of the project, which is also the prefix for all its issues."
id: String!
"The name of the project, used in place of the ID in the UI."
name: String!
"Description of the project."
description: String!
"The amount of points given as a bonus towards goals for any activity on a given day."
dailyPoints: Int!
"Get issues within the project."
issues(filter: ProjectIssueFilter): [Issue!]!
"All users' permissions. Only available to administrators and up."
permissions: [ProjectPermissions!]!
"Own permissions to the project. Available to any logged in user."
userPermissions: ProjectPermissions!
}
"The permissions of a user within the project."
type ProjectPermissions {
"User ID."
userId: String!
"Access level."
accessLevel: Int!
"The user whose permissions it is. Can be null if the user no longer exists."
user: User
}
type ProjectStatus {
"The stage of the status. 0=inactive, 1=pending, 2=active, 3=review, 4=completed, 5=failed, 6=postponed"
stage: Int!
"The name of the status."
name: String!
"A description of the status and where it's used."
description: String!
}
"Filter for projects query"
input ProjectFilter {
"Project IDs"
projectIds: [String!]
"Text search"
search: String
"User permission"
permission: ProjectFilterPermission
}
input ProjectFilterPermission {
"User ID"
userId: String!
"Lowest access level to filter by (inclusive)"
minLevel: Int!
"Highest access level to filter by (inclusive)"
maxLevel: Int!
}
"A limited filter for constraining the list of issues within a project."
input ProjectIssueFilter {
"Filter by assignee IDs"
assigneeIds: [String!]
"Text search"
search: String
"Earliest stage (inclusive)"
minStage: Int
"Latest stage (inclusive)"
maxStage: Int
"Limit the result set"
limit: Int
}

14
graph/schema/query.gql

@ -0,0 +1,14 @@
type Query {
"Find issue"
issue(id: String!): Issue!
"List issues"
issues(filter: IssueFilter): [Issue!]!
"Find project"
project(id: String!): Project!
"List projects"
projects(filter: ProjectFilter): [Project!]!
"Session checks the user session."
session: User
}

1
graph/schema/scalars.gql

@ -0,0 +1 @@
scalar Time

18
graph/schema/user.gql

@ -0,0 +1,18 @@
"User information."
type User {
"User ID, used when logging in."
id: String!
"User's display name, which can differ from the user ID."
name: String!
"Whether the user is active."
active: Boolean!
"Whether the user has general administrator privileges. This does not grant admin rights on any project."
admin: Boolean!
}
"Input for loginUser."
input UserLoginInput {
userId: String!
password: String!
rememberMe: Boolean!
}

48
internal/generate/generate.go

@ -0,0 +1,48 @@
package generate
import (
"crypto/rand"
"encoding/binary"
mathRand "math/rand"
"strconv"
)
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
func generateWeak(length int, prefix string) string {
result := prefix
for len(result) < length {
result += string(alphabet[mathRand.Intn(len(alphabet))])
}
return result
}
// Generate generates an ID either strongly or weakly depending on the system.
func Generate(length int, prefix string) string {
var buffer [32]byte
result := prefix
offset := 0
_, err := rand.Read(buffer[:32])
if err != nil {
return generateWeak(length, prefix)
}
for len(result) < length {
result += strconv.FormatUint(binary.LittleEndian.Uint64(buffer[offset:]), 36)
offset += 8
if offset >= 32 {
_, err = rand.Read(buffer[:32])
if err != nil {
return generateWeak(length, prefix)
}
offset = 0
}
}
return result[:length]
}

9
internal/generate/ids.go

@ -0,0 +1,9 @@
package generate
func ItemID() string {
return Generate(32, "I")
}
func SessionID() string {
return Generate(32, "S")
}

18
internal/xlerrors/notfound.go

@ -0,0 +1,18 @@
package xlerrors
type notFoundError struct {
Subject string
}
func (err *notFoundError) Error() string {
return err.Subject + " not found"
}
func NotFound(subject string) error {
return &notFoundError{Subject: subject}
}
func IsNotFound(err error) bool {
_, ok := err.(*notFoundError)
return ok
}

75
main.go

@ -0,0 +1,75 @@
package main
import (
"git.aiterp.net/stufflog/server/database"
"github.com/pkg/errors"
"github.com/pressly/goose"
"github.com/urfave/cli/v2"
"log"
"os"
"sort"
)
func main() {
app := &cli.App{
Name: "xiaoli",
Usage: "Issue tracker for your home and hobbies",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "db-driver",
Value: "mysql",
Usage: "Database driver",
EnvVars: []string{"DATABASE_DRIVER"},
},
&cli.StringFlag{
Name: "db-connect",
Value: "xiaoli_user:stuff1234@(localhost:3306)/xiaoli",
Usage: "Database connection string or path",
EnvVars: []string{"DATABASE_CONNECT"},
},
},
Commands: []*cli.Command{
{
Name: "migrate",
Usage: "Migrate the configured database",
Action: func(c *cli.Context) error {
db, err := database.Open(c.String("db-driver"), c.String("db-connect"))
if err != nil {
return errors.Wrap(err, "Failed to connect to database")
}
err = db.Migrate()
if err == goose.ErrNoNextVersion {
log.Println("No more migrations to run")
} else if err != nil {
return errors.Wrap(err, "Failed to run migration")
}
log.Println("Migration succeeded")
return nil
},
},
{
Name: "server",
Usage: "Run the server",
Action: func(c *cli.Context) error {
_, err := database.Open(c.String("db-driver"), c.String("db-connect"))
if err != nil {
return errors.Wrap(err, "Failed to connect to database")
}
return nil
},
},
},
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}

16
migrations/mysql/20200405173553_create_table_project.sql

@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE project (
project_id CHAR(16) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
daily_points INTEGER NOT NULL,
FULLTEXT(name, description)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE project;
-- +goose StatementEnd

30
migrations/mysql/20200406105917_create_table_issue.sql

@ -0,0 +1,30 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE issue (
issue_id CHAR(32) NOT NULL PRIMARY KEY,
project_id CHAR(16) NOT NULL,
owner_id CHAR(32) NOT NULL,
assignee_id CHAR(32) NOT NULL,
status_stage INTEGER NOT NULL,
status_name CHAR(32) NOT NULL,
created_time TIMESTAMP NOT NULL,
updated_time TIMESTAMP NOT NULL,
due_time TIMESTAMP NOT NULL,
name VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
FULLTEXT(name, title, description),
INDEX(owner_id),
INDEX(assignee_id),
INDEX(project_id, status_stage, status_name),
INDEX(project_id),
INDEX(created_time),
INDEX(updated_time)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE issue;
-- +goose StatementEnd

16
migrations/mysql/20200406110301_create_table_item.sql

@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE item (
item_id CHAR(32) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
image_url VARCHAR(255),
FULLTEXT(name, description)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE item;
-- +goose StatementEnd

15
migrations/mysql/20200406110546_create_table_item_tag.sql

@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE item_tag (
item_id CHAR(32),
tag VARCHAR(255),
PRIMARY KEY (item_id, tag),
INDEX (tag)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE item_tag;
-- +goose StatementEnd

15
migrations/mysql/20200406114528_create_table_user.sql

@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE user (
user_id CHAR(32) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
active BOOL NOT NULL,
admin BOOL NOT NULL,
hash VARCHAR(255) NOT NULL
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE user;
-- +goose StatementEnd

15
migrations/mysql/20200406160317_create_table_counters.sql

@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE counters (
kind CHAR(16) NOT NULL,
name CHAR(32) NOT NULL,
value INT NOT NULL,
PRIMARY KEY (kind, name)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE counters;
-- +goose StatementEnd

16
migrations/mysql/20200409113154_create_table_project_permission.sql

@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE project_permission (
project_id CHAR(16) NOT NULL,
user_id CHAR(32) NOT NULL,
access_level INTEGER NOT NULL DEFAULT (0),
PRIMARY KEY (project_id, user_id),
INDEX (user_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE project_permission
-- +goose StatementEnd

15
migrations/mysql/20200412174220_create_table_session.sql

@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE session (
session_id CHAR(32) NOT NULL PRIMARY KEY,
user_id CHAR(32) NOT NULL,
expiry_time TIMESTAMP NOT NULL,
INDEX(expiry_time)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE session;
-- +goose StatementEnd

12
models/activity.go

@ -0,0 +1,12 @@
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"`
}

21
models/goal.go

@ -0,0 +1,21 @@
package models
import "time"
// An Goal denotes a goal for a project or an activity within a project.
// It can ask for one or more of the time, unit or score. If there is no
// activity ID provided, all activities within the project should contribute.
// If there are no activity, a non-zero unit amount is not allowed.
type Goal struct {
ActivityGoalID string `db:"goal_id"`
ProjectID string `db:"project_id"`
ActivityID string `db:"activity_id"`
UserID string `db:"user_id"`
TimeAmount time.Duration `db:"time_amount"`
AllRequired bool `db:"all_required"`
UnitAmount int `db:"unit_amount"`
ScoreAmount int `db:"score_amount"`
TaskAmount int `db:"task_amount"`
StartTime time.Time `db:"start_time"`
EndTime time.Time `db:"end_time"`
}

39
models/issue.go

@ -0,0 +1,39 @@
package models
import "time"
type Issue struct {
ID string `db:"issue_id"`
ProjectID string `db:"project_id"`
OwnerID string `db:"owner_id"`
AssigneeID string `db:"assignee_id"`
StatusStage int `db:"status_stage"`
StatusName string `db:"status_name"`
CreatedTime time.Time `db:"created_time"`
UpdatedTime time.Time `db:"updated_time"`
DueTime time.Time `db:"due_time"`
Name string `db:"name"`
Title string `db:"title"`
Description string `db:"description"`
}
type IssueFilter struct {
IssueIDs []string
ProjectIDs []string
OwnerIDs []string
AssigneeIDs []string
Search *string
MinStage *int
MaxStage *int
Limit *int
}
const (
IssueStageInactive = 0
IssueStagePending = 1
IssueStageActive = 2
IssueStageReview = 3
IssueStageCompleted = 4
IssueStageFailed = 5
IssueStagePostponed = 6
)

9
models/issueitem.go

@ -0,0 +1,9 @@
package models
// An IssueTask is a task within an issue.
type IssueItem struct {
ID string `db:"issue_item_id"`
IssueID string `db:"issue_id"`
ItemID string `db:"item_id"`
Quantity int `db:"quantity"`
}

20
models/issuetask.go

@ -0,0 +1,20 @@
package models
import "time"
// An IssueTask is a task within an issue.
type IssueTask struct {
TaskID string `db:"task_id"`
IssueID string `db:"issue_id"`
ActivityID string `db:"activity_id"`
CreatedTime time.Time `db:"created_time"`
UpdatedTime time.Time `db:"updated_time"`
DueTime time.Time `db:"due_time"`
StatusStage int `db:"status_stage"`
StatusName string `db:"status_name"`
Name string `db:"name"`
Description string `db:"description"`
EstimatedTime time.Duration `db:"estimated_time"`
EstimatedUnits int `db:"estimated_units"`
PointsMultiplier float64 `db:"points_multiplier"`
}

21
models/item.go

@ -0,0 +1,21 @@
package models
type Item struct {
ID string `db:"item_id"`
Name string `db:"name"`
Description string `db:"description"`
Tags []string `db:"tags"`
ImageURL *string `db:"image_url"`
}
type ItemFilter struct {
ItemIDs []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;
*/

6
models/log.go

@ -0,0 +1,6 @@
package models
type Log struct {
ID string `db:"log_id"`
IssueID string `db:"issue_id"`
}

40
models/project.go

@ -0,0 +1,40 @@
package models
// A Project is a vague category for issues. It's use depend on the user's judgement, as it could represent
// an entire hobby/job or a single project within one.
type Project struct {
ID string `db:"project_id"`
Name string `db:"name"`
Description string `db:"description"`
DailyPoints int `db:"daily_points"`
}
type ProjectFilter struct {
ProjectIDs []string
Search *string
Permission *ProjectFilterPermission
}
type ProjectFilterPermission struct {
UserID string
MinLevel int
MaxLevel int
}
func (project *Project) ValidKey() bool {
if len(project.ID) < 1 || len(project.ID) > 16 {
return false
}
for _, r := range project.ID {
if r < 'A' && r > 'Z' {
return false
}
}
return true
}
func (pfp *ProjectFilterPermission) Valid() bool {
return pfp.UserID != "" && pfp.MinLevel > 0
}

36
models/projectpermission.go

@ -0,0 +1,36 @@
package models
// ProjectPermission is a structure that associates a user with a project.
type ProjectPermission struct {
ProjectID string `db:"project_id"`
UserID string `db:"user_id"`
Level int `db:"access_level"`
}
const (
ProjectPermissionLevelNoAccess int = 0
ProjectPermissionLevelObserver int = 1
ProjectPermissionLevelMember int = 2
ProjectPermissionLevelAdmin int = 3
ProjectPermissionLevelOwner int = 4
)
func (permission *ProjectPermission) CanViewAnyIssue() bool {
return permission.Level >= ProjectPermissionLevelObserver
}
func (permission *ProjectPermission) CanViewOwnIssue() bool {
return permission.Level >= ProjectPermissionLevelObserver
}
func (permission *ProjectPermission) CanManageOwnIssue() bool {
return permission.Level >= ProjectPermissionLevelMember
}
func (permission *ProjectPermission) CanManageAnyIssue() bool {
return permission.Level >= ProjectPermissionLevelAdmin
}
func (permission *ProjectPermission) CanManagePermissions() bool {
return permission.Level >= ProjectPermissionLevelOwner
}

9
models/projectstatus.go

@ -0,0 +1,9 @@
package models
// ProjectStatus is an entry denoting a status a project's issues can have.
type ProjectStatus struct {
ProjectID string `db:"project_id"`
Stage int `db:"status_stage"`
Name string `db:"status_name"`
Description string `db:"description"`
}

9
models/session.go

@ -0,0 +1,9 @@
package models
import "time"
type Session struct {
ID string `db:"session_id"`
UserID string `db:"user_id"`
ExpiryTime time.Time `db:"expiry_time"`
}

39
models/user.go

@ -0,0 +1,39 @@
package models
import (
"errors"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID string `db:"user_id"`
Name string `db:"name"`
Active bool `db:"active"`
Admin bool `db:"admin"`
Hash []byte `db:"hash"`
}
func (user *User) CheckPassword(password string) bool {
return bcrypt.CompareHashAndPassword(user.Hash, []byte(password)) == nil
}
func (user *User) SetPassword(password string) error {
if len(password) < 6 {
return errors.New("password too short (min: 6)")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Hash = hash
return nil
}
type UserFilter struct {
UserIDs []string
Active *bool
Admin *bool
Limit *int
}

133
services/auth.go

@ -0,0 +1,133 @@
package services
import (
"context"
"errors"
"git.aiterp.net/stufflog/server/database/repositories"
"git.aiterp.net/stufflog/server/internal/generate"
"git.aiterp.net/stufflog/server/models"
"github.com/gin-gonic/gin"
"math/rand"
"time"
)
var ErrLoginFailed = errors.New("login failed")
var ErrInternalLoginFailure = errors.New("login failed due to internal error")
var ErrInternalPermissionFailure = errors.New("permission check failed due to internal error")
var ErrPermissionDenied = errors.New("permission denied")
var authCookieName = "stufflog_cookie"
var authCtxKey = "stufflog.auth"
var ginCtxKey = "stufflog.gin"
type Auth struct {
users repositories.UserRepository
session repositories.SessionRepository
projects repositories.ProjectRepository
}
func (auth *Auth) Login(ctx context.Context, username, password string) (*models.User, error) {
user, err := auth.users.Find(ctx, username)
if err != nil {
select {
case <-time.After(time.Millisecond * time.Duration(rand.Int63n(100)+100)):
case <-ctx.Done():
}
return nil, ErrLoginFailed
}
if !user.CheckPassword(password) {
select {
case <-time.After(time.Millisecond * time.Duration(rand.Int63n(50))):
case <-ctx.Done():
}
return nil, ErrLoginFailed
}
session := models.Session{
ID: generate.SessionID(),
UserID: user.ID,
ExpiryTime: time.Now().Add(time.Hour * 168),
}
err = auth.session.Save(c.Request.Context(), *session)
if err != nil {
return nil, ErrInternalLoginFailure
}
if c := ctx.Value(ginCtxKey).(*gin.Context); c != nil {
c.SetCookie(authCookieName, session.ID, 3600*168, "/", "", false, true)
} else {
return nil, ErrInternalLoginFailure
}
return user, nil
}
func (auth *Auth) UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(authCtxKey).(*models.User)
return user
}
func (auth *Auth) ProjectPermission(ctx context.Context, project models.Project) (*models.ProjectPermission, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, ErrPermissionDenied
}
permission, err := auth.projects.GetPermission(ctx, project, *user)
if err != nil {
return nil, ErrInternalPermissionFailure
}
if permission.Level == models.ProjectPermissionLevelNoAccess {
return nil, ErrPermissionDenied
}
return permission, nil
}
func (auth *Auth) IssuePermission(ctx context.Context, issue models.Issue) (*models.ProjectPermission, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, ErrPermissionDenied
}
permission, err := auth.projects.GetIssuePermission(ctx, issue, *user)
if err != nil {
return nil, ErrInternalPermissionFailure
}
if permission.Level == models.ProjectPermissionLevelNoAccess {
return nil, ErrPermissionDenied
}
return permission, nil
}
func (auth *Auth) CheckGinSession(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), ginCtxKey, authCtxKey)
defer func() {
c.Request = c.Request.WithContext(ctx)
}()
cookie, err := c.Cookie(authCookieName)
if err != nil {
return
}
session, err := auth.session.Find(c.Request.Context(), cookie)
if err != nil {
return
}
if time.Until(session.ExpiryTime) < time.Hour*167 {
session.ExpiryTime = time.Now().Add(time.Hour * 168)
_ = auth.session.Save(c.Request.Context(), *session)
c.SetCookie(authCookieName, session.ID, 3600*168, "/", "", false, true)
}
user, err := auth.users.Find(c.Request.Context(), cookie)
if err != nil {
return
}
ctx = context.WithValue(ctx, authCtxKey, user)
}

5
services/bundle.go

@ -0,0 +1,5 @@
package services
type Bundle struct {
Auth *Auth
}
Loading…
Cancel
Save