Gisle Aune
5 years ago
commit
4cce632708
69 changed files with 7852 additions and 0 deletions
-
8.idea/.gitignore
-
5.idea/codeStyles/codeStyleConfig.xml
-
11.idea/dataSources.xml
-
13.idea/dictionaries/gisle.xml
-
6.idea/misc.xml
-
8.idea/modules.xml
-
7.idea/sqldialects.xml
-
29.idea/watcherTasks.xml
-
8.idea/xiaoli.iml
-
29database/database.go
-
103database/drivers/mysqldriver/db.go
-
78database/drivers/mysqldriver/db_test.go
-
169database/drivers/mysqldriver/issues.go
-
188database/drivers/mysqldriver/issues_test.go
-
215database/drivers/mysqldriver/items.go
-
142database/drivers/mysqldriver/items_test.go
-
139database/drivers/mysqldriver/projects.go
-
165database/drivers/mysqldriver/projects_test.go
-
49database/drivers/mysqldriver/session.go
-
99database/drivers/mysqldriver/users.go
-
117database/drivers/mysqldriver/users_test.go
-
14database/repositories/issuerepository.go
-
15database/repositories/itemrepository.go
-
17database/repositories/projectrepository.go
-
13database/repositories/sessionrepository.go
-
14database/repositories/userrepository.go
-
17go.mod
-
143go.sum
-
18graph/gqlgen.yml
-
30graph/graph.go
-
4983graph/graphcore/exec_gen.go
-
38graph/graphcore/input_gen.go
-
29graph/resolvers/issue.resolvers.go
-
25graph/resolvers/mutation.resolvers.go
-
29graph/resolvers/project.resolvers.go
-
37graph/resolvers/query.resolvers.go
-
11graph/resolvers/resolver.go
-
37graph/schema/issue.gql
-
4graph/schema/mutation.gql
-
70graph/schema/project.gql
-
14graph/schema/query.gql
-
1graph/schema/scalars.gql
-
18graph/schema/user.gql
-
48internal/generate/generate.go
-
9internal/generate/ids.go
-
18internal/xlerrors/notfound.go
-
75main.go
-
16migrations/mysql/20200405173553_create_table_project.sql
-
30migrations/mysql/20200406105917_create_table_issue.sql
-
16migrations/mysql/20200406110301_create_table_item.sql
-
15migrations/mysql/20200406110546_create_table_item_tag.sql
-
15migrations/mysql/20200406114528_create_table_user.sql
-
15migrations/mysql/20200406160317_create_table_counters.sql
-
16migrations/mysql/20200409113154_create_table_project_permission.sql
-
15migrations/mysql/20200412174220_create_table_session.sql
-
12models/activity.go
-
21models/goal.go
-
39models/issue.go
-
9models/issueitem.go
-
20models/issuetask.go
-
21models/item.go
-
6models/log.go
-
40models/project.go
-
36models/projectpermission.go
-
9models/projectstatus.go
-
9models/session.go
-
39models/user.go
-
133services/auth.go
-
5services/bundle.go
@ -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/ |
@ -0,0 +1,5 @@ |
|||||
|
<component name="ProjectCodeStyleConfiguration"> |
||||
|
<state> |
||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> |
||||
|
</state> |
||||
|
</component> |
@ -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> |
@ -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> |
@ -0,0 +1,6 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project version="4"> |
||||
|
<component name="JavaScriptSettings"> |
||||
|
<option name="languageLevel" value="ES6" /> |
||||
|
</component> |
||||
|
</project> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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 |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
) |
@ -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= |
@ -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 |
@ -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
File diff suppressed because it is too large
View File
@ -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"` |
||||
|
} |
@ -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 } |
@ -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 } |
@ -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 } |
@ -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 } |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
type Mutation { |
||||
|
loginUser(input: UserLoginInput): User! |
||||
|
logoutUser: User! |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -0,0 +1 @@ |
|||||
|
scalar Time |
@ -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! |
||||
|
} |
@ -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] |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
package generate |
||||
|
|
||||
|
func ItemID() string { |
||||
|
return Generate(32, "I") |
||||
|
} |
||||
|
|
||||
|
func SessionID() string { |
||||
|
return Generate(32, "S") |
||||
|
} |
@ -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 ¬FoundError{Subject: subject} |
||||
|
} |
||||
|
|
||||
|
func IsNotFound(err error) bool { |
||||
|
_, ok := err.(*notFoundError) |
||||
|
return ok |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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"` |
||||
|
} |
@ -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"` |
||||
|
} |
@ -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 |
||||
|
) |
@ -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"` |
||||
|
} |
@ -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"` |
||||
|
} |
@ -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; |
||||
|
*/ |
@ -0,0 +1,6 @@ |
|||||
|
package models |
||||
|
|
||||
|
type Log struct { |
||||
|
ID string `db:"log_id"` |
||||
|
IssueID string `db:"issue_id"` |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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"` |
||||
|
} |
@ -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"` |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -0,0 +1,5 @@ |
|||||
|
package services |
||||
|
|
||||
|
type Bundle struct { |
||||
|
Auth *Auth |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue