├── .dockerignore ├── .env.development ├── .env.sample ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── questions.md ├── traggo_calendar.png ├── traggo_dashboard.png ├── traggo_list.png └── workflows │ └── build.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── auth ├── cleanup.go ├── cleanup_test.go ├── hasrole.go ├── hasrole_test.go ├── middleware.go ├── middleware_test.go └── rand │ ├── random.go │ └── random_test.go ├── config ├── config.go ├── config_test.go ├── error.go ├── loglevel.go ├── loglevel_test.go └── mode │ ├── mode.go │ └── mode_test.go ├── dashboard ├── convert │ ├── dashboard.go │ ├── entry.go │ ├── entrytype.go │ ├── interval.go │ ├── position.go │ └── range.go ├── create.go ├── dashboard_test.go ├── dashboardresolver.go ├── dbrange │ ├── add.go │ ├── dbrangeresolver.go │ ├── ranges_test.go │ ├── remove.go │ └── update.go ├── entry │ ├── add.go │ ├── dbentryresolver.go │ ├── entries_test.go │ ├── remove.go │ └── update.go ├── get.go ├── remove.go ├── update.go └── util │ └── dashboard.go ├── database ├── database.go └── database_test.go ├── device ├── create.go ├── create_test.go ├── current.go ├── current_test.go ├── deviceresolver.go ├── get.go ├── get_test.go ├── remove.go ├── remove_current.go ├── remove_current_test.go ├── remove_test.go ├── update.go ├── update_test.go └── utils_test.go ├── docker-compose.yml ├── docker ├── Dockerfile ├── Dockerfile.build └── Dockerfile.dev ├── go.mod ├── go.sum ├── gqlgen.yml ├── graphql ├── directive.go ├── handler.go ├── handler_test.go ├── resolver.go └── resolver_test.go ├── hack └── datagen │ └── datagen.go ├── logger ├── database.go ├── database_test.go ├── gql.go ├── logger.go └── logger_test.go ├── main.go ├── model ├── all.go ├── all_test.go ├── dashboard.go ├── device.go ├── device_test.go ├── setting.go ├── tagdefinition.go ├── time.go ├── time_test.go ├── timespan.go ├── user.go └── version.go ├── schema.graphql ├── server ├── server.go └── server_test.go ├── setting ├── get.go ├── get_test.go ├── settingresolver.go ├── usersettings.go └── usersettings_test.go ├── statistics ├── statisticsresolver.go ├── summary.go ├── summary2.go └── summary_test.go ├── tag ├── create.go ├── create_test.go ├── get.go ├── get_test.go ├── remove.go ├── remove_test.go ├── suggest.go ├── suggest_test.go ├── tagresolver.go ├── update.go ├── update_test.go └── utils_test.go ├── test ├── db.go ├── db_test.go ├── fake │ ├── device.go │ └── user.go ├── fake_test.go ├── init.go ├── logger.go ├── logger_test.go ├── time.go └── time_test.go ├── time ├── interval.go ├── interval_test.go ├── parse.go ├── parse_test.go └── range.go ├── timespan ├── convert.go ├── copy.go ├── copy_test.go ├── create.go ├── create_test.go ├── get.go ├── get_test.go ├── remove.go ├── remove_test.go ├── replace_tags.go ├── replace_tags_test.go ├── stop.go ├── stop_test.go ├── suggestvalue.go ├── suggestvalue_test.go ├── tagcheck.go ├── timers.go ├── timers_test.go ├── timespanresolver.go ├── update.go ├── update_test.go └── utils_test.go ├── ui ├── .prettierignore ├── .prettierrc ├── package.json ├── public │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-192x192.png │ ├── favicon-256x256.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── serve.go ├── src │ ├── Root.tsx │ ├── Router.tsx │ ├── common │ │ ├── Center.tsx │ │ ├── CenteredSpinner.tsx │ │ ├── ConfirmDialog.tsx │ │ ├── DateTimeSelector.tsx │ │ ├── DefaultPaper.tsx │ │ ├── Fade.tsx │ │ ├── Page.tsx │ │ ├── RelativeDateTimeSelector.tsx │ │ ├── RelativeTime.tsx │ │ ├── TagChip.tsx │ │ └── tsutil.ts │ ├── dashboard │ │ ├── AddDashboardDialog.tsx │ │ ├── DashboardPage.tsx │ │ ├── DashboardsPage.tsx │ │ ├── DateRanges.tsx │ │ └── Entry │ │ │ ├── AddPopup.tsx │ │ │ ├── DashboardBarChart.tsx │ │ │ ├── DashboardEntry.tsx │ │ │ ├── DashboardEntryForm.tsx │ │ │ ├── DashboardLineChart.tsx │ │ │ ├── DashboardPieChart.tsx │ │ │ ├── DashboardTable.tsx │ │ │ ├── EditGlass.tsx │ │ │ ├── EditPopup.tsx │ │ │ ├── TagTooltip.tsx │ │ │ ├── colors.ts │ │ │ ├── dateformat.ts │ │ │ └── unit.ts │ ├── devices │ │ ├── AddDeviceDialog.tsx │ │ ├── DevicesPage.tsx │ │ └── typeutils.ts │ ├── global.css │ ├── gql │ │ ├── dashboard.ts │ │ ├── device.ts │ │ ├── settings.ts │ │ ├── statistics.ts │ │ ├── tags.ts │ │ ├── timeSpan.ts │ │ ├── user.ts │ │ ├── utils.ts │ │ └── version.ts │ ├── index.tsx │ ├── login │ │ ├── LoginForm.tsx │ │ └── LoginPage.tsx │ ├── provider │ │ ├── ApolloProvider.tsx │ │ ├── SnackbarProvider.tsx │ │ ├── ThemeProvider.tsx │ │ └── UserSettingsProvider.tsx │ ├── react-app-env.d.ts │ ├── setting │ │ └── SettingsPage.tsx │ ├── tag │ │ ├── AddTagDialog.tsx │ │ ├── TagKeySelector.tsx │ │ ├── TagPage.tsx │ │ ├── TagSelector.tsx │ │ ├── suggest.ts │ │ └── tagSelectorEntry.ts │ ├── timespan │ │ ├── ActiveTrackers.tsx │ │ ├── DailyPage.tsx │ │ ├── DoneTrackers.tsx │ │ ├── RefreshTimespans.tsx │ │ ├── TimeSpan.tsx │ │ ├── Tracker.tsx │ │ ├── calendar │ │ │ ├── CalendarPage.tsx │ │ │ └── FullCalendarStyling.tsx │ │ ├── colorutils.ts │ │ ├── timespanutils.ts │ │ └── timeutils.ts │ ├── user │ │ ├── AddUserDialog.tsx │ │ └── UsersPage.tsx │ └── utils │ │ ├── errors.tsx │ │ ├── hooks.ts │ │ ├── never.ts │ │ ├── range.ts │ │ ├── strip.ts │ │ ├── time.test.ts │ │ └── time.ts ├── tsconfig.json ├── tslint.json └── yarn.lock └── user ├── create.go ├── create_test.go ├── current.go ├── current_test.go ├── get.go ├── get_test.go ├── password ├── password.go └── password_test.go ├── remove.go ├── remove_test.go ├── update.go ├── update_test.go ├── userresolver.go └── utils_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /docker/Dockerfile.dev 2 | /ui/node_modules/ 3 | .idea/ 4 | build/ 5 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | TRAGGO_LOG_LEVEL=debug 2 | TRAGGO_DATABASE_DIALECT=sqlite3 3 | TRAGGO_DATABASE_CONNECTION=file::memory:?mode=memory&cache=shared -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # the port the http server should use 2 | # TRAGGO_PORT=3030 3 | 4 | # default username and password 5 | # TRAGGO_DEFAULT_USER_NAME=admin 6 | # TRAGGO_DEFAULT_USER_PASS=admin 7 | 8 | # bcrypt password strength (higher = more secure but also slower) 9 | # TRAGGO_PASS_STRENGTH=10 10 | 11 | # how verbose traggo/server should log (must be one of: debug, info, warn, error, fatal, panic) 12 | # TRAGGO_LOG_LEVEL=info 13 | 14 | # the database dialect (must be one of: sqlite3) 15 | # TRAGGO_DATABASE_DIALECT=sqlite3 16 | 17 | # the database connection string, differs depending on the dialect 18 | # sqlite3: path/to/database.db 19 | # TRAGGO_DATABASE_CONNECTION=data/traggo.db 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jmattheis 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://jmattheis.de/donate 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: a:bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. (Include configs for traggo and reverse proxies like nginx) 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: a:feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Questions 3 | about: Having difficulties with traggo? 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Have you read the documentation?** 15 | - [ ] Yes, but it does not include related information regarding my question. 16 | - [ ] Yes, but the steps described in the documentation do not work on my machine. 17 | - [ ] Yes, but I am having difficulty understanding it and want clarification. 18 | 19 | **You are setting up traggo in** 20 | - [ ] Docker 21 | - [ ] Linux native platform 22 | - [ ] Windows native platform 23 | 24 | **Describe your problem** 25 | A clear and concise description of what the question is. 26 | 27 | **Any errors, logs, or other information that might help us identify your problem** 28 | 29 | Ex: `docker-compose.yml`, `nginx.conf`, browser requests, etc. 30 | -------------------------------------------------------------------------------- /.github/traggo_calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/.github/traggo_calendar.png -------------------------------------------------------------------------------- /.github/traggo_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/.github/traggo_dashboard.png -------------------------------------------------------------------------------- /.github/traggo_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/.github/traggo_list.png -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/setup-go@v5 9 | with: 10 | go-version: 1.23.x 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: '20' 14 | - uses: actions/checkout@v4 15 | - run: make download-tools 16 | - run: make install 17 | - run: make generate 18 | - run: make build-js 19 | env: 20 | NODE_OPTIONS: --openssl-legacy-provider 21 | - run: make lint 22 | - run: make test 23 | - if: startsWith(github.ref, 'refs/tags/v') 24 | run: echo "$DOCKER_PASS" | docker login --username "traggoci" --password-stdin 25 | env: 26 | DOCKER_PASS: ${{ secrets.DOCKER_PASS }} 27 | - if: startsWith(github.ref, 'refs/tags/v') 28 | run: make release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist/ 3 | build 4 | vendor 5 | coverage.txt 6 | generated 7 | .env.development.local 8 | .env 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | node_modules 13 | __generated__ 14 | *-packr.go 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAGS=netgo osusergo sqlite_omit_load_extension 2 | VERSION=$(shell git describe --tags --abbrev=0 | cut -c 2-) 3 | COMMIT=$(shell git rev-parse --verify HEAD) 4 | DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 5 | LD_FLAGS=-s -w -linkmode external -extldflags "-static" -X main.BuildDate=$(DATE) -X main.BuildMode=prod -X main.BuildCommit=$(COMMIT) -X main.BuildVersion=$(VERSION) 6 | BUILD_DIR=./build 7 | PWD=$(shell pwd) 8 | GOLANG_CROSS_VERSION=v1.22.0 9 | 10 | license-dir: 11 | mkdir -p build/license || true 12 | 13 | download-tools: 14 | go install golang.org/x/tools/cmd/goimports@v0.1.10 15 | go install github.com/99designs/gqlgen@v0.17.44 16 | 17 | generate-go: 18 | gqlgen 19 | 20 | generate-js: 21 | (cd ui && yarn generate) 22 | 23 | generate: generate-go generate-js 24 | 25 | lint-go: 26 | go vet ./... 27 | goimports -l $(shell find . -type f -name '*.go' -not -path "./vendor/*") 28 | 29 | lint-js: 30 | (cd ui && yarn format:check) 31 | (cd ui && yarn lint:check) 32 | 33 | lint: lint-go lint-js 34 | 35 | format-go: 36 | goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*") 37 | 38 | format-js: 39 | (cd ui && yarn format) 40 | 41 | format: format-go format-js 42 | 43 | test-go: 44 | go test --race -coverprofile=coverage.txt -covermode=atomic ./... 45 | 46 | test-js: 47 | (cd ui && CI=true yarn test) 48 | 49 | test: test-go test-js 50 | 51 | install-go: 52 | go mod download 53 | 54 | install-js: 55 | (cd ui && yarn) 56 | 57 | build-js: 58 | (cd ui && yarn build) 59 | 60 | pre-build: build-js 61 | 62 | build-bin-local: pre-build 63 | CGO_ENABLED=1 go build -a -ldflags '${LD_FLAGS}' -tags '${TAGS}' -o ${BUILD_DIR}/traggo-server 64 | 65 | .PHONY: release 66 | release: 67 | docker build -t traggo:build -f docker/Dockerfile.build docker 68 | docker run \ 69 | --rm \ 70 | -v "$$HOME/.docker/config.json:/root/.docker/config.json" \ 71 | -e CGO_ENABLED=1 \ 72 | -e GITHUB_TOKEN="$$GITHUB_TOKEN" \ 73 | -v /var/run/docker.sock:/var/run/docker.sock \ 74 | -v $$PWD:/work \ 75 | -w /work \ 76 | traggo:build \ 77 | release --skip-validate --clean 78 | 79 | .PHONY: release-snapshot 80 | release-snapshot: 81 | docker build -t traggo:build -f docker/Dockerfile.build docker 82 | docker run \ 83 | --rm \ 84 | -v "$$HOME/.docker/config.json:/root/.docker/config.json" \ 85 | -e CGO_ENABLED=1 \ 86 | -e GITHUB_TOKEN="$$GITHUB_TOKEN" \ 87 | -v /var/run/docker.sock:/var/run/docker.sock \ 88 | -v $$PWD:/work \ 89 | -w /work \ 90 | traggo:build \ 91 | release --clean --snapshot 92 | 93 | install: install-go install-js 94 | 95 | .PHONY: build 96 | -------------------------------------------------------------------------------- /auth/cleanup.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/jmattheis/go-timemath" 8 | "github.com/rs/zerolog/log" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | var timeNow = time.Now 13 | 14 | // CleanUp clean up expired devices 15 | func CleanUp(db *gorm.DB, interval time.Duration, close chan bool) { 16 | for { 17 | select { 18 | case <-time.After(interval): 19 | affected := db.Where("type = ? AND active_at < ?", model.TypeLongExpiry, timemath.Second.Subtract(timeNow(), model.TypeLongExpiry.Seconds())). 20 | Or("type = ? AND active_at < ?", model.TypeShortExpiry, timemath.Second.Subtract(timeNow(), model.TypeShortExpiry.Seconds())). 21 | Delete(new(model.Device)).RowsAffected 22 | if affected > 0 { 23 | log.Debug().Int64("amount", affected).Msg("removed devices") 24 | } 25 | case <-close: 26 | return 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /auth/hasrole.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/99designs/gqlgen/graphql" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | ) 10 | 11 | // HasRole checks if the current user has sufficient permissions. 12 | func HasRole() func(ctx context.Context, obj interface{}, next graphql.Resolver, role gqlmodel.Role) (res interface{}, err error) { 13 | return func(ctx context.Context, obj interface{}, next graphql.Resolver, role gqlmodel.Role) (interface{}, error) { 14 | user := GetUser(ctx) 15 | 16 | if user == nil { 17 | return nil, errors.New("you need to login") 18 | } 19 | 20 | if role == gqlmodel.RoleAdmin && !user.Admin { 21 | return nil, errors.New("permission denied") 22 | } 23 | 24 | return next(ctx) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /auth/hasrole_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | var ( 14 | successResp = struct{}{} 15 | ) 16 | 17 | func TestHasRole_requiredUser_givenAdmin_succeeds(t *testing.T) { 18 | res, err := auth.HasRole()(fake.UserWithPerm(1, true), nil, noop, gqlmodel.RoleUser) 19 | assert.Nil(t, err) 20 | assert.Equal(t, successResp, res) 21 | } 22 | 23 | func TestHasRole_requiredAdmin_givenAdmin_succeeds(t *testing.T) { 24 | res, err := auth.HasRole()(fake.UserWithPerm(1, true), nil, noop, gqlmodel.RoleAdmin) 25 | assert.Nil(t, err) 26 | assert.Equal(t, successResp, res) 27 | } 28 | 29 | func TestHasRole_requiredUser_givenUser_succeeds(t *testing.T) { 30 | res, err := auth.HasRole()(fake.UserWithPerm(1, false), nil, noop, gqlmodel.RoleUser) 31 | assert.Nil(t, err) 32 | assert.Equal(t, successResp, res) 33 | } 34 | 35 | func TestHasRole_requiredAdmin_givenUser_fails(t *testing.T) { 36 | res, err := auth.HasRole()(fake.UserWithPerm(1, false), nil, noop, gqlmodel.RoleAdmin) 37 | assert.Nil(t, res) 38 | assert.EqualError(t, err, "permission denied") 39 | } 40 | 41 | func TestHasRole_requiredAdmin_noUserGiven_fails(t *testing.T) { 42 | res, err := auth.HasRole()(context.Background(), nil, noop, gqlmodel.RoleAdmin) 43 | assert.Nil(t, res) 44 | assert.EqualError(t, err, "you need to login") 45 | } 46 | 47 | func TestHasRole_requiredUser_noUserGiven_fails(t *testing.T) { 48 | res, err := auth.HasRole()(context.Background(), nil, noop, gqlmodel.RoleUser) 49 | assert.Nil(t, res) 50 | assert.EqualError(t, err, "you need to login") 51 | } 52 | 53 | func noop(context.Context) (res interface{}, err error) { 54 | return successResp, nil 55 | } 56 | -------------------------------------------------------------------------------- /auth/rand/random.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "crypto/rand" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | var ( 10 | chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 11 | 12 | randRead = rand.Read 13 | ) 14 | 15 | func init() { 16 | // exits with 1 when crypto/rand is not available 17 | Token(1) 18 | } 19 | 20 | func randBytes(count int) ([]byte, error) { 21 | bytes := make([]byte, count) 22 | _, err := randRead(bytes) 23 | return bytes, err 24 | } 25 | 26 | // Token returns a random token. 27 | func Token(count int) string { 28 | bytes, err := randBytes(count) 29 | if err != nil { 30 | log.Panic().Msg("crypto/rand is not available") 31 | } 32 | return bytesToString(bytes, count) 33 | } 34 | 35 | func bytesToString(bytes []byte, count int) string { 36 | token := make([]rune, count) 37 | 38 | for index, item := range bytes { 39 | token[index] = chars[int(item)%len(chars)] 40 | } 41 | 42 | return string(token) 43 | } 44 | -------------------------------------------------------------------------------- /auth/rand/random_test.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestToken(t *testing.T) { 11 | for i := 1; i < 10; i++ { 12 | assert.Len(t, Token(i), i) 13 | } 14 | assert.Len(t, Token(120), 120) 15 | assert.Len(t, Token(222), 222) 16 | assert.Len(t, Token(555665), 555665) 17 | } 18 | 19 | func TestToken_panics(t *testing.T) { 20 | old := randRead 21 | defer func() { 22 | randRead = old 23 | }() 24 | randRead = func(b []byte) (n int, err error) { 25 | return 0, errors.New("oops") 26 | } 27 | assert.Panics(t, func() { 28 | Token(1) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /config/error.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/rs/zerolog" 4 | 5 | // FutureLog is an intermediate type for log messages. It is used before the config was loaded because without loaded 6 | // config we do not know the log level, so we log these messages once the config was initialized. 7 | type FutureLog struct { 8 | Level zerolog.Level 9 | Msg string 10 | } 11 | -------------------------------------------------------------------------------- /config/loglevel.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/rs/zerolog" 7 | ) 8 | 9 | // LogLevel type that provides helper methods for decoding. 10 | type LogLevel zerolog.Level 11 | 12 | // Decode decodes a string to a log level. 13 | func (ll *LogLevel) Decode(value string) error { 14 | if level, err := zerolog.ParseLevel(value); err == nil { 15 | *ll = LogLevel(level) 16 | return nil 17 | } 18 | *ll = LogLevel(zerolog.InfoLevel) 19 | return errors.New("unknown log level") 20 | } 21 | 22 | // AsZeroLogLevel converts the LogLevel to a zerolog.Level. 23 | func (ll LogLevel) AsZeroLogLevel() zerolog.Level { 24 | return zerolog.Level(ll) 25 | } 26 | -------------------------------------------------------------------------------- /config/loglevel_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestLogLevel_Decode_success(t *testing.T) { 11 | ll := new(LogLevel) 12 | err := ll.Decode("fatal") 13 | assert.Nil(t, err) 14 | assert.Equal(t, ll.AsZeroLogLevel(), zerolog.FatalLevel) 15 | } 16 | 17 | func TestLogLevel_Decode_fail(t *testing.T) { 18 | ll := new(LogLevel) 19 | err := ll.Decode("asdasdasdasdasdasd") 20 | assert.EqualError(t, err, "unknown log level") 21 | assert.Equal(t, ll.AsZeroLogLevel(), zerolog.InfoLevel) 22 | } 23 | -------------------------------------------------------------------------------- /config/mode/mode.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | const ( 4 | // Dev for development mode 5 | Dev = "dev" 6 | // Prod for production mode 7 | Prod = "prod" 8 | ) 9 | 10 | var mode = Dev 11 | 12 | // Set sets the new mode. 13 | func Set(newMode string) { 14 | mode = newMode 15 | } 16 | 17 | // Get returns the current mode. 18 | func Get() string { 19 | return mode 20 | } 21 | -------------------------------------------------------------------------------- /config/mode/mode_test.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/magiconair/properties/assert" 7 | ) 8 | 9 | func TestGet(t *testing.T) { 10 | mode = Prod 11 | assert.Equal(t, Prod, Get()) 12 | } 13 | 14 | func TestSet(t *testing.T) { 15 | Set(Prod) 16 | assert.Equal(t, Prod, mode) 17 | } 18 | -------------------------------------------------------------------------------- /dashboard/convert/dashboard.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "github.com/traggo/server/generated/gqlmodel" 5 | "github.com/traggo/server/model" 6 | ) 7 | 8 | // ToExternalDashboards converts dashboards. 9 | func ToExternalDashboards(dashboards []model.Dashboard) ([]*gqlmodel.Dashboard, error) { 10 | result := []*gqlmodel.Dashboard{} 11 | for _, dashboard := range dashboards { 12 | if converted, err := ToExternalDashboard(dashboard); err == nil { 13 | result = append(result, converted) 14 | } else { 15 | return nil, err 16 | } 17 | } 18 | return result, nil 19 | } 20 | 21 | // ToExternalDashboard converts a dashboard. 22 | func ToExternalDashboard(dashboard model.Dashboard) (*gqlmodel.Dashboard, error) { 23 | entries, err := toExternalEntries(dashboard.Entries) 24 | if err != nil { 25 | return nil, err 26 | } 27 | ranges, err := toExternalDashboardsRanges(dashboard.Ranges) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &gqlmodel.Dashboard{ 32 | ID: dashboard.ID, 33 | Name: dashboard.Name, 34 | Items: entries, 35 | Ranges: ranges, 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /dashboard/convert/entry.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/traggo/server/generated/gqlmodel" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | func toExternalEntries(entries []model.DashboardEntry) ([]*gqlmodel.DashboardEntry, error) { 11 | result := []*gqlmodel.DashboardEntry{} 12 | for _, entry := range entries { 13 | converted, err := ToExternalEntry(entry) 14 | if err != nil { 15 | return nil, err 16 | } 17 | result = append(result, converted) 18 | } 19 | return result, nil 20 | } 21 | 22 | // ToExternalEntry converts entries. 23 | func ToExternalEntry(entry model.DashboardEntry) (*gqlmodel.DashboardEntry, error) { 24 | mobilePosition, err := toExternalPosition(entry.MobilePosition) 25 | if err != nil { 26 | return &gqlmodel.DashboardEntry{}, err 27 | } 28 | desktopPos, err := toExternalPosition(entry.DesktopPosition) 29 | if err != nil { 30 | return &gqlmodel.DashboardEntry{}, err 31 | } 32 | pos := gqlmodel.ResponsiveDashboardEntryPos{ 33 | Mobile: enhancePos(mobilePosition, "mobile"), 34 | Desktop: enhancePos(desktopPos, "desktop"), 35 | } 36 | dateRange := &gqlmodel.RelativeOrStaticRange{ 37 | From: entry.RangeFrom, 38 | To: entry.RangeTo, 39 | } 40 | stats := &gqlmodel.StatsSelection{ 41 | Interval: ExternalInterval(entry.Interval), 42 | Tags: strings.Split(entry.Keys, ","), 43 | Range: dateRange, 44 | } 45 | if entry.RangeID != model.NoRangeIDDefined { 46 | stats.RangeID = &entry.RangeID 47 | stats.Range = nil 48 | } 49 | return &gqlmodel.DashboardEntry{ 50 | ID: entry.ID, 51 | Title: entry.Title, 52 | Total: entry.Total, 53 | Pos: &pos, 54 | StatsSelection: stats, 55 | EntryType: ExternalEntryType(entry.Type), 56 | }, nil 57 | } 58 | -------------------------------------------------------------------------------- /dashboard/convert/entrytype.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "github.com/traggo/server/generated/gqlmodel" 5 | "github.com/traggo/server/model" 6 | ) 7 | 8 | // InternalEntryType converts entry type. 9 | func InternalEntryType(entryType gqlmodel.EntryType) model.DashboardType { 10 | switch entryType { 11 | case gqlmodel.EntryTypeBarChart: 12 | return model.TypeBarChart 13 | case gqlmodel.EntryTypePieChart: 14 | return model.TypePieChart 15 | case gqlmodel.EntryTypeStackedBarChart: 16 | return model.TypeStackedBarChart 17 | case gqlmodel.EntryTypeLineChart: 18 | return model.TypeLineChart 19 | case gqlmodel.EntryTypeHorizontalTable: 20 | return model.HorizontalTable 21 | case gqlmodel.EntryTypeVerticalTable: 22 | return model.VerticalTable 23 | default: 24 | panic("unknown entry type " + entryType) 25 | } 26 | } 27 | 28 | // ExternalEntryType converts entry type. 29 | func ExternalEntryType(entryType model.DashboardType) gqlmodel.EntryType { 30 | switch entryType { 31 | case model.TypeBarChart: 32 | return gqlmodel.EntryTypeBarChart 33 | case model.TypePieChart: 34 | return gqlmodel.EntryTypePieChart 35 | case model.TypeStackedBarChart: 36 | return gqlmodel.EntryTypeStackedBarChart 37 | case model.TypeLineChart: 38 | return gqlmodel.EntryTypeLineChart 39 | case model.HorizontalTable: 40 | return gqlmodel.EntryTypeHorizontalTable 41 | case model.VerticalTable: 42 | return gqlmodel.EntryTypeVerticalTable 43 | default: 44 | panic("unknown entry type " + entryType) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/convert/interval.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "github.com/traggo/server/generated/gqlmodel" 5 | "github.com/traggo/server/model" 6 | ) 7 | 8 | // InternalInterval converts interval. 9 | func InternalInterval(interval gqlmodel.StatsInterval) model.Interval { 10 | switch interval { 11 | case gqlmodel.StatsIntervalHourly: 12 | return model.IntervalHourly 13 | case gqlmodel.StatsIntervalDaily: 14 | return model.IntervalDaily 15 | case gqlmodel.StatsIntervalWeekly: 16 | return model.IntervalWeekly 17 | case gqlmodel.StatsIntervalMonthly: 18 | return model.IntervalMonthly 19 | case gqlmodel.StatsIntervalYearly: 20 | return model.IntervalYearly 21 | case gqlmodel.StatsIntervalSingle: 22 | return model.IntervalSingle 23 | default: 24 | panic("unknown interval type " + interval) 25 | } 26 | } 27 | 28 | // ExternalInterval converts interval. 29 | func ExternalInterval(interval model.Interval) gqlmodel.StatsInterval { 30 | switch interval { 31 | case model.IntervalHourly: 32 | return gqlmodel.StatsIntervalHourly 33 | case model.IntervalDaily: 34 | return gqlmodel.StatsIntervalDaily 35 | case model.IntervalWeekly: 36 | return gqlmodel.StatsIntervalWeekly 37 | case model.IntervalMonthly: 38 | return gqlmodel.StatsIntervalMonthly 39 | case model.IntervalYearly: 40 | return gqlmodel.StatsIntervalYearly 41 | case model.IntervalSingle: 42 | return gqlmodel.StatsIntervalSingle 43 | default: 44 | panic("unknown interval type " + interval) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/convert/range.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/traggo/server/generated/gqlmodel" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/time" 9 | ) 10 | 11 | func toExternalDashboardsRanges(ranges []model.DashboardRange) ([]*gqlmodel.NamedDateRange, error) { 12 | result := []*gqlmodel.NamedDateRange{} 13 | for _, xrange := range ranges { 14 | result = append(result, ToExternalDashboardRange(xrange)) 15 | } 16 | return result, nil 17 | } 18 | 19 | // ToExternalDashboardRange converts a range. 20 | func ToExternalDashboardRange(xrange model.DashboardRange) *gqlmodel.NamedDateRange { 21 | return &gqlmodel.NamedDateRange{ 22 | ID: xrange.ID, 23 | Name: xrange.Name, 24 | Editable: xrange.Editable, 25 | Range: &gqlmodel.RelativeOrStaticRange{ 26 | From: xrange.From, 27 | To: xrange.To, 28 | }, 29 | } 30 | } 31 | 32 | // ToInternalDashboardRange converts a range. 33 | func ToInternalDashboardRange(xrange gqlmodel.InputNamedDateRange) (model.DashboardRange, error) { 34 | if err := time.Validate(xrange.Range.From); err != nil { 35 | return model.DashboardRange{}, fmt.Errorf("range from (%s) invalid: %s", xrange.Range.From, err) 36 | } 37 | if err := time.Validate(xrange.Range.To); err != nil { 38 | return model.DashboardRange{}, fmt.Errorf("range to (%s) invalid: %s", xrange.Range.To, err) 39 | } 40 | return model.DashboardRange{ 41 | Editable: xrange.Editable, 42 | From: xrange.Range.From, 43 | To: xrange.Range.To, 44 | Name: xrange.Name, 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/create.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard/convert" 7 | 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // CreateDashboard creates a dashboard. 14 | func (r *ResolverForDashboard) CreateDashboard(ctx context.Context, name string) (*gqlmodel.Dashboard, error) { 15 | userID := auth.GetUser(ctx).ID 16 | dashboard := model.Dashboard{ 17 | UserID: userID, 18 | Name: name, 19 | } 20 | 21 | create := r.DB.Create(&dashboard) 22 | if create.Error != nil { 23 | return &gqlmodel.Dashboard{}, create.Error 24 | } 25 | 26 | return convert.ToExternalDashboard(dashboard) 27 | } 28 | -------------------------------------------------------------------------------- /dashboard/dashboardresolver.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/traggo/server/dashboard/dbrange" 6 | "github.com/traggo/server/dashboard/entry" 7 | ) 8 | 9 | // ResolverForDashboard resolves dashboard specific things. 10 | type ResolverForDashboard struct { 11 | DB *gorm.DB 12 | entry.ResolverForEntry 13 | dbrange.ResolverForRange 14 | } 15 | 16 | // NewResolverForDashboard creates a new resolver. 17 | func NewResolverForDashboard(db *gorm.DB) ResolverForDashboard { 18 | return ResolverForDashboard{ 19 | DB: db, 20 | ResolverForEntry: entry.ResolverForEntry{ 21 | DB: db, 22 | }, 23 | ResolverForRange: dbrange.ResolverForRange{ 24 | DB: db, 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dashboard/dbrange/add.go: -------------------------------------------------------------------------------- 1 | package dbrange 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/dashboard/convert" 8 | "github.com/traggo/server/dashboard/util" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | ) 11 | 12 | // AddDashboardRange adds a dashboard range. 13 | func (r *ResolverForRange) AddDashboardRange(ctx context.Context, dashboardID int, rangeArg gqlmodel.InputNamedDateRange) (*gqlmodel.NamedDateRange, error) { 14 | userID := auth.GetUser(ctx).ID 15 | if _, err := util.FindDashboard(r.DB, userID, dashboardID); err != nil { 16 | return nil, err 17 | } 18 | 19 | rangeToAdd, err := convert.ToInternalDashboardRange(rangeArg) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | rangeToAdd.DashboardID = dashboardID 25 | 26 | save := r.DB.Create(&rangeToAdd) 27 | return convert.ToExternalDashboardRange(rangeToAdd), save.Error 28 | } 29 | -------------------------------------------------------------------------------- /dashboard/dbrange/dbrangeresolver.go: -------------------------------------------------------------------------------- 1 | package dbrange 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForRange resolves range specific things. 6 | type ResolverForRange struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /dashboard/dbrange/remove.go: -------------------------------------------------------------------------------- 1 | package dbrange 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/dashboard/convert" 10 | "github.com/traggo/server/dashboard/util" 11 | "github.com/traggo/server/generated/gqlmodel" 12 | "github.com/traggo/server/model" 13 | ) 14 | 15 | // RemoveDashboardRange removes a dashboard range. 16 | func (r *ResolverForRange) RemoveDashboardRange(ctx context.Context, rangeID int) (*gqlmodel.NamedDateRange, error) { 17 | userID := auth.GetUser(ctx).ID 18 | dashboardRange, err := util.FindDashboardRange(r.DB, rangeID) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | if _, err := util.FindDashboard(r.DB, userID, dashboardRange.DashboardID); err != nil { 24 | return nil, err 25 | } 26 | 27 | entries := []model.DashboardEntry{} 28 | if err := r.DB.Where(model.DashboardEntry{RangeID: rangeID}).Find(&entries).Error; err != nil { 29 | return nil, err 30 | } 31 | 32 | names := []string{} 33 | for _, entry := range entries { 34 | names = append(names, entry.Title) 35 | } 36 | 37 | if len(entries) != 0 { 38 | return nil, errors.New("range is used in entries: " + strings.Join(names, ",")) 39 | } 40 | 41 | remove := r.DB.Delete(&model.DashboardRange{}, rangeID) 42 | return convert.ToExternalDashboardRange(dashboardRange), remove.Error 43 | } 44 | -------------------------------------------------------------------------------- /dashboard/dbrange/update.go: -------------------------------------------------------------------------------- 1 | package dbrange 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/dashboard/convert" 8 | "github.com/traggo/server/dashboard/util" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | ) 11 | 12 | // UpdateDashboardRange updates a dashboard range. 13 | func (r *ResolverForRange) UpdateDashboardRange(ctx context.Context, rangeID int, rangeArg gqlmodel.InputNamedDateRange) (*gqlmodel.NamedDateRange, error) { 14 | userID := auth.GetUser(ctx).ID 15 | 16 | dashboardRange, err := util.FindDashboardRange(r.DB, rangeID) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if _, err := util.FindDashboard(r.DB, userID, dashboardRange.DashboardID); err != nil { 22 | return nil, err 23 | } 24 | 25 | rangeToUpdate, err := convert.ToInternalDashboardRange(rangeArg) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | rangeToUpdate.DashboardID = dashboardRange.DashboardID 31 | rangeToUpdate.ID = dashboardRange.ID 32 | 33 | save := r.DB.Save(rangeToUpdate) 34 | return convert.ToExternalDashboardRange(rangeToUpdate), save.Error 35 | } 36 | -------------------------------------------------------------------------------- /dashboard/entry/add.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/traggo/server/time" 10 | 11 | "github.com/traggo/server/dashboard/convert" 12 | "github.com/traggo/server/dashboard/util" 13 | 14 | "github.com/traggo/server/auth" 15 | "github.com/traggo/server/generated/gqlmodel" 16 | "github.com/traggo/server/model" 17 | ) 18 | 19 | // AddDashboardEntry adds a dashboard entry. 20 | func (r *ResolverForEntry) AddDashboardEntry(ctx context.Context, dashboardID int, entryType gqlmodel.EntryType, title string, total bool, stats gqlmodel.InputStatsSelection, pos *gqlmodel.InputResponsiveDashboardEntryPos) (*gqlmodel.DashboardEntry, error) { 21 | userID := auth.GetUser(ctx).ID 22 | 23 | if _, err := util.FindDashboard(r.DB, userID, dashboardID); err != nil { 24 | return nil, err 25 | } 26 | 27 | entry := model.DashboardEntry{ 28 | Keys: strings.Join(stats.Tags, ","), 29 | Type: convert.InternalEntryType(entryType), 30 | Title: title, 31 | Total: total, 32 | DashboardID: dashboardID, 33 | Interval: convert.InternalInterval(stats.Interval), 34 | MobilePosition: convert.EmptyPos(), 35 | DesktopPosition: convert.EmptyPos(), 36 | RangeID: -1, 37 | } 38 | 39 | if len(stats.Tags) == 0 { 40 | return nil, errors.New("at least one tag is required") 41 | } 42 | 43 | if err := convert.ApplyPos(&entry, pos); err != nil { 44 | return &gqlmodel.DashboardEntry{}, err 45 | } 46 | 47 | if stats.RangeID != nil { 48 | if _, err := util.FindDashboardRange(r.DB, *stats.RangeID); err != nil { 49 | return nil, err 50 | } 51 | entry.RangeID = *stats.RangeID 52 | } else if stats.Range != nil { 53 | entry.RangeID = model.NoRangeIDDefined 54 | if err := time.Validate(stats.Range.From); err != nil { 55 | return nil, fmt.Errorf("range from (%s) invalid: %s", stats.Range.From, err) 56 | } 57 | if err := time.Validate(stats.Range.To); err != nil { 58 | return nil, fmt.Errorf("range to (%s) invalid: %s", stats.Range.To, err) 59 | } 60 | entry.RangeFrom = stats.Range.From 61 | entry.RangeTo = stats.Range.To 62 | } 63 | 64 | if err := r.DB.Save(&entry).Error; err != nil { 65 | return nil, err 66 | } 67 | 68 | return convert.ToExternalEntry(entry) 69 | } 70 | -------------------------------------------------------------------------------- /dashboard/entry/dbentryresolver.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForEntry resolves dashboard entry things. 6 | type ResolverForEntry struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /dashboard/entry/remove.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard/convert" 7 | "github.com/traggo/server/dashboard/util" 8 | 9 | "github.com/traggo/server/auth" 10 | "github.com/traggo/server/generated/gqlmodel" 11 | "github.com/traggo/server/model" 12 | ) 13 | 14 | // RemoveDashboardEntry removes a dashboard entry. 15 | func (r *ResolverForEntry) RemoveDashboardEntry(ctx context.Context, id int) (*gqlmodel.DashboardEntry, error) { 16 | 17 | userID := auth.GetUser(ctx).ID 18 | 19 | entry, err := util.FindDashboardEntry(r.DB, id) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | if _, err := util.FindDashboard(r.DB, userID, entry.DashboardID); err != nil { 25 | return nil, err 26 | } 27 | 28 | remove := r.DB.Delete(&model.DashboardEntry{}, id) 29 | if remove.Error != nil { 30 | return &gqlmodel.DashboardEntry{}, remove.Error 31 | } 32 | 33 | return convert.ToExternalEntry(entry) 34 | } 35 | -------------------------------------------------------------------------------- /dashboard/entry/update.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/traggo/server/time" 9 | 10 | "github.com/traggo/server/model" 11 | 12 | "github.com/traggo/server/auth" 13 | "github.com/traggo/server/dashboard/convert" 14 | "github.com/traggo/server/dashboard/util" 15 | "github.com/traggo/server/generated/gqlmodel" 16 | ) 17 | 18 | // UpdateDashboardEntry updates a dashboard entry. 19 | func (r *ResolverForEntry) UpdateDashboardEntry(ctx context.Context, id int, entryType *gqlmodel.EntryType, title *string, total *bool, stats *gqlmodel.InputStatsSelection, pos *gqlmodel.InputResponsiveDashboardEntryPos) (*gqlmodel.DashboardEntry, error) { 20 | userID := auth.GetUser(ctx).ID 21 | 22 | entry, err := util.FindDashboardEntry(r.DB, id) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | if _, err := util.FindDashboard(r.DB, userID, entry.DashboardID); err != nil { 28 | return nil, err 29 | } 30 | 31 | if title != nil { 32 | entry.Title = *title 33 | } 34 | 35 | if total != nil { 36 | entry.Total = *total 37 | } 38 | 39 | if stats != nil { 40 | if stats.RangeID != nil { 41 | if _, err := util.FindDashboardRange(r.DB, *stats.RangeID); err != nil { 42 | return nil, err 43 | } 44 | entry.RangeID = *stats.RangeID 45 | } else if stats.Range != nil { 46 | entry.RangeID = model.NoRangeIDDefined 47 | if err := time.Validate(stats.Range.From); err != nil { 48 | return nil, fmt.Errorf("range from (%s) invalid: %s", stats.Range.From, err) 49 | } 50 | if err := time.Validate(stats.Range.To); err != nil { 51 | return nil, fmt.Errorf("range to (%s) invalid: %s", stats.Range.To, err) 52 | } 53 | entry.RangeFrom = stats.Range.From 54 | entry.RangeTo = stats.Range.To 55 | } 56 | entry.Keys = strings.Join(stats.Tags, ",") 57 | entry.Interval = convert.InternalInterval(stats.Interval) 58 | } 59 | 60 | if entryType != nil { 61 | entry.Type = convert.InternalEntryType(*entryType) 62 | } 63 | 64 | if err := convert.ApplyPos(&entry, pos); err != nil { 65 | return &gqlmodel.DashboardEntry{}, err 66 | } 67 | 68 | r.DB.Save(entry) 69 | 70 | return convert.ToExternalEntry(entry) 71 | } 72 | -------------------------------------------------------------------------------- /dashboard/get.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard/convert" 7 | 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // Dashboards returns all dashboards. 14 | func (r *ResolverForDashboard) Dashboards(ctx context.Context) ([]*gqlmodel.Dashboard, error) { 15 | userID := auth.GetUser(ctx).ID 16 | 17 | dashboards := []model.Dashboard{} 18 | find := r.DB.Preload("Entries").Preload("Ranges").Where(&model.Dashboard{UserID: userID}).Find(&dashboards) 19 | 20 | if find.Error != nil { 21 | return nil, find.Error 22 | } 23 | 24 | return convert.ToExternalDashboards(dashboards) 25 | } 26 | -------------------------------------------------------------------------------- /dashboard/remove.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard/convert" 7 | "github.com/traggo/server/dashboard/util" 8 | 9 | "github.com/traggo/server/auth" 10 | "github.com/traggo/server/generated/gqlmodel" 11 | "github.com/traggo/server/model" 12 | ) 13 | 14 | // RemoveDashboard removes a dashboard. 15 | func (r *ResolverForDashboard) RemoveDashboard(ctx context.Context, id int) (*gqlmodel.Dashboard, error) { 16 | userID := auth.GetUser(ctx).ID 17 | 18 | dashboard, err := util.FindDashboard(r.DB, userID, id) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | if err := r.DB.Where(&model.Dashboard{UserID: userID, ID: id}).Delete(&model.Dashboard{}).Error; err != nil { 24 | return &gqlmodel.Dashboard{}, err 25 | } 26 | 27 | return convert.ToExternalDashboard(dashboard) 28 | } 29 | -------------------------------------------------------------------------------- /dashboard/update.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard/convert" 7 | 8 | "github.com/traggo/server/dashboard/util" 9 | 10 | "github.com/traggo/server/auth" 11 | "github.com/traggo/server/generated/gqlmodel" 12 | "github.com/traggo/server/model" 13 | ) 14 | 15 | // UpdateDashboard updates a dashboard. 16 | func (r *ResolverForDashboard) UpdateDashboard(ctx context.Context, id int, name string) (*gqlmodel.Dashboard, error) { 17 | userID := auth.GetUser(ctx).ID 18 | 19 | dashboard, err := util.FindDashboard(r.DB, userID, id) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | dashboard.Name = name 25 | 26 | save := r.DB.Save(dashboard) 27 | 28 | if save.Error != nil { 29 | return nil, save.Error 30 | } 31 | 32 | var entries []model.DashboardEntry 33 | if err := r.DB.Where(&model.DashboardEntry{DashboardID: dashboard.ID}).Find(&entries).Error; err != nil { 34 | return nil, err 35 | } 36 | dashboard.Entries = entries 37 | 38 | var ranges []model.DashboardRange 39 | if err := r.DB.Where(&model.DashboardRange{DashboardID: dashboard.ID}).Find(&ranges).Error; err != nil { 40 | return nil, err 41 | } 42 | dashboard.Ranges = ranges 43 | 44 | return convert.ToExternalDashboard(dashboard) 45 | } 46 | -------------------------------------------------------------------------------- /dashboard/util/dashboard.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | // FindDashboard finds a dashboard. 11 | func FindDashboard(db *gorm.DB, userID, dashboardID int) (model.Dashboard, error) { 12 | dashboard := model.Dashboard{} 13 | find := db.Where(&model.Dashboard{UserID: userID, ID: dashboardID}).Find(&dashboard) 14 | if find.RecordNotFound() { 15 | return dashboard, errors.New("dashboard does not exist") 16 | } 17 | 18 | return dashboard, find.Error 19 | } 20 | 21 | // FindDashboardRange finds a dashboard range. 22 | func FindDashboardRange(db *gorm.DB, rangeID int) (model.DashboardRange, error) { 23 | dashboardRange := model.DashboardRange{} 24 | find := db.Where(&model.DashboardRange{ID: rangeID}).Find(&dashboardRange) 25 | if find.RecordNotFound() { 26 | return dashboardRange, errors.New("dashboard range does not exist") 27 | } 28 | 29 | return dashboardRange, find.Error 30 | } 31 | 32 | // FindDashboardEntry finds a dashboard entry. 33 | func FindDashboardEntry(db *gorm.DB, entryID int) (model.DashboardEntry, error) { 34 | dashboardEntry := model.DashboardEntry{} 35 | find := db.Where(&model.DashboardEntry{ID: entryID}).Find(&dashboardEntry) 36 | if find.RecordNotFound() { 37 | return dashboardEntry, errors.New("entry does not exist") 38 | } 39 | 40 | return dashboardEntry, find.Error 41 | } 42 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/jinzhu/gorm" 8 | _ "github.com/jinzhu/gorm/dialects/mysql" // enable the mysql dialect 9 | _ "github.com/jinzhu/gorm/dialects/postgres" // enable the postgres dialect 10 | _ "github.com/jinzhu/gorm/dialects/sqlite" // enable the sqlite3 dialect 11 | "github.com/rs/zerolog/log" 12 | "github.com/traggo/server/logger" 13 | "github.com/traggo/server/model" 14 | ) 15 | 16 | var mkdirAll = os.MkdirAll 17 | 18 | // New creates a gorm instance. 19 | func New(dialect, connection string) (*gorm.DB, error) { 20 | createDirectoryIfSqlite(dialect, connection) 21 | 22 | db, err := gorm.Open(dialect, connection) 23 | if err != nil { 24 | return nil, err 25 | } 26 | db.LogMode(true) 27 | db.SetLogger(&logger.DatabaseLogger{}) 28 | 29 | // We normally don't need that much connections, so we limit them. F.ex. mysql complains about 30 | // "too many connections". 31 | db.DB().SetMaxOpenConns(10) 32 | 33 | if dialect == "sqlite3" { 34 | // We use the database connection inside the handlers from the http 35 | // framework, therefore concurrent access occurs. Sqlite cannot handle 36 | // concurrent writes, so we limit sqlite to one connection. 37 | // see https://github.com/mattn/go-sqlite3/issues/274 38 | db.DB().SetMaxOpenConns(1) 39 | db.Exec("PRAGMA foreign_keys = ON") 40 | } 41 | 42 | log.Debug().Msg("Auto migrating schema's") 43 | db.AutoMigrate(model.All()...) 44 | 45 | log.Debug().Msg("Database initialized") 46 | return db, nil 47 | } 48 | 49 | func createDirectoryIfSqlite(dialect string, connection string) { 50 | if dialect == "sqlite3" { 51 | if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) { 52 | if err := mkdirAll(filepath.Dir(connection), 0777); err != nil { 53 | panic(err) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/rs/zerolog" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/traggo/server/logger" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | logger.Init(zerolog.WarnLevel) 15 | os.Exit(m.Run()) 16 | } 17 | 18 | func TestInvalidDialect(t *testing.T) { 19 | _, err := New("asdf", "testdb.db") 20 | assert.NotNil(t, err) 21 | } 22 | 23 | func TestCreateSqliteFolder(t *testing.T) { 24 | // ensure path not exists 25 | os.RemoveAll("somepath") 26 | 27 | db, err := New("sqlite3", "somepath/testdb.db") 28 | assert.Nil(t, err) 29 | assert.DirExists(t, "somepath") 30 | db.Close() 31 | 32 | assert.Nil(t, os.RemoveAll("somepath")) 33 | } 34 | 35 | func TestWithAlreadyExistingSqliteFolder(t *testing.T) { 36 | // ensure path not exists 37 | os.RemoveAll("somepath") 38 | os.MkdirAll("somepath", 0777) 39 | 40 | db, err := New("sqlite3", "somepath/testdb.db") 41 | assert.Nil(t, err) 42 | assert.DirExists(t, "somepath") 43 | db.Close() 44 | 45 | assert.Nil(t, os.RemoveAll("somepath")) 46 | } 47 | 48 | func TestPanicsOnMkdirError(t *testing.T) { 49 | os.RemoveAll("somepath") 50 | mkdirAll = func(path string, perm os.FileMode) error { 51 | return errors.New("ERROR") 52 | } 53 | assert.Panics(t, func() { 54 | New("sqlite3", "somepath/test.db") 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /device/create.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/traggo/server/auth" 10 | "github.com/traggo/server/auth/rand" 11 | "github.com/traggo/server/generated/gqlmodel" 12 | "github.com/traggo/server/model" 13 | "github.com/traggo/server/user/password" 14 | ) 15 | 16 | var ( 17 | timeNow = time.Now 18 | randToken = rand.Token 19 | comparePassword = password.ComparePassword 20 | errUserPassWrong = errors.New("username/password combination does not exist") 21 | ) 22 | 23 | // Login creates a device. 24 | func (r *ResolverForDevice) Login(ctx context.Context, username string, pass string, deviceName string, deviceType gqlmodel.DeviceType, cookie bool) (*gqlmodel.Login, error) { 25 | 26 | user := new(model.User) 27 | find := r.DB.Where("name = ?", username).Find(user) 28 | 29 | if find.RecordNotFound() { 30 | return nil, errUserPassWrong 31 | } 32 | 33 | if !comparePassword(user.Pass, []byte(pass)) { 34 | return nil, errUserPassWrong 35 | } 36 | 37 | return r.createDeviceInternal(ctx, user, deviceName, deviceType, cookie) 38 | } 39 | 40 | // CreateDevice creates a device. 41 | func (r *ResolverForDevice) CreateDevice(ctx context.Context, deviceName string, deviceType gqlmodel.DeviceType) (*gqlmodel.Login, error) { 42 | 43 | user := auth.GetUser(ctx) 44 | 45 | return r.createDeviceInternal(ctx, user, deviceName, deviceType, false) 46 | } 47 | 48 | func (r *ResolverForDevice) createDeviceInternal(ctx context.Context, user *model.User, deviceName string, deviceType gqlmodel.DeviceType, cookie bool) (*gqlmodel.Login, error) { 49 | 50 | token := randToken(20) 51 | for !r.DB.Where("token = ?", token).Find(new(model.Device)).RecordNotFound() { 52 | token = randToken(20) 53 | } 54 | 55 | now := timeNow() 56 | device := &model.Device{ 57 | Token: token, 58 | UserID: user.ID, 59 | Name: deviceName, 60 | Type: model.DeviceType(deviceType), 61 | CreatedAt: now.UTC(), 62 | ActiveAt: now.UTC(), 63 | } 64 | 65 | if err := device.Type.Valid(); err != nil { 66 | return nil, err 67 | } 68 | 69 | if cookie { 70 | auth.GetCreateSession(ctx)(token, device.Type.Seconds()) 71 | } 72 | 73 | create := r.DB.Create(device) 74 | 75 | gqlUser := &gqlmodel.User{} 76 | copier.Copy(gqlUser, user) 77 | gqlDevice := &gqlmodel.Device{} 78 | copier.Copy(gqlDevice, device) 79 | return &gqlmodel.Login{ 80 | User: gqlUser, 81 | Device: gqlDevice, 82 | Token: token}, create.Error 83 | } 84 | -------------------------------------------------------------------------------- /device/current.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | ) 10 | 11 | // CurrentDevice returns the current device. 12 | func (r *ResolverForDevice) CurrentDevice(ctx context.Context) (*gqlmodel.Device, error) { 13 | device := auth.GetDevice(ctx) 14 | if device == nil { 15 | return nil, nil 16 | } 17 | var result gqlmodel.Device 18 | copier.Copy(&result, device) 19 | return &result, nil 20 | } 21 | -------------------------------------------------------------------------------- /device/current_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | "github.com/traggo/server/test/fake" 12 | ) 13 | 14 | func TestGQL_CurrentDevice_withDevice(t *testing.T) { 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | 18 | device := &model.Device{ 19 | ID: 2, 20 | Name: "Browser", 21 | Token: "abcd", 22 | UserID: 2, 23 | CreatedAt: test.Time("2004-06-30T18:30:00Z"), 24 | ActiveAt: test.Time("2015-06-30T18:30:00Z"), 25 | Type: model.TypeNoExpiry, 26 | } 27 | 28 | resolver := ResolverForDevice{DB: db.DB} 29 | result, err := resolver.CurrentDevice(fake.Device(device)) 30 | 31 | require.Nil(t, err) 32 | expected := &gqlmodel.Device{ 33 | ID: 2, 34 | Name: "Browser", 35 | CreatedAt: test.ModelTimeUTC("2004-06-30T18:30:00Z"), 36 | ActiveAt: test.ModelTimeUTC("2015-06-30T18:30:00Z"), 37 | Type: gqlmodel.DeviceTypeNoExpiry, 38 | } 39 | 40 | require.Equal(t, expected, result) 41 | } 42 | 43 | func TestGQL_CurrentDevice_noDevice(t *testing.T) { 44 | db := test.InMemoryDB(t) 45 | defer db.Close() 46 | 47 | resolver := ResolverForDevice{DB: db.DB} 48 | result, err := resolver.CurrentDevice(context.Background()) 49 | 50 | require.Nil(t, err) 51 | 52 | require.Nil(t, result) 53 | } 54 | -------------------------------------------------------------------------------- /device/deviceresolver.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForDevice resolves device specific things. 6 | type ResolverForDevice struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /device/get.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // Devices returns all devices. 13 | func (r *ResolverForDevice) Devices(ctx context.Context) ([]*gqlmodel.Device, error) { 14 | user := auth.GetUser(ctx) 15 | var devices []model.Device 16 | find := r.DB.Where(&model.Device{UserID: user.ID}).Order("active_at DESC").Find(&devices) 17 | var result []*gqlmodel.Device 18 | copier.Copy(&result, &devices) 19 | return result, find.Error 20 | } 21 | -------------------------------------------------------------------------------- /device/get_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | func TestGQL_Devices(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | db.User(1) 17 | db.User(2) 18 | db.Create(&model.Device{ 19 | ID: 1, 20 | Name: "Android", 21 | Token: "abc", 22 | UserID: 1, 23 | CreatedAt: test.Time("2009-06-30T18:30:00Z"), 24 | ActiveAt: test.Time("2018-06-30T18:30:00Z"), 25 | Type: model.TypeNoExpiry, 26 | }) 27 | db.Create(&model.Device{ 28 | ID: 2, 29 | Name: "Browser", 30 | Token: "abcd", 31 | UserID: 2, 32 | CreatedAt: test.Time("2004-06-30T18:30:00Z"), 33 | ActiveAt: test.Time("2015-06-30T18:30:00Z"), 34 | Type: model.TypeNoExpiry, 35 | }) 36 | 37 | resolver := ResolverForDevice{DB: db.DB} 38 | devices, err := resolver.Devices(fake.User(1)) 39 | 40 | require.Nil(t, err) 41 | expected := []*gqlmodel.Device{ 42 | { 43 | ID: 1, 44 | Name: "Android", 45 | CreatedAt: test.ModelTimeUTC("2009-06-30T18:30:00Z"), 46 | ActiveAt: test.ModelTimeUTC("2018-06-30T18:30:00Z"), 47 | Type: gqlmodel.DeviceTypeNoExpiry, 48 | }, 49 | } 50 | require.Equal(t, expected, devices) 51 | } 52 | -------------------------------------------------------------------------------- /device/remove.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // RemoveDevice removes a device 14 | func (r *ResolverForDevice) RemoveDevice(ctx context.Context, id int) (*gqlmodel.Device, error) { 15 | device := model.Device{ID: id} 16 | if r.DB.Where(&model.Device{UserID: auth.GetUser(ctx).ID}).Find(&device).RecordNotFound() { 17 | return nil, fmt.Errorf("device not found") 18 | } 19 | 20 | remove := r.DB.Delete(&device) 21 | gqlDevice := &gqlmodel.Device{} 22 | copier.Copy(gqlDevice, &device) 23 | return gqlDevice, remove.Error 24 | } 25 | -------------------------------------------------------------------------------- /device/remove_current.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | ) 10 | 11 | // RemoveCurrentDevice removes the current authenticated device 12 | func (r *ResolverForDevice) RemoveCurrentDevice(ctx context.Context) (*gqlmodel.Device, error) { 13 | device := auth.GetDevice(ctx) 14 | remove := r.DB.Delete(device) 15 | gqlDevice := &gqlmodel.Device{} 16 | copier.Copy(gqlDevice, device) 17 | return gqlDevice, remove.Error 18 | } 19 | -------------------------------------------------------------------------------- /device/remove_current_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | func TestGQL_RemoveCurrentDevice_succeeds_removesDevice(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | db.User(1) 17 | device := &model.Device{ 18 | ID: 55, 19 | Name: "Android", 20 | Token: "abc", 21 | UserID: 1, 22 | CreatedAt: test.Time("2009-06-30T18:30:00Z"), 23 | ActiveAt: test.Time("2018-06-30T18:30:00Z"), 24 | Type: model.TypeNoExpiry, 25 | } 26 | db.Create(device) 27 | resolver := ResolverForDevice{DB: db.DB} 28 | gqlDevice, err := resolver.RemoveCurrentDevice(fake.Device(device)) 29 | require.Nil(t, err) 30 | 31 | expected := &gqlmodel.Device{ 32 | ID: 55, 33 | Name: "Android", 34 | CreatedAt: test.ModelTimeUTC("2009-06-30T18:30:00Z"), 35 | ActiveAt: test.ModelTimeUTC("2018-06-30T18:30:00Z"), 36 | Type: gqlmodel.DeviceTypeNoExpiry, 37 | } 38 | 39 | require.Equal(t, expected, gqlDevice) 40 | assertDeviceCount(t, db, 0) 41 | } 42 | -------------------------------------------------------------------------------- /device/remove_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/test" 9 | "github.com/traggo/server/test/fake" 10 | ) 11 | 12 | func TestGQL_RemoveDevice_succeeds_removesDevice(t *testing.T) { 13 | db := test.InMemoryDB(t) 14 | defer db.Close() 15 | db.User(1) 16 | db.Create(&model.Device{ 17 | ID: 55, 18 | Name: "Android", 19 | Token: "abc", 20 | UserID: 1, 21 | CreatedAt: test.Time("2009-06-30T18:30:00+02:00"), 22 | ActiveAt: test.Time("2018-06-30T18:30:00+02:00"), 23 | Type: model.TypeNoExpiry, 24 | }) 25 | resolver := ResolverForDevice{DB: db.DB} 26 | _, err := resolver.RemoveDevice(fake.User(1), 55) 27 | require.Nil(t, err) 28 | assertDeviceCount(t, db, 0) 29 | } 30 | 31 | func TestGQL_RemoveDevice_fails_notExistingDevice(t *testing.T) { 32 | db := test.InMemoryDB(t) 33 | defer db.Close() 34 | db.User(1) 35 | 36 | resolver := ResolverForDevice{DB: db.DB} 37 | _, err := resolver.RemoveDevice(fake.User(1), 55) 38 | require.EqualError(t, err, "device not found") 39 | } 40 | 41 | func TestGQL_RemoveDevice_fails_notPermission(t *testing.T) { 42 | db := test.InMemoryDB(t) 43 | defer db.Close() 44 | db.User(1) 45 | db.User(2) 46 | db.Create(&model.Device{ 47 | ID: 55, 48 | Name: "Android", 49 | Token: "abc", 50 | UserID: 2, 51 | CreatedAt: test.Time("2009-06-30T18:30:00+02:00"), 52 | ActiveAt: test.Time("2018-06-30T18:30:00+02:00"), 53 | Type: model.TypeNoExpiry, 54 | }) 55 | resolver := ResolverForDevice{DB: db.DB} 56 | _, err := resolver.RemoveDevice(fake.User(1), 55) 57 | require.EqualError(t, err, "device not found") 58 | } 59 | -------------------------------------------------------------------------------- /device/update.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // UpdateDevice updates a device. 14 | func (r *ResolverForDevice) UpdateDevice(ctx context.Context, id int, name string, deviceType gqlmodel.DeviceType) (*gqlmodel.Device, error) { 15 | device := new(model.Device) 16 | if r.DB.Where("user_id = ?", auth.GetUser(ctx).ID).Find(device, id).RecordNotFound() { 17 | return nil, errors.New("device not found") 18 | } 19 | 20 | device.Name = name 21 | device.Type = model.DeviceType(deviceType) 22 | if err := device.Type.Valid(); err != nil { 23 | return nil, err 24 | } 25 | update := r.DB.Save(device) 26 | gqlDevice := &gqlmodel.Device{} 27 | copier.Copy(gqlDevice, device) 28 | return gqlDevice, update.Error 29 | } 30 | -------------------------------------------------------------------------------- /device/update_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | func TestGQL_UpdateDevice_succeeds_updatesDevice(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | db.Create(&model.User{ 17 | Name: "jmattheis", 18 | ID: 1, 19 | Admin: true, 20 | }) 21 | db.Create(&model.Device{ 22 | Name: "old name", 23 | ID: 1, 24 | UserID: 1, 25 | CreatedAt: test.Time("2009-06-30T18:30:00Z"), 26 | ActiveAt: test.Time("2018-06-30T18:30:00Z"), 27 | Type: model.TypeNoExpiry, 28 | }) 29 | 30 | resolver := ResolverForDevice{DB: db.DB} 31 | device, err := resolver.UpdateDevice(fake.User(1), 1, "updated name", gqlmodel.DeviceTypeShortExpiry) 32 | require.Nil(t, err) 33 | 34 | expected := &gqlmodel.Device{ 35 | Name: "updated name", 36 | ID: 1, 37 | CreatedAt: test.ModelTimeUTC("2009-06-30T18:30:00Z"), 38 | ActiveAt: test.ModelTimeUTC("2018-06-30T18:30:00Z"), 39 | Type: gqlmodel.DeviceTypeShortExpiry, 40 | } 41 | require.Equal(t, expected, device) 42 | assertDeviceCount(t, db, 1) 43 | assertDeviceExist(t, db, model.Device{ 44 | Name: "updated name", 45 | ID: 1, 46 | UserID: 1, 47 | CreatedAt: test.Time("2009-06-30T18:30:00Z"), 48 | ActiveAt: test.Time("2018-06-30T18:30:00Z"), 49 | Type: model.TypeShortExpiry, 50 | }) 51 | } 52 | 53 | func TestGQL_UpdateDevice_fails_notExistingDevice(t *testing.T) { 54 | db := test.InMemoryDB(t) 55 | defer db.Close() 56 | db.Create(&model.User{ 57 | Name: "jmattheis", 58 | ID: 1, 59 | Admin: true, 60 | }) 61 | resolver := ResolverForDevice{DB: db.DB} 62 | _, err := resolver.UpdateDevice(fake.User(1), 3, "tst", gqlmodel.DeviceTypeShortExpiry) 63 | require.EqualError(t, err, "device not found") 64 | 65 | assertDeviceCount(t, db, 0) 66 | } 67 | 68 | func TestGQL_UpdateDevice_fails_noPermissions(t *testing.T) { 69 | db := test.InMemoryDB(t) 70 | defer db.Close() 71 | db.Create(&model.User{ 72 | Name: "jmattheis", 73 | ID: 1, 74 | Admin: true, 75 | }) 76 | db.Create(&model.User{ 77 | Name: "broderp", 78 | ID: 2, 79 | Admin: true, 80 | }) 81 | db.Create(&model.Device{ 82 | Name: "old name", 83 | ID: 66, 84 | UserID: 2, 85 | CreatedAt: test.Time("2009-06-30T18:30:00Z"), 86 | ActiveAt: test.Time("2018-06-30T18:30:00Z"), 87 | Type: model.TypeNoExpiry, 88 | }) 89 | resolver := ResolverForDevice{DB: db.DB} 90 | _, err := resolver.UpdateDevice(fake.User(1), 66, "tst", gqlmodel.DeviceTypeNoExpiry) 91 | require.EqualError(t, err, "device not found") 92 | 93 | assertDeviceCount(t, db, 1) 94 | } 95 | -------------------------------------------------------------------------------- /device/utils_test.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/test" 9 | ) 10 | 11 | func assertDeviceExist(t *testing.T, db *test.Database, expected model.Device) { 12 | foundDevice := new(model.Device) 13 | find := db.Find(foundDevice, expected.ID) 14 | require.Nil(t, find.Error) 15 | require.NotNil(t, foundDevice) 16 | require.Equal(t, expected, *foundDevice) 17 | } 18 | 19 | func assertDeviceCount(t *testing.T, db *test.Database, expected int) { 20 | count := new(int) 21 | db.Model(new(model.Device)).Count(count) 22 | require.Equal(t, expected, *count) 23 | } 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | traggo: 4 | build: 5 | context: . 6 | dockerfile: docker/Dockerfile.dev 7 | environment: 8 | TRAGGO_DEFAULT_USER_NAME: "admin" 9 | TRAGGO_DEFAULT_USER_PASS: "admin" 10 | 11 | TRAGGO_LOG_LEVEL: debug 12 | ports: 13 | - 3030:3030 14 | volumes: 15 | - .traggodata:/opt/traggo/data 16 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | LABEL org.opencontainers.image.source="https://github.com/traggo/server" 3 | WORKDIR /opt/traggo 4 | COPY traggo /opt/traggo 5 | EXPOSE 3030 6 | ENTRYPOINT ["./traggo"] 7 | -------------------------------------------------------------------------------- /docker/Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/goreleaser/goreleaser-cross:v1.22.0 2 | 3 | RUN apt-get update && apt-get install -y libc6-dev-i386 4 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.1 as builder 2 | 3 | RUN apt-get update && apt-get install --yes nodejs npm && npm install --global yarn 4 | 5 | WORKDIR /app 6 | COPY . . 7 | 8 | ENV GO111MODULE=on 9 | RUN make download-tools install generate build-bin-local 10 | 11 | FROM scratch 12 | WORKDIR /opt/traggo 13 | COPY --from=builder /app/build/traggo-server /opt/traggo/traggo 14 | EXPOSE 3030 15 | ENTRYPOINT ["./traggo"] 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/traggo/server 2 | 3 | require ( 4 | github.com/99designs/gqlgen v0.17.53 5 | github.com/gorilla/mux v1.8.1 6 | github.com/jinzhu/copier v0.4.0 7 | github.com/jinzhu/gorm v1.9.2 8 | github.com/jmattheis/go-timemath v1.0.1 9 | github.com/joho/godotenv v1.5.1 10 | github.com/kelseyhightower/envconfig v1.4.0 11 | github.com/magiconair/properties v1.8.7 12 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 13 | github.com/rs/zerolog v1.33.0 14 | github.com/stretchr/testify v1.9.0 15 | github.com/vektah/gqlparser/v2 v2.5.16 16 | golang.org/x/crypto v0.27.0 17 | ) 18 | 19 | require ( 20 | cloud.google.com/go v0.34.0 // indirect 21 | github.com/agnivade/levenshtein v1.1.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f // indirect 24 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect 25 | github.com/go-sql-driver/mysql v1.4.1 // indirect 26 | github.com/gofrs/uuid v3.1.0+incompatible // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/gorilla/websocket v1.5.0 // indirect 29 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 30 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect 31 | github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3 // indirect 32 | github.com/lib/pq v1.0.0 // indirect 33 | github.com/mattn/go-colorable v0.1.13 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/mattn/go-sqlite3 v1.10.0 // indirect 36 | github.com/mitchellh/mapstructure v1.5.0 // indirect 37 | github.com/pmezard/go-difflib v1.0.0 // indirect 38 | github.com/sosodev/duration v1.3.1 // indirect 39 | golang.org/x/sys v0.25.0 // indirect 40 | google.golang.org/appengine v1.3.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | 44 | go 1.22.5 45 | 46 | toolchain go1.23.1 47 | -------------------------------------------------------------------------------- /gqlgen.yml: -------------------------------------------------------------------------------- 1 | schema: schema.graphql 2 | 3 | exec: 4 | filename: generated/gqlschema/generated.go 5 | package: gqlschema 6 | 7 | model: 8 | filename: generated/gqlmodel/generated.go 9 | package: gqlmodel 10 | 11 | models: 12 | Time: 13 | model: github.com/traggo/server/model.Time 14 | 15 | struct_tag: json -------------------------------------------------------------------------------- /graphql/directive.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "github.com/traggo/server/auth" 5 | "github.com/traggo/server/generated/gqlschema" 6 | ) 7 | 8 | // NewDirective creates a new directive. 9 | func NewDirective() gqlschema.DirectiveRoot { 10 | return gqlschema.DirectiveRoot{ 11 | HasRole: auth.HasRole(), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /graphql/handler.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/99designs/gqlgen/handler" 8 | "github.com/traggo/server/generated/gqlschema" 9 | "github.com/traggo/server/logger" 10 | ) 11 | 12 | // Handler combines graphql handler and playground handler. 13 | func Handler(endpoint string, resolvers gqlschema.ResolverRoot, directives gqlschema.DirectiveRoot) http.HandlerFunc { 14 | gqlHandler := handler.GraphQL(gqlschema.NewExecutableSchema(gqlschema.Config{ 15 | Resolvers: resolvers, 16 | Directives: directives, 17 | }), handler.RequestMiddleware(logger.GQLLog())) 18 | playground := handler.Playground("Traggo Playground", endpoint) 19 | 20 | return func(writer http.ResponseWriter, request *http.Request) { 21 | if acceptHTMLAndNotJSON(request) { 22 | playground.ServeHTTP(writer, request) 23 | } else { 24 | gqlHandler.ServeHTTP(writer, request) 25 | } 26 | } 27 | } 28 | 29 | func acceptHTMLAndNotJSON(request *http.Request) bool { 30 | val := request.Header.Get("Accept") 31 | return strings.Contains(val, "text/html") && !strings.Contains(val, "application/json") 32 | } 33 | -------------------------------------------------------------------------------- /graphql/handler_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "github.com/traggo/server/model" 11 | "github.com/traggo/server/test" 12 | ) 13 | 14 | func TestHandler_jsonOverHtml(t *testing.T) { 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | resolver := NewResolver(db.DB, 4, model.Version{}) 18 | handler := Handler("/gql", resolver, NewDirective()) 19 | req := httptest.NewRequest("GET", "/gql?query="+url.QueryEscape("query {currentUser {name}}"), strings.NewReader("")) 20 | req.Header.Set("Accept", "text/html;application/json") 21 | recorder := httptest.NewRecorder() 22 | handler.ServeHTTP(recorder, req) 23 | require.Equal(t, "application/json", recorder.Header().Get("Content-Type")) 24 | require.JSONEq(t, ` 25 | { 26 | "data": { "currentUser": null } 27 | } 28 | `, recorder.Body.String()) 29 | } 30 | 31 | func TestHandler_htmlIfNotJson(t *testing.T) { 32 | db := test.InMemoryDB(t) 33 | defer db.Close() 34 | resolver := NewResolver(db.DB, 4, model.Version{}) 35 | handler := Handler("/gql", resolver, NewDirective()) 36 | req := httptest.NewRequest("get", "/gql", strings.NewReader("")) 37 | req.Header.Set("Accept", "text/html;application/xml") 38 | recorder := httptest.NewRecorder() 39 | handler.ServeHTTP(recorder, req) 40 | 41 | require.Contains(t, recorder.Body.String(), "Traggo Playground") 42 | } 43 | -------------------------------------------------------------------------------- /graphql/resolver.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/dashboard" 7 | "github.com/traggo/server/setting" 8 | 9 | "github.com/jinzhu/copier" 10 | "github.com/jinzhu/gorm" 11 | "github.com/traggo/server/device" 12 | "github.com/traggo/server/generated/gqlmodel" 13 | "github.com/traggo/server/generated/gqlschema" 14 | "github.com/traggo/server/model" 15 | "github.com/traggo/server/statistics" 16 | "github.com/traggo/server/tag" 17 | "github.com/traggo/server/timespan" 18 | "github.com/traggo/server/user" 19 | ) 20 | 21 | // NewResolver combines all resolvers to a resolver root. 22 | func NewResolver(db *gorm.DB, passStrength int, version model.Version) gqlschema.ResolverRoot { 23 | return &resolver{ 24 | ResolverForUser: user.ResolverForUser{ 25 | DB: db, 26 | PassStrength: passStrength, 27 | }, 28 | ResolverForTag: tag.ResolverForTag{ 29 | DB: db, 30 | }, 31 | ResolverForDevice: device.ResolverForDevice{ 32 | DB: db, 33 | }, 34 | ResolverForTimeSpan: timespan.ResolverForTimeSpan{ 35 | DB: db, 36 | }, 37 | ResolverForStatistics: statistics.ResolverForStatistics{ 38 | DB: db, 39 | }, 40 | ResolverForSettings: setting.ResolverForSettings{ 41 | DB: db, 42 | }, 43 | ResolverForDashboard: dashboard.NewResolverForDashboard(db), 44 | version: version, 45 | } 46 | } 47 | 48 | type resolver struct { 49 | user.ResolverForUser 50 | tag.ResolverForTag 51 | device.ResolverForDevice 52 | timespan.ResolverForTimeSpan 53 | statistics.ResolverForStatistics 54 | version model.Version 55 | setting.ResolverForSettings 56 | dashboard.ResolverForDashboard 57 | } 58 | 59 | func (r *resolver) RootMutation() gqlschema.RootMutationResolver { 60 | return r 61 | } 62 | 63 | func (r *resolver) RootQuery() gqlschema.RootQueryResolver { 64 | return r 65 | } 66 | 67 | func (r *resolver) Version(ctx context.Context) (*gqlmodel.Version, error) { 68 | gql := &gqlmodel.Version{} 69 | copier.Copy(gql, r.version) 70 | return gql, nil 71 | } 72 | -------------------------------------------------------------------------------- /graphql/resolver_test.go: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | ) 12 | 13 | func TestNewResolver_doesNotThrow(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | resolver := NewResolver(db.DB, 4, model.Version{Name: "oops", BuildDate: "date", Commit: "aeu"}) 17 | resolver.RootMutation() 18 | resolver.RootQuery() 19 | version, e := resolver.RootQuery().Version(context.Background()) 20 | assert.NoError(t, e) 21 | assert.Equal(t, &gqlmodel.Version{Name: "oops", BuildDate: "date", Commit: "aeu"}, version) 22 | } 23 | -------------------------------------------------------------------------------- /logger/database.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | var ( 13 | sqlRegexp = regexp.MustCompile(`\?`) 14 | ) 15 | 16 | // DatabaseLogger logs sql queries 17 | type DatabaseLogger struct { 18 | } 19 | 20 | // Print pretty prints the gorm.DB log values 21 | // Mostly copied from https://github.com/jinzhu/gorm/blob/master/logger.go 22 | func (l *DatabaseLogger) Print(values ...interface{}) { 23 | if len(values) > 1 { 24 | var ( 25 | sql string 26 | formattedValues []string 27 | level = values[0] 28 | ) 29 | 30 | if level == "sql" { 31 | for _, value := range values[4].([]interface{}) { 32 | indirectValue := reflect.Indirect(reflect.ValueOf(value)) 33 | if indirectValue.IsValid() { 34 | value = indirectValue.Interface() 35 | if t, ok := value.(time.Time); ok { 36 | formattedValues = append(formattedValues, fmt.Sprintf("'%v'", t.Format("2006-01-02 15:04:05"))) 37 | } else if _, ok := value.([]byte); ok { 38 | formattedValues = append(formattedValues, "''") 39 | } else { 40 | formattedValues = append(formattedValues, fmt.Sprintf("'%v'", value)) 41 | } 42 | } else { 43 | formattedValues = append(formattedValues, "NULL") 44 | } 45 | } 46 | 47 | formattedValuesLength := len(formattedValues) 48 | for index, value := range sqlRegexp.Split(values[3].(string), -1) { 49 | sql += value 50 | if index < formattedValuesLength { 51 | sql += formattedValues[index] 52 | } 53 | } 54 | 55 | log.Debug().Str("took", values[2].(time.Duration).String()).Int64("rows", values[5].(int64)).Msg("SQL: " + sql) 56 | } else if level == "log" { 57 | if len(values) > 2 { 58 | if err, ok := values[2].(error); ok { 59 | log.Error().Err(err).Msg("Database error") 60 | return 61 | } 62 | } 63 | log.Debug().Msg(fmt.Sprint(values[2:]...)) 64 | } else { 65 | log.Error().Msgf("DatabaseLogger: cannot handle level %#v", level) 66 | } 67 | } 68 | // ignore empty log 69 | } 70 | -------------------------------------------------------------------------------- /logger/gql.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | "strings" 7 | "time" 8 | "unicode" 9 | 10 | "github.com/99designs/gqlgen/graphql" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | var ( 15 | passRegEx = regexp.MustCompile(`pass\s*:\s*"(?:[^"\\]|\\.)*"`) 16 | ) 17 | 18 | // GQLLog logs graphql queries, mutations and errors. 19 | func GQLLog() graphql.ResponseMiddleware { 20 | return func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response { 21 | start := time.Now() 22 | result := next(ctx) 23 | elapsed := time.Now().Sub(start) 24 | errs := graphql.GetErrors(ctx) 25 | rawQuery := graphql.GetRequestContext(ctx).RawQuery 26 | 27 | if len(errs) > 0 { 28 | var errorStrings []string 29 | for _, err := range errs { 30 | errorStrings = append(errorStrings, err.Error()) 31 | } 32 | 33 | log.Error().Strs("error", errorStrings).Str("took", elapsed.String()).Msg("GQL: " + toOneLine(hidePassword(rawQuery))) 34 | } else if log.Debug().Enabled() { 35 | log.Debug().Str("took", elapsed.String()).Msg("GQL: " + toOneLine(hidePassword(rawQuery))) 36 | } 37 | 38 | return result 39 | } 40 | } 41 | 42 | func toOneLine(s string) string { 43 | return strings.Join(strings.FieldsFunc(s, func(r rune) bool { 44 | return unicode.IsSpace(r) || r == '\n' || r == '\r' 45 | }), " ") 46 | } 47 | 48 | func hidePassword(s string) string { 49 | return passRegEx.ReplaceAllString(s, `pass:""`) 50 | } 51 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // Init initializes the logger 12 | func Init(lvl zerolog.Level) { 13 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).Level(lvl) 14 | log.Debug().Msg("Logger initialized") 15 | } 16 | -------------------------------------------------------------------------------- /logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "github.com/traggo/server/logger" 9 | ) 10 | 11 | func TestInit_LoggingWorks(t *testing.T) { 12 | logger.Init(zerolog.InfoLevel) 13 | log.Info().Msg("test logging") 14 | } 15 | -------------------------------------------------------------------------------- /model/all.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // All returns all schema instances. 4 | func All() []interface{} { 5 | return []interface{}{ 6 | new(TagDefinition), 7 | new(User), 8 | new(Device), 9 | new(TimeSpan), 10 | new(TimeSpanTag), 11 | new(UserSetting), 12 | new(Dashboard), 13 | new(DashboardEntry), 14 | new(DashboardRange), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /model/all_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | func TestAll_NoDuplicateEntries(t *testing.T) { 11 | all := model.All() 12 | var checkedItems []interface{} 13 | for _, item := range all { 14 | assert.NotContains(t, checkedItems, item) 15 | checkedItems = append(checkedItems, item) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /model/device.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "errors" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // Device represents something which can connect to traggo 11 | type Device struct { 12 | ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` 13 | Token string `gorm:"unique"` 14 | Name string 15 | UserID int `gorm:"type:int REFERENCES users(id) ON DELETE CASCADE"` 16 | CreatedAt time.Time 17 | Type DeviceType 18 | ActiveAt time.Time 19 | } 20 | 21 | // DeviceType the device type 22 | type DeviceType string 23 | 24 | // Value for db 25 | func (t DeviceType) Value() (driver.Value, error) { 26 | return string(t), nil 27 | } 28 | 29 | // Scan for db 30 | func (t *DeviceType) Scan(value interface{}) error { 31 | s, ok := value.([]byte) 32 | if !ok { 33 | return fmt.Errorf("expected []byte but was %#v", value) 34 | } 35 | *t = DeviceType(s) 36 | return nil 37 | } 38 | 39 | // Seconds returns the amount of seconds after the device expires. 40 | func (t DeviceType) Seconds() int { 41 | switch t { 42 | case TypeLongExpiry: 43 | return 60 * 60 * 24 * 30 44 | case TypeShortExpiry: 45 | return 60 * 60 46 | case TypeNoExpiry: 47 | return 60 * 60 * 24 * 365 48 | default: 49 | return 0 50 | } 51 | } 52 | 53 | // Valid checks if the device type is valid. 54 | func (t DeviceType) Valid() error { 55 | switch t { 56 | case TypeNoExpiry, TypeShortExpiry, TypeLongExpiry: 57 | return nil 58 | default: 59 | return errors.New("unknown device type") 60 | } 61 | } 62 | 63 | // Device types 64 | const ( 65 | TypeShortExpiry DeviceType = "ShortExpiry" 66 | TypeLongExpiry DeviceType = "LongExpiry" 67 | TypeNoExpiry DeviceType = "NoExpiry" 68 | ) 69 | -------------------------------------------------------------------------------- /model/device_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func Test(t *testing.T) { 10 | dType := DeviceType("abc") 11 | require.Error(t, dType.Valid(), "unknown type") 12 | 13 | require.Equal(t, TypeShortExpiry.Seconds(), 3600) 14 | require.Equal(t, TypeLongExpiry.Seconds(), 2592000) 15 | require.Equal(t, TypeNoExpiry.Seconds(), 31536000) 16 | value, err := TypeNoExpiry.Value() 17 | require.NoError(t, err) 18 | require.Equal(t, value, "NoExpiry") 19 | require.Nil(t, TypeNoExpiry.Valid()) 20 | 21 | var scan DeviceType 22 | 23 | err = scan.Scan([]byte("NoExpiry")) 24 | require.NoError(t, err) 25 | require.Equal(t, TypeNoExpiry, scan) 26 | } 27 | -------------------------------------------------------------------------------- /model/setting.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // UserSetting a setting for a user. 6 | type UserSetting struct { 7 | UserID int `gorm:"primary_key;unique_index"` 8 | Theme string 9 | DateLocale string 10 | FirstDayOfTheWeek string 11 | DateTimeInputStyle string 12 | } 13 | 14 | // Settings constants 15 | const ( 16 | ThemeGruvboxDark = "GruvboxDark" 17 | ThemeGruvboxLight = "GruvboxLight" 18 | ThemeMaterialDark = "MaterialDark" 19 | ThemeMaterialLight = "MaterialLight" 20 | 21 | DateLocaleGerman = "German" 22 | DateLocaleAmerican = "American" 23 | DateLocaleAmerican24h = "American24h" 24 | DateLocaleBritish = "British" 25 | DateLocaleAustralian = "Australian" 26 | 27 | DateTimeInputFancy = "Fancy" 28 | DateTimeInputNative = "Native" 29 | ) 30 | 31 | var daysOfWeek = map[string]time.Weekday{ 32 | "Sunday": time.Sunday, 33 | "Monday": time.Monday, 34 | "Tuesday": time.Tuesday, 35 | "Wednesday": time.Wednesday, 36 | "Thursday": time.Thursday, 37 | "Friday": time.Friday, 38 | "Saturday": time.Saturday, 39 | } 40 | 41 | // FirstDayOfTheWeekTimeWeekday returns the configured first day of the week. 42 | func (s UserSetting) FirstDayOfTheWeekTimeWeekday() time.Weekday { 43 | return daysOfWeek[s.FirstDayOfTheWeek] 44 | } 45 | 46 | // LastDayOfTheWeekTimeWeekday returns the configured last day of the week. 47 | func (s UserSetting) LastDayOfTheWeekTimeWeekday() time.Weekday { 48 | return daysOfWeek[(daysOfWeek[s.FirstDayOfTheWeek] - 1).String()] 49 | } 50 | -------------------------------------------------------------------------------- /model/tagdefinition.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // TagDefinition describes a tag. 4 | type TagDefinition struct { 5 | Key string 6 | UserID int `gorm:"type:int REFERENCES users(id) ON DELETE CASCADE"` 7 | Color string 8 | Usages int `gorm:"-"` 9 | } 10 | -------------------------------------------------------------------------------- /model/time.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | ) 8 | 9 | // Time scalar type for graphql 10 | type Time time.Time 11 | 12 | // Time returns the wrapped time 13 | func (t Time) Time() time.Time { 14 | return time.Time(t) 15 | } 16 | 17 | // OmitTimeZone omits the time zone and removes a utc date. 18 | func (t Time) OmitTimeZone() time.Time { 19 | x := t.Time() 20 | return time.Date(x.Year(), x.Month(), x.Day(), x.Hour(), x.Minute(), x.Second(), x.Nanosecond(), time.UTC) 21 | } 22 | 23 | // UTC changes the timezone to utc. 24 | func (t Time) UTC() time.Time { 25 | return t.Time().UTC() 26 | } 27 | 28 | // MarshalGQL implements the graphql.Marshaler interface 29 | func (t Time) MarshalGQL(w io.Writer) { 30 | if _, err := w.Write([]byte(fmt.Sprintf(`"%s"`, t.Time().Format(time.RFC3339)))); err != nil { 31 | panic(err) 32 | } 33 | } 34 | 35 | // UnmarshalGQL implements the graphql.Unmarshaler interface 36 | func (t *Time) UnmarshalGQL(v interface{}) error { 37 | raw, ok := v.(string) 38 | if !ok { 39 | return fmt.Errorf("time must be a string") 40 | } 41 | 42 | parse, err := time.Parse(time.RFC3339, raw) 43 | if err != nil { 44 | return err 45 | } 46 | *t = Time(parse) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /model/time_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestTime_MarshalGQL(t *testing.T) { 13 | var buffer bytes.Buffer 14 | expected := `2009-06-30T18:30:00+02:00` 15 | parse, err := time.Parse(time.RFC3339, expected) 16 | assert.Nil(t, err) 17 | toTest := Time(parse) 18 | toTest.MarshalGQL(&buffer) 19 | actual := buffer.String() 20 | assert.Equal(t, `"`+expected+`"`, actual) 21 | } 22 | 23 | func TestTime_MarshalGQL_fails(t *testing.T) { 24 | toTest := &Time{} 25 | assert.Panics(t, func() { 26 | toTest.MarshalGQL(&failWriter{}) 27 | }) 28 | } 29 | 30 | type failWriter struct { 31 | } 32 | 33 | func (*failWriter) Write(p []byte) (n int, err error) { 34 | return 0, errors.New("uff") 35 | } 36 | 37 | func TestTime_UnmarshalGQL_success(t *testing.T) { 38 | date := "2009-06-30T18:30:00+02:00" 39 | parse, err := time.Parse(time.RFC3339, date) 40 | assert.Nil(t, err) 41 | expected := Time(parse) 42 | 43 | actual := &Time{} 44 | err = actual.UnmarshalGQL(date) 45 | assert.Nil(t, err) 46 | 47 | assert.Equal(t, expected, *actual) 48 | } 49 | 50 | func TestTime_OmitTimeZone(t *testing.T) { 51 | date := "2009-06-30T18:30:00+02:00" 52 | tzDate, err := time.Parse(time.RFC3339, date) 53 | assert.Nil(t, err) 54 | utcDate := "2009-06-30T18:30:00Z" 55 | withoutTz, err := time.Parse(time.RFC3339, utcDate) 56 | assert.Nil(t, err) 57 | 58 | assert.Equal(t, withoutTz, Time(tzDate).OmitTimeZone()) 59 | } 60 | 61 | func TestTime_UTC(t *testing.T) { 62 | date := "2009-06-30T18:30:00+02:00" 63 | parse, err := time.Parse(time.RFC3339, date) 64 | assert.Nil(t, err) 65 | without := "2009-06-30T16:30:00Z" 66 | withoutTz, err := time.Parse(time.RFC3339, without) 67 | assert.Nil(t, err) 68 | 69 | assert.Equal(t, withoutTz, Time(parse).UTC()) 70 | } 71 | 72 | func TestTime_UnmarshalGQL_failInvalidType(t *testing.T) { 73 | actual := &Time{} 74 | err := actual.UnmarshalGQL(1) 75 | assert.EqualError(t, err, "time must be a string") 76 | } 77 | 78 | func TestTime_UnmarshalGQL_invalidFormat(t *testing.T) { 79 | actual := &Time{} 80 | err := actual.UnmarshalGQL("lol") 81 | assert.EqualError(t, err, `parsing time "lol" as "2006-01-02T15:04:05Z07:00": cannot parse "lol" as "2006"`) 82 | } 83 | -------------------------------------------------------------------------------- /model/timespan.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // TimeSpan is basically a tagged time range. 6 | type TimeSpan struct { 7 | ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` 8 | StartUTC time.Time 9 | EndUTC *time.Time 10 | StartUserTime time.Time 11 | EndUserTime *time.Time 12 | OffsetUTC int 13 | UserID int `gorm:"type:int REFERENCES users(id) ON DELETE CASCADE"` 14 | Tags []TimeSpanTag 15 | Note string 16 | } 17 | 18 | // TimeSpanTag is a tag for a time range 19 | type TimeSpanTag struct { 20 | TimeSpanID int `gorm:"type:int REFERENCES time_spans(id) ON DELETE CASCADE"` 21 | Key string 22 | StringValue string 23 | } 24 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // User holds information about credentials and authorizations. 4 | type User struct { 5 | ID int `gorm:"primary_key;unique_index;AUTO_INCREMENT"` 6 | Name string `gorm:"unique"` 7 | Pass []byte 8 | Admin bool 9 | } 10 | -------------------------------------------------------------------------------- /model/version.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // The Version of traggo. 4 | type Version struct { 5 | Commit string 6 | Name string 7 | BuildDate string 8 | } 9 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "time" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | var notifySignal = signal.Notify 15 | var serverShutdown = func(server *http.Server, ctx context.Context) error { 16 | return server.Shutdown(ctx) 17 | } 18 | 19 | // Start starts the http server 20 | func Start(mux *mux.Router, port int) error { 21 | server, shutdown := startServer(mux, port) 22 | shutdownOnInterruptSignal(server, 2*time.Second, shutdown) 23 | return waitForServerToClose(shutdown) 24 | } 25 | 26 | func startServer(mux *mux.Router, port int) (*http.Server, chan error) { 27 | srv := &http.Server{ 28 | Addr: fmt.Sprintf(":%d", port), 29 | Handler: mux, 30 | } 31 | 32 | shutdown := make(chan error) 33 | go func() { 34 | err := srv.ListenAndServe() 35 | shutdown <- err 36 | }() 37 | return srv, shutdown 38 | } 39 | 40 | func shutdownOnInterruptSignal(server *http.Server, timeout time.Duration, shutdown chan<- error) { 41 | interrupt := make(chan os.Signal, 1) 42 | notifySignal(interrupt, os.Interrupt) 43 | 44 | go func() { 45 | select { 46 | case <-interrupt: 47 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 48 | defer cancel() 49 | if err := serverShutdown(server, ctx); err != nil { 50 | shutdown <- err 51 | } 52 | } 53 | }() 54 | } 55 | 56 | func waitForServerToClose(shutdown <-chan error) error { 57 | err := <-shutdown 58 | if err == http.ErrServerClosed { 59 | return nil 60 | } 61 | return err 62 | } 63 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gorilla/mux" 12 | "github.com/phayes/freeport" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/traggo/server/test" 15 | ) 16 | 17 | func TestShutdownOnErrorWhileShutdown(t *testing.T) { 18 | db := test.InMemoryDB(t) 19 | defer db.Close() 20 | 21 | disposeInterrupt := fakeInterrupt(t) 22 | defer disposeInterrupt() 23 | 24 | shutdownError := errors.New("shutdown error") 25 | disposeShutdown := fakeShutdownError(shutdownError) 26 | defer disposeShutdown() 27 | 28 | finished := make(chan error) 29 | 30 | go func() { 31 | finished <- Start(mux.NewRouter(), freeport.GetPort()) 32 | }() 33 | 34 | select { 35 | case <-time.After(1 * time.Second): 36 | t.Fatal("Server should be closed") 37 | case err := <-finished: 38 | assert.Equal(t, shutdownError, err) 39 | } 40 | } 41 | 42 | func TestShutdownAfterError(t *testing.T) { 43 | db := test.InMemoryDB(t) 44 | defer db.Close() 45 | 46 | finished := make(chan error) 47 | 48 | go func() { 49 | finished <- Start(mux.NewRouter(), -5) 50 | }() 51 | 52 | select { 53 | case <-time.After(1 * time.Second): 54 | t.Fatal("Server should be closed") 55 | case err := <-finished: 56 | assert.NotNil(t, err) 57 | } 58 | } 59 | 60 | func TestShutdown(t *testing.T) { 61 | db := test.InMemoryDB(t) 62 | defer db.Close() 63 | 64 | dispose := fakeInterrupt(t) 65 | defer dispose() 66 | 67 | finished := make(chan error) 68 | 69 | go func() { 70 | finished <- Start(mux.NewRouter(), freeport.GetPort()) 71 | }() 72 | 73 | select { 74 | case <-time.After(1 * time.Second): 75 | t.Fatal("Server should be closed") 76 | case err := <-finished: 77 | assert.Nil(t, err) 78 | } 79 | } 80 | 81 | func fakeInterrupt(t *testing.T) func() { 82 | oldNotify := notifySignal 83 | notifySignal = func(c chan<- os.Signal, sig ...os.Signal) { 84 | assert.Contains(t, sig, os.Interrupt) 85 | go func() { 86 | select { 87 | case <-time.After(100 * time.Millisecond): 88 | c <- os.Interrupt 89 | } 90 | }() 91 | } 92 | return func() { 93 | notifySignal = oldNotify 94 | } 95 | } 96 | 97 | func fakeShutdownError(err error) func() { 98 | old := serverShutdown 99 | serverShutdown = func(server *http.Server, ctx context.Context) error { 100 | return err 101 | } 102 | return func() { 103 | serverShutdown = old 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /setting/get.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/jinzhu/gorm" 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // Get returns the settings 13 | func Get(ctx context.Context, db *gorm.DB) (model.UserSetting, error) { 14 | internal := model.UserSetting{} 15 | user := auth.GetUser(ctx) 16 | defaultSettings := model.UserSetting{ 17 | Theme: model.ThemeGruvboxDark, 18 | DateLocale: model.DateLocaleAmerican, 19 | FirstDayOfTheWeek: time.Monday.String(), 20 | DateTimeInputStyle: model.DateTimeInputFancy, 21 | } 22 | 23 | if user == nil { 24 | return defaultSettings, nil 25 | } 26 | find := db.Where(&model.UserSetting{UserID: user.ID}).Find(&internal) 27 | 28 | if find.RecordNotFound() { 29 | return defaultSettings, nil 30 | } 31 | 32 | if find.Error != nil { 33 | return defaultSettings, find.Error 34 | } 35 | 36 | return internal, nil 37 | } 38 | -------------------------------------------------------------------------------- /setting/get_test.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | "github.com/traggo/server/test/fake" 12 | ) 13 | 14 | func TestGet_noUser(t *testing.T) { 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | 18 | settings, err := Get(context.Background(), db.DB) 19 | require.NoError(t, err) 20 | require.Equal(t, model.ThemeGruvboxDark, settings.Theme) 21 | } 22 | 23 | func TestGet_user_noSettings(t *testing.T) { 24 | db := test.InMemoryDB(t) 25 | defer db.Close() 26 | db.User(1) 27 | 28 | settings, err := Get(fake.User(1), db.DB) 29 | require.NoError(t, err) 30 | require.Equal(t, model.ThemeGruvboxDark, settings.Theme) 31 | } 32 | 33 | func TestGet_user(t *testing.T) { 34 | db := test.InMemoryDB(t) 35 | defer db.Close() 36 | db.User(1) 37 | db.Save(&model.UserSetting{ 38 | UserID: 1, 39 | Theme: model.ThemeGruvboxLight, 40 | DateLocale: model.DateLocaleGerman, 41 | FirstDayOfTheWeek: time.Sunday.String(), 42 | }) 43 | 44 | settings, err := Get(fake.User(1), db.DB) 45 | require.NoError(t, err) 46 | require.Equal(t, model.ThemeGruvboxLight, settings.Theme) 47 | } 48 | -------------------------------------------------------------------------------- /setting/settingresolver.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForSettings resolves setting specific things. 6 | type ResolverForSettings struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /setting/usersettings_test.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | const ( 14 | invalidInternalWeekday = time.Weekday(100) 15 | invalidExternalWeekday = gqlmodel.WeekDay("abc") 16 | ) 17 | 18 | func TestSettingsResolver(t *testing.T) { 19 | db := test.InMemoryDB(t) 20 | defer db.Close() 21 | 22 | db.User(1) 23 | 24 | resolver := &ResolverForSettings{DB: db.DB} 25 | 26 | settings, err := resolver.UserSettings(fake.User(1)) 27 | require.NoError(t, err) 28 | require.Equal(t, gqlmodel.ThemeGruvboxDark, settings.Theme) 29 | 30 | _, err = resolver.SetUserSettings(fake.User(1), gqlmodel.InputUserSettings{ 31 | Theme: gqlmodel.ThemeGruvboxLight, 32 | DateLocale: gqlmodel.DateLocaleGerman, 33 | FirstDayOfTheWeek: gqlmodel.WeekDayWednesday, 34 | DateTimeInputStyle: gqlmodel.DateTimeInputStyleFancy, 35 | }) 36 | require.NoError(t, err) 37 | 38 | settings, err = resolver.UserSettings(fake.User(1)) 39 | require.NoError(t, err) 40 | require.Equal(t, &gqlmodel.UserSettings{ 41 | Theme: gqlmodel.ThemeGruvboxLight, 42 | DateLocale: gqlmodel.DateLocaleGerman, 43 | FirstDayOfTheWeek: gqlmodel.WeekDayWednesday, 44 | DateTimeInputStyle: gqlmodel.DateTimeInputStyleFancy, 45 | }, settings) 46 | } 47 | 48 | func TestWeekDayConvert(t *testing.T) { 49 | for _, day := range gqlmodel.AllWeekDay { 50 | require.Equal(t, day, toExternalWeekday(toInternalWeekday(day))) 51 | } 52 | require.Panics(t, func() { 53 | toInternalWeekday(invalidExternalWeekday) 54 | }) 55 | require.Panics(t, func() { 56 | toExternalWeekday(invalidInternalWeekday) 57 | }) 58 | } 59 | 60 | func TestShouldHandleInvalidInputs(t *testing.T) { 61 | toExternalTheme("aoeuaoeu") 62 | toInternalTheme("aoeuaoeu") 63 | toExternalDateLocale("aeu") 64 | toInternalDateLocale("aoeu") 65 | toExternalDateTimeInputStyle("aoeu") 66 | toInternalDateTimeInputStyle("aoeu") 67 | 68 | } 69 | -------------------------------------------------------------------------------- /statistics/statisticsresolver.go: -------------------------------------------------------------------------------- 1 | package statistics 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForStatistics resolves statistic things. 6 | type ResolverForStatistics struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /statistics/summary2.go: -------------------------------------------------------------------------------- 1 | package statistics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/generated/gqlmodel" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/setting" 9 | "github.com/traggo/server/time" 10 | ) 11 | 12 | // Stats2 another version of the stats endpoint 13 | func (r *ResolverForStatistics) Stats2(ctx context.Context, now model.Time, stats gqlmodel.InputStatsSelection) ([]*gqlmodel.RangedStatisticsEntries, error) { 14 | 15 | settings, err := setting.Get(ctx, r.DB) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | var ranges []*gqlmodel.Range 21 | 22 | staticRanges, err := time.ParseRange(now.OmitTimeZone(), 23 | time.RelativeRange{From: stats.Range.From, To: stats.Range.To}, 24 | time.InternalInterval(stats.Interval), 25 | settings.FirstDayOfTheWeekTimeWeekday(), 26 | settings.LastDayOfTheWeekTimeWeekday()) 27 | if err != nil { 28 | return nil, err 29 | } 30 | for _, r := range staticRanges { 31 | ranges = append(ranges, &gqlmodel.Range{Start: model.Time(r.From), End: model.Time(r.To)}) 32 | } 33 | 34 | return r.Stats(ctx, ranges, stats.Tags, stats.ExcludeTags, stats.IncludeTags) 35 | } 36 | -------------------------------------------------------------------------------- /tag/create.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/traggo/server/auth" 10 | "github.com/traggo/server/generated/gqlmodel" 11 | "github.com/traggo/server/model" 12 | ) 13 | 14 | // CreateTag creates a tag. 15 | func (r *ResolverForTag) CreateTag(ctx context.Context, key string, color string) (*gqlmodel.TagDefinition, error) { 16 | userID := auth.GetUser(ctx).ID 17 | definition := &model.TagDefinition{ 18 | Key: strings.ToLower(key), 19 | Color: color, 20 | UserID: userID, 21 | } 22 | 23 | if !r.DB.Where("user_id = ?", userID).Where("key = ?", strings.ToLower(key)).Find(new(model.TagDefinition)).RecordNotFound() { 24 | return nil, fmt.Errorf("tag with key '%s' does already exist", definition.Key) 25 | } 26 | 27 | create := r.DB.Create(&definition) 28 | gqlTag := &gqlmodel.TagDefinition{} 29 | copier.Copy(gqlTag, definition) 30 | return gqlTag, create.Error 31 | } 32 | -------------------------------------------------------------------------------- /tag/create_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | "github.com/traggo/server/test/fake" 12 | ) 13 | 14 | func TestGQL_CreateTag_succeeds_addsTag(t *testing.T) { 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | db.User(5) 18 | 19 | resolver := ResolverForTag{DB: db.DB} 20 | tag, err := resolver.CreateTag(fake.User(5), "new tag", "#fff") 21 | 22 | require.Nil(t, err) 23 | expected := &gqlmodel.TagDefinition{ 24 | Key: "new tag", 25 | Color: "#fff", 26 | } 27 | require.Equal(t, expected, tag) 28 | assertTagExist(t, db, model.TagDefinition{ 29 | Key: "new tag", 30 | Color: "#fff", 31 | UserID: 5, 32 | }) 33 | assertTagCount(t, db, 1) 34 | } 35 | 36 | func TestGQL_CreateTag_fails_tagAlreadyExists(t *testing.T) { 37 | db := test.InMemoryDB(t) 38 | defer db.Close() 39 | db.User(5) 40 | db.Create(&model.TagDefinition{Key: "existing tag", Color: "#fff", UserID: 5}) 41 | 42 | resolver := ResolverForTag{DB: db.DB} 43 | _, err := resolver.CreateTag(fake.User(5), "existing tag", "#fff") 44 | 45 | require.EqualError(t, err, "tag with key 'existing tag' does already exist") 46 | assertTagCount(t, db, 1) 47 | } 48 | 49 | func TestGQL_CreateTag_fails_tagAlreadyExists_caseInsensitive(t *testing.T) { 50 | db := test.InMemoryDB(t) 51 | defer db.Close() 52 | db.User(5) 53 | db.Create(&model.TagDefinition{Key: "tag", Color: "#fff", UserID: 5}) 54 | 55 | resolver := ResolverForTag{DB: db.DB} 56 | _, err := resolver.CreateTag(fake.User(5), "Tag", "#fff") 57 | 58 | require.EqualError(t, err, "tag with key 'tag' does already exist") 59 | assertTagCount(t, db, 1) 60 | } 61 | 62 | func TestGQL_CreateTag_succeeds_existingTagForOtherUser(t *testing.T) { 63 | db := test.InMemoryDB(t) 64 | defer db.Close() 65 | db.User(4) 66 | db.User(5) 67 | db.Create(&model.TagDefinition{Key: "existing tag", Color: "#fff", UserID: 4}) 68 | 69 | resolver := ResolverForTag{DB: db.DB} 70 | _, err := resolver.CreateTag(fake.User(5), "existing tag", "#xxx") 71 | 72 | assert.Nil(t, err) 73 | assertTagCount(t, db, 2) 74 | assertTagExist(t, db, model.TagDefinition{ 75 | Key: "existing tag", 76 | Color: "#xxx", 77 | UserID: 5, 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /tag/get.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // Tags returns all tags. 13 | func (r *ResolverForTag) Tags(ctx context.Context) ([]*gqlmodel.TagDefinition, error) { 14 | var tags []model.TagDefinition 15 | userID := auth.GetUser(ctx).ID 16 | 17 | timeSpansIdsOfUser := r.DB.Model(new(model.TimeSpan)). 18 | Select("id"). 19 | Where(&model.TimeSpan{UserID: userID}). 20 | SubQuery() 21 | usages := r.DB.Select("COUNT (*)").Where("time_span_tags.time_span_id in ?", timeSpansIdsOfUser). 22 | Where("tag_definitions.key = time_span_tags.key"). 23 | Model(new(model.TimeSpanTag)). 24 | Group("time_span_tags.key"). 25 | SubQuery() 26 | find := r.DB.Select("tag_definitions.*, ? as usages", usages).Where("user_id = ?", userID).Order("usages desc").Find(&tags) 27 | result := []*gqlmodel.TagDefinition{} 28 | copier.Copy(&result, &tags) 29 | return result, find.Error 30 | } 31 | -------------------------------------------------------------------------------- /tag/get_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/test" 9 | "github.com/traggo/server/test/fake" 10 | ) 11 | 12 | func TestGQL_Tags(t *testing.T) { 13 | db := test.InMemoryDB(t) 14 | defer db.Close() 15 | left := db.User(5) 16 | right := db.User(2) 17 | left.NewTagDefinition("my tag") 18 | left.NewTagDefinition("my tag 2") 19 | left.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z").Tag("my tag", "value") 20 | left.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z").Tag("my tag", "value").Tag("my tag 2", "v") 21 | left.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z").Tag("my tag", "value").Tag("my tag 2", "v") 22 | right.NewTagDefinition("my tag") 23 | right.NewTagDefinition("my tag 2") 24 | right.NewTagDefinition("my tag 5") 25 | right.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z").Tag("my tag", "value").Tag("my tag 2", "v") 26 | 27 | resolver := ResolverForTag{DB: db.DB} 28 | tags, err := resolver.Tags(fake.User(left.User.ID)) 29 | 30 | require.Nil(t, err) 31 | expected := []*gqlmodel.TagDefinition{ 32 | {Key: "my tag", Usages: 3}, 33 | {Key: "my tag 2", Usages: 2}, 34 | } 35 | require.Equal(t, expected, tags) 36 | } 37 | -------------------------------------------------------------------------------- /tag/remove.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // RemoveTag removes a tag. 14 | func (r *ResolverForTag) RemoveTag(ctx context.Context, key string) (*gqlmodel.TagDefinition, error) { 15 | tag := model.TagDefinition{} 16 | userID := auth.GetUser(ctx).ID 17 | if r.DB.Where(&model.TagDefinition{UserID: userID, Key: key}).Find(&tag).RecordNotFound() { 18 | return nil, fmt.Errorf("tag with key '%s' does not exist", key) 19 | } 20 | 21 | usedInEntries := []model.DashboardEntry{} 22 | // Do not read the next statements, not proud of it. 23 | if err := r.DB.Where("keys LIKE ?", "%"+key). 24 | Or("keys like ?", "%"+key+"%"). 25 | Or("keys like ?", key+"%"). 26 | Find(&usedInEntries).Error; err != nil { 27 | return nil, err 28 | } 29 | 30 | if len(usedInEntries) > 0 { 31 | dashboard := &model.Dashboard{ID: usedInEntries[0].DashboardID} 32 | r.DB.Find(dashboard) 33 | return nil, fmt.Errorf("tag '%s' is used in dashboard '%s' entry '%s', remove this reference before deleting the tag", key, dashboard.Name, usedInEntries[0].Title) 34 | } 35 | 36 | tx := r.DB.Begin() 37 | if err := tx.Where(model.TagDefinition{Key: key, UserID: userID}). 38 | Delete(new(model.TagDefinition)).Error; err != nil { 39 | tx.Rollback() 40 | return nil, err 41 | } 42 | 43 | timeSpansIdsOfUser := tx.Model(new(model.TimeSpan)). 44 | Select("id"). 45 | Where(&model.TimeSpan{UserID: userID}). 46 | SubQuery() 47 | 48 | if err := tx. 49 | Where("time_span_id in ?", timeSpansIdsOfUser). 50 | Where(&model.TimeSpanTag{Key: key}). 51 | Delete(new(model.TimeSpanTag)).Error; err != nil { 52 | tx.Rollback() 53 | return nil, err 54 | } 55 | 56 | remove := tx.Commit() 57 | gqlTag := &gqlmodel.TagDefinition{} 58 | copier.Copy(gqlTag, &tag) 59 | return gqlTag, remove.Error 60 | } 61 | -------------------------------------------------------------------------------- /tag/remove_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/test" 8 | "github.com/traggo/server/test/fake" 9 | ) 10 | 11 | func TestGQL_RemoveTag_succeeds_removesTag(t *testing.T) { 12 | db := test.InMemoryDB(t) 13 | defer db.Close() 14 | user := db.User(3) 15 | user.NewTagDefinition("existing") 16 | 17 | resolver := ResolverForTag{DB: db.DB} 18 | _, err := resolver.RemoveTag(fake.User(3), "existing") 19 | require.Nil(t, err) 20 | user.AssertHasTagDefinition("existing", false) 21 | } 22 | 23 | func TestRemove_referencedInDashboardEntry(t *testing.T) { 24 | db := test.InMemoryDB(t) 25 | defer db.Close() 26 | left := db.User(5) 27 | left.NewTagDefinition("coolio") 28 | dashboard := left.Dashboard("yeah") 29 | dashboard.Entry("entry") 30 | entry := dashboard.Dashboard.Entries[0] 31 | entry.Keys = "abc,coolio,chicken" 32 | db.Save(&entry) 33 | 34 | resolver := ResolverForTag{DB: db.DB} 35 | _, err := resolver.RemoveTag(fake.User(left.User.ID), "coolio") 36 | require.EqualError(t, err, "tag 'coolio' is used in dashboard 'yeah' entry 'entry', remove this reference before deleting the tag") 37 | } 38 | 39 | func TestGQL_RemoveTag_succeeds_removesTimespans(t *testing.T) { 40 | db := test.InMemoryDB(t) 41 | defer db.Close() 42 | left := db.User(3) 43 | right := db.User(4) 44 | left.NewTagDefinition("tag") 45 | right.NewTagDefinition("tag") 46 | leftTs := left.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z") 47 | leftTs.Tag("tag", "def") 48 | rightTs := right.TimeSpan("2009-06-30T18:30:00Z", "2009-06-30T18:40:00Z") 49 | rightTs.Tag("tag", "def") 50 | 51 | resolver := ResolverForTag{DB: db.DB} 52 | _, err := resolver.RemoveTag(fake.User(left.User.ID), "tag") 53 | require.Nil(t, err) 54 | 55 | assertTagCount(t, db, 1) 56 | 57 | left.AssertHasTagDefinition("tag", false) 58 | right.AssertHasTagDefinition("tag", true) 59 | 60 | leftTs.AssertExists(true).AssertHasTag("tag", "def", false) 61 | rightTs.AssertExists(true).AssertHasTag("tag", "def", true) 62 | } 63 | 64 | func TestGQL_RemoveTag_fails_notExistingTag(t *testing.T) { 65 | db := test.InMemoryDB(t) 66 | defer db.Close() 67 | db.User(3) 68 | 69 | resolver := ResolverForTag{DB: db.DB} 70 | _, err := resolver.RemoveTag(fake.User(3), "not existing") 71 | require.EqualError(t, err, "tag with key 'not existing' does not exist") 72 | } 73 | 74 | func TestGQL_RemoveTag_fails_notPermission(t *testing.T) { 75 | db := test.InMemoryDB(t) 76 | defer db.Close() 77 | db.User(3).NewTagDefinition("existing") 78 | db.User(5) 79 | 80 | resolver := ResolverForTag{DB: db.DB} 81 | _, err := resolver.RemoveTag(fake.User(5), "existing") 82 | require.EqualError(t, err, "tag with key 'existing' does not exist") 83 | } 84 | -------------------------------------------------------------------------------- /tag/suggest.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // SuggestTag suggests a tag. 13 | func (r *ResolverForTag) SuggestTag(ctx context.Context, query string) ([]*gqlmodel.TagDefinition, error) { 14 | var suggestions []model.TagDefinition 15 | find := r.DB.Where("user_id = ?", auth.GetUser(ctx).ID).Where("Key LIKE ?", query+"%").Find(&suggestions) 16 | var result []*gqlmodel.TagDefinition 17 | copier.Copy(&result, &suggestions) 18 | return result, find.Error 19 | } 20 | -------------------------------------------------------------------------------- /tag/suggest_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | func TestGQL_SuggestTag_matchesTags(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | db.User(1) 17 | db.User(2) 18 | resolver := ResolverForTag{DB: db.DB} 19 | db.Create(&model.TagDefinition{Key: "project", Color: "#fff", UserID: 1}) 20 | db.Create(&model.TagDefinition{Key: "project2", Color: "#fff", UserID: 2}) 21 | db.Create(&model.TagDefinition{Key: "priority", Color: "#fff", UserID: 1}) 22 | db.Create(&model.TagDefinition{Key: "wood", Color: "#fff", UserID: 1}) 23 | 24 | tags, err := resolver.SuggestTag(fake.User(1), "pr") 25 | 26 | require.Nil(t, err) 27 | expected := []*gqlmodel.TagDefinition{ 28 | {Key: "project", Color: "#fff"}, 29 | {Key: "priority", Color: "#fff"}, 30 | } 31 | require.Equal(t, expected, tags) 32 | } 33 | 34 | func TestGQL_SuggestTag_noMatchingTags(t *testing.T) { 35 | db := test.InMemoryDB(t) 36 | defer db.Close() 37 | db.User(1) 38 | resolver := ResolverForTag{DB: db.DB} 39 | db.Create(&model.TagDefinition{Key: "project", Color: "#fff", UserID: 1}) 40 | db.Create(&model.TagDefinition{Key: "wood", Color: "#fff", UserID: 1}) 41 | 42 | tags, err := resolver.SuggestTag(fake.User(1), "fire") 43 | 44 | require.Nil(t, err) 45 | expected := []*gqlmodel.TagDefinition{} 46 | require.Equal(t, expected, tags) 47 | } 48 | -------------------------------------------------------------------------------- /tag/tagresolver.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForTag resolves tag specific things. 6 | type ResolverForTag struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /tag/update.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/jinzhu/copier" 9 | "github.com/traggo/server/auth" 10 | "github.com/traggo/server/generated/gqlmodel" 11 | "github.com/traggo/server/model" 12 | ) 13 | 14 | // UpdateTag updates a tag. 15 | func (r *ResolverForTag) UpdateTag(ctx context.Context, key string, newKey *string, color string) (*gqlmodel.TagDefinition, error) { 16 | tag := model.TagDefinition{} 17 | userID := auth.GetUser(ctx).ID 18 | if r.DB.Where(&model.TagDefinition{UserID: userID, Key: key}).Find(&tag).RecordNotFound() { 19 | return nil, fmt.Errorf("tag with key '%s' does not exist", key) 20 | } 21 | 22 | tx := r.DB.Begin() 23 | 24 | newValue := model.TagDefinition{ 25 | Key: strings.ToLower(key), 26 | Color: color, 27 | UserID: userID, 28 | } 29 | 30 | if newKey != nil && *newKey != key { 31 | newValue.Key = strings.ToLower(*newKey) 32 | timeSpansIdsOfUser := tx.Model(new(model.TimeSpan)). 33 | Select("id"). 34 | Where(&model.TimeSpan{UserID: userID}). 35 | SubQuery() 36 | 37 | if err := tx. 38 | Model(new(model.TimeSpanTag)). 39 | Where("time_span_id in ?", timeSpansIdsOfUser). 40 | Where(&model.TimeSpanTag{Key: key}). 41 | Updates(&model.TimeSpanTag{Key: *newKey}).Error; err != nil { 42 | tx.Rollback() 43 | return nil, err 44 | } 45 | usedInEntries := []model.DashboardEntry{} 46 | 47 | // Do not read the next statements, not proud of it. 48 | if err := tx.Where("keys LIKE ?", "%"+key). 49 | Or("keys like ?", "%"+key+"%"). 50 | Or("keys like ?", key+"%"). 51 | Find(&usedInEntries).Error; err != nil { 52 | tx.Rollback() 53 | return nil, err 54 | } 55 | 56 | for _, entry := range usedInEntries { 57 | tags := strings.Split(entry.Keys, ",") 58 | for index, tagInEntry := range tags { 59 | if tagInEntry == key { 60 | tags[index] = *newKey 61 | } 62 | } 63 | entry.Keys = strings.Join(tags, ",") 64 | if err := tx.Save(&entry).Error; err != nil { 65 | tx.Rollback() 66 | return nil, err 67 | } 68 | } 69 | } 70 | 71 | if err := tx.Model(new(model.TagDefinition)).Where(&model.TagDefinition{UserID: userID, Key: key}).Updates(&newValue).Error; err != nil { 72 | tx.Rollback() 73 | return nil, err 74 | } 75 | 76 | update := tx.Commit() 77 | 78 | gqlTag := &gqlmodel.TagDefinition{} 79 | copier.Copy(gqlTag, &newValue) 80 | return gqlTag, update.Error 81 | } 82 | -------------------------------------------------------------------------------- /tag/utils_test.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/test" 9 | ) 10 | 11 | func assertTagExist(t *testing.T, db *test.Database, expected model.TagDefinition) { 12 | foundUser := new(model.TagDefinition) 13 | find := db.Where("key = ?", expected.Key).Find(foundUser) 14 | require.Nil(t, find.Error) 15 | require.NotNil(t, foundUser) 16 | require.Equal(t, expected, *foundUser) 17 | } 18 | 19 | func assertTagCount(t *testing.T, db *test.Database, expected int) { 20 | count := new(int) 21 | db.Model(new(model.TagDefinition)).Count(count) 22 | require.Equal(t, expected, *count) 23 | } 24 | -------------------------------------------------------------------------------- /test/fake/device.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | // Device creates a context with a fake device. 11 | func Device(device *model.Device) context.Context { 12 | return auth.WithDevice(context.Background(), device) 13 | } 14 | -------------------------------------------------------------------------------- /test/fake/user.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | // User create a context with a fake user. 11 | func User(id int) context.Context { 12 | return UserWithPerm(id, true) 13 | } 14 | 15 | // UserWithPerm create a context with a fake user. 16 | func UserWithPerm(id int, admin bool) context.Context { 17 | return auth.WithUser(context.Background(), &model.User{ 18 | ID: id, 19 | Name: "fake", 20 | Admin: admin, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /test/fake_test.go: -------------------------------------------------------------------------------- 1 | package test_test 2 | 3 | type fakeTesting struct { 4 | hasErrors bool 5 | } 6 | 7 | func (t *fakeTesting) Errorf(format string, args ...interface{}) { 8 | t.hasErrors = true 9 | } 10 | -------------------------------------------------------------------------------- /test/init.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | "github.com/traggo/server/logger" 6 | ) 7 | 8 | func init() { 9 | logger.Init(zerolog.WarnLevel) 10 | } 11 | -------------------------------------------------------------------------------- /test/logger.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | "github.com/rs/zerolog/log" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/traggo/server/logger" 8 | ) 9 | 10 | // Logger the test logger with util methods 11 | type Logger struct { 12 | old zerolog.Logger 13 | entries []Entry 14 | t assert.TestingT 15 | } 16 | 17 | // Entry a test logging entry 18 | type Entry struct { 19 | Level zerolog.Level 20 | Message string 21 | } 22 | 23 | // Dispose resets the logger 24 | func (l *Logger) Dispose() { 25 | log.Logger = l.old 26 | } 27 | 28 | // NewLogger creates a new test logger 29 | func NewLogger(t assert.TestingT) *Logger { 30 | logger := &Logger{t: t} 31 | log.Logger = zerolog.New(&noop{}).With().Timestamp().Logger().Hook(logger) 32 | return logger 33 | } 34 | 35 | // Run records log entries 36 | func (l *Logger) Run(e *zerolog.Event, level zerolog.Level, message string) { 37 | l.entries = append(l.entries, Entry{Level: level, Message: message}) 38 | } 39 | 40 | // AssertCount asserts the amount of recorded logging entries 41 | func (l *Logger) AssertCount(count int) { 42 | assert.Len(l.t, l.entries, count) 43 | } 44 | 45 | // AssertEntryExists asserts that a logging entry exists 46 | func (l *Logger) AssertEntryExists(entry Entry) { 47 | assert.Contains(l.t, l.entries, entry) 48 | } 49 | 50 | type noop struct { 51 | } 52 | 53 | func (*noop) Write(p []byte) (n int, err error) { 54 | return len(p), nil 55 | } 56 | 57 | // LogDebug enables debug log 58 | func LogDebug() { 59 | logger.Init(zerolog.DebugLevel) 60 | } 61 | -------------------------------------------------------------------------------- /test/logger_test.go: -------------------------------------------------------------------------------- 1 | package test_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/traggo/server/test" 10 | ) 11 | 12 | func TestLogger_AssertCount_Succeeds(t *testing.T) { 13 | logger := test.NewLogger(t) 14 | defer logger.Dispose() 15 | 16 | log.Error().Msg("error") 17 | 18 | logger.AssertCount(1) 19 | } 20 | 21 | func TestLogger_AssertEntry_Succeeds(t *testing.T) { 22 | logger := test.NewLogger(t) 23 | defer logger.Dispose() 24 | 25 | log.Error().Msg("error") 26 | 27 | logger.AssertEntryExists(test.Entry{Message: "error", Level: zerolog.ErrorLevel}) 28 | } 29 | 30 | func TestLogger_AssertCount_Fails(t *testing.T) { 31 | fake := &fakeTesting{} 32 | logger := test.NewLogger(fake) 33 | defer logger.Dispose() 34 | 35 | log.Error().Msg("error") 36 | 37 | logger.AssertCount(2) 38 | assert.True(t, fake.hasErrors) 39 | } 40 | 41 | func TestLogger_AssertEntry_Fails_wrongLevel(t *testing.T) { 42 | fake := &fakeTesting{} 43 | logger := test.NewLogger(fake) 44 | defer logger.Dispose() 45 | 46 | log.Error().Msg("error") 47 | 48 | logger.AssertEntryExists(test.Entry{Message: "error", Level: zerolog.InfoLevel}) 49 | 50 | assert.True(t, fake.hasErrors) 51 | } 52 | 53 | func TestLogger_AssertEntry_Fails_wrongMessage(t *testing.T) { 54 | fake := &fakeTesting{} 55 | logger := test.NewLogger(fake) 56 | defer logger.Dispose() 57 | 58 | log.Error().Msg("error") 59 | 60 | logger.AssertEntryExists(test.Entry{Message: "info", Level: zerolog.ErrorLevel}) 61 | 62 | assert.True(t, fake.hasErrors) 63 | } 64 | -------------------------------------------------------------------------------- /test/time.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/traggo/server/model" 7 | ) 8 | 9 | // Time parses a time panics if not valid 10 | func Time(value string) time.Time { 11 | parse, firstErr := time.ParseInLocation(time.RFC3339, value, time.UTC) 12 | if firstErr != nil { 13 | var err error 14 | parse, err = time.ParseInLocation(time.RFC3339Nano, value, time.UTC) 15 | if err != nil { 16 | panic(firstErr) 17 | } 18 | } 19 | return parse 20 | } 21 | 22 | func timeWithCustomTZ(value string) time.Time { 23 | parse, err := time.Parse(time.RFC3339, value) 24 | if err != nil { 25 | panic(err) 26 | } 27 | _, offset := parse.Zone() 28 | return parse.In(time.FixedZone("unknown", offset)) 29 | } 30 | 31 | // TimeP parses a time panics if not valid 32 | func TimeP(value string) *time.Time { 33 | t := Time(value) 34 | return &t 35 | } 36 | 37 | // ModelTimeUTC parses a model.Time in utc time. panics if not valid 38 | func ModelTimeUTC(value string) model.Time { 39 | return model.Time(Time(value)) 40 | } 41 | 42 | // ModelTime parses a model.Time panics if not valid 43 | func ModelTime(value string) model.Time { 44 | return model.Time(timeWithCustomTZ(value)) 45 | } 46 | 47 | // ModelTimeP parses a model.Time panics if not valid 48 | func ModelTimeP(value string) *model.Time { 49 | modelTime := ModelTime(value) 50 | return &modelTime 51 | } 52 | -------------------------------------------------------------------------------- /test/time_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTime_panics(t *testing.T) { 10 | assert.Panics(t, func() { 11 | Time("asds") 12 | }) 13 | } 14 | 15 | func TestTime_succeeds(t *testing.T) { 16 | Time("2009-06-30T18:30:00+02:00") 17 | } 18 | 19 | func TestModelTime_panics(t *testing.T) { 20 | assert.Panics(t, func() { 21 | ModelTime("asds") 22 | }) 23 | } 24 | 25 | func TestModelTime_succeeds(t *testing.T) { 26 | ModelTime("2009-06-30T18:30:00+02:00") 27 | } 28 | 29 | func TestTimeP_panics(t *testing.T) { 30 | assert.Panics(t, func() { 31 | TimeP("asds") 32 | }) 33 | } 34 | 35 | func TestTimeP_succeeds(t *testing.T) { 36 | TimeP("2009-06-30T18:30:00+02:00") 37 | } 38 | 39 | func TestModelTimeP_panics(t *testing.T) { 40 | assert.Panics(t, func() { 41 | ModelTimeP("asds") 42 | }) 43 | } 44 | 45 | func TestModelTimeP_succeeds(t *testing.T) { 46 | ModelTimeP("2009-06-30T18:30:00+02:00") 47 | } 48 | 49 | func TestModelTimeUTC_succeeds(t *testing.T) { 50 | ModelTimeUTC("2009-06-30T18:30:00+02:00") 51 | } 52 | -------------------------------------------------------------------------------- /time/interval.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "github.com/traggo/server/generated/gqlmodel" 5 | "github.com/traggo/server/model" 6 | ) 7 | 8 | // InternalInterval converts gqlmodel to internal 9 | func InternalInterval(interval gqlmodel.StatsInterval) model.Interval { 10 | switch interval { 11 | case gqlmodel.StatsIntervalHourly: 12 | return model.IntervalHourly 13 | case gqlmodel.StatsIntervalDaily: 14 | return model.IntervalDaily 15 | case gqlmodel.StatsIntervalWeekly: 16 | return model.IntervalWeekly 17 | case gqlmodel.StatsIntervalMonthly: 18 | return model.IntervalMonthly 19 | case gqlmodel.StatsIntervalYearly: 20 | return model.IntervalYearly 21 | case gqlmodel.StatsIntervalSingle: 22 | return model.IntervalSingle 23 | default: 24 | panic("unknown interval type " + interval) 25 | } 26 | } 27 | 28 | // ExternalInterval converts internal to gqlmodel 29 | func ExternalInterval(interval model.Interval) gqlmodel.StatsInterval { 30 | switch interval { 31 | case model.IntervalHourly: 32 | return gqlmodel.StatsIntervalHourly 33 | case model.IntervalDaily: 34 | return gqlmodel.StatsIntervalDaily 35 | case model.IntervalWeekly: 36 | return gqlmodel.StatsIntervalWeekly 37 | case model.IntervalMonthly: 38 | return gqlmodel.StatsIntervalMonthly 39 | case model.IntervalYearly: 40 | return gqlmodel.StatsIntervalYearly 41 | case model.IntervalSingle: 42 | return gqlmodel.StatsIntervalSingle 43 | default: 44 | panic("unknown interval type " + interval) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /time/interval_test.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | func TestInterval(t *testing.T) { 12 | for _, inter := range []model.Interval{ 13 | model.IntervalSingle, model.IntervalHourly, model.IntervalDaily, model.IntervalWeekly, model.IntervalMonthly, model.IntervalYearly, 14 | } { 15 | assert.Equal(t, inter, InternalInterval(ExternalInterval(inter))) 16 | } 17 | assert.Panics(t, func() { 18 | InternalInterval(gqlmodel.StatsInterval("meh")) 19 | }) 20 | assert.Panics(t, func() { 21 | ExternalInterval(model.Interval("meh")) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /time/parse.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/jmattheis/go-timemath" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | const nowKey = "now" 12 | 13 | // RelativeRange represents a relative range. 14 | type RelativeRange struct { 15 | From string 16 | To string 17 | } 18 | 19 | // StaticRange represents a concrete range. 20 | type StaticRange struct { 21 | From time.Time 22 | To time.Time 23 | } 24 | 25 | // ParseRange parses a range and converts it to static ranges. 26 | func ParseRange(now time.Time, r RelativeRange, interval model.Interval, startOf, endOf time.Weekday) ([]StaticRange, error) { 27 | from, err := ParseTime(now, r.From, true, startOf) 28 | if err != nil { 29 | return nil, fmt.Errorf("range from: %s", err) 30 | } 31 | to, err := ParseTime(now, r.To, false, endOf) 32 | if err != nil { 33 | return nil, fmt.Errorf("range to: %s", err) 34 | } 35 | 36 | return ranges(from, to, interval), nil 37 | } 38 | 39 | // Validate tries to parse the input and only returns the error. 40 | func Validate(value string) error { 41 | _, err := ParseTime(time.Now(), value, true, time.Monday) 42 | return err 43 | } 44 | 45 | // ParseTime parses time. 46 | func ParseTime(now time.Time, value string, startOf bool, weekday time.Weekday) (time.Time, error) { 47 | parse, err := time.Parse(time.RFC3339, value) 48 | if err == nil { 49 | return parse, nil 50 | } 51 | 52 | return timemath.Parse(now, value, startOf, weekday) 53 | } 54 | -------------------------------------------------------------------------------- /time/range.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jmattheis/go-timemath" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | func ranges(from time.Time, to time.Time, interval model.Interval) []StaticRange { 11 | switch interval { 12 | case model.IntervalSingle: 13 | return []StaticRange{{From: from, To: to}} 14 | case model.IntervalHourly: 15 | return rangeForUnit(from, to, timemath.Hour) 16 | case model.IntervalDaily: 17 | return rangeForUnit(from, to, timemath.Day) 18 | case model.IntervalWeekly: 19 | return rangeForUnit(from, to, timemath.Week) 20 | case model.IntervalMonthly: 21 | return rangeForUnit(from, to, timemath.Month) 22 | case model.IntervalYearly: 23 | return rangeForUnit(from, to, timemath.Year) 24 | default: 25 | panic("unknown interval type") 26 | } 27 | } 28 | 29 | func rangeForUnit(from time.Time, to time.Time, u timemath.Unit) []StaticRange { 30 | var result []StaticRange 31 | newFrom := from 32 | for newFrom.Before(to) { 33 | newTo := timemath.Second.Subtract(u.Add(newFrom, 1), 1) 34 | if newTo.After(to) { 35 | newTo = to 36 | } 37 | result = append(result, StaticRange{From: newFrom, To: newTo}) 38 | newFrom = timemath.Second.Add(newTo, 1) 39 | } 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /timespan/convert.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | func timespanToInternal(userID int, start model.Time, end *model.Time, tags []*gqlmodel.InputTimeSpanTag, note string) (model.TimeSpan, error) { 12 | _, offset := start.Time().Zone() 13 | span := model.TimeSpan{ 14 | StartUserTime: start.OmitTimeZone(), 15 | StartUTC: start.UTC(), 16 | UserID: userID, 17 | Tags: tagsToInternal(tags), 18 | OffsetUTC: offset, 19 | Note: note, 20 | } 21 | 22 | if end != nil { 23 | if start.Time().After(end.Time()) { 24 | return span, fmt.Errorf("start must be before end") 25 | } 26 | endUser := end.OmitTimeZone() 27 | span.EndUserTime = &endUser 28 | endUTC := end.UTC() 29 | span.EndUTC = &endUTC 30 | } 31 | 32 | return span, nil 33 | } 34 | 35 | func timeSpanToExternal(span model.TimeSpan) *gqlmodel.TimeSpan { 36 | location := time.FixedZone("unknown", span.OffsetUTC) 37 | 38 | result := gqlmodel.TimeSpan{ 39 | Start: model.Time(span.StartUTC.In(location)), 40 | End: nil, 41 | ID: span.ID, 42 | Tags: tagsToExternal(span.Tags), 43 | Note: span.Note, 44 | } 45 | if span.EndUTC != nil && !span.EndUTC.IsZero() { 46 | end := *span.EndUTC 47 | endModel := model.Time(end.In(location)) 48 | result.End = &endModel 49 | } 50 | 51 | return &result 52 | } 53 | 54 | func tagsToExternal(tags []model.TimeSpanTag) []*gqlmodel.TimeSpanTag { 55 | result := []*gqlmodel.TimeSpanTag{} 56 | for _, tag := range tags { 57 | result = append(result, &gqlmodel.TimeSpanTag{ 58 | Key: tag.Key, 59 | Value: tag.StringValue, 60 | }) 61 | } 62 | return result 63 | } 64 | 65 | func tagsToInternal(gqls []*gqlmodel.InputTimeSpanTag) []model.TimeSpanTag { 66 | result := make([]model.TimeSpanTag, 0) 67 | for _, tag := range gqls { 68 | result = append(result, tagToInternal(*tag)) 69 | } 70 | return result 71 | } 72 | 73 | func tagToInternal(gqls gqlmodel.InputTimeSpanTag) model.TimeSpanTag { 74 | return model.TimeSpanTag{ 75 | Key: gqls.Key, 76 | StringValue: gqls.Value, 77 | } 78 | } 79 | 80 | func tagsToInputTag(tags []model.TimeSpanTag) []*gqlmodel.InputTimeSpanTag { 81 | result := make([]*gqlmodel.InputTimeSpanTag, 0) 82 | for _, tag := range tags { 83 | result = append(result, &gqlmodel.InputTimeSpanTag{ 84 | Key: tag.Key, 85 | Value: tag.StringValue, 86 | }) 87 | } 88 | return result 89 | } 90 | -------------------------------------------------------------------------------- /timespan/copy.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // CopyTimeSpan copies a time span. 13 | func (r *ResolverForTimeSpan) CopyTimeSpan(ctx context.Context, id int, start model.Time, end *model.Time) (*gqlmodel.TimeSpan, error) { 14 | old := &model.TimeSpan{ID: id} 15 | 16 | if r.DB.Preload("Tags").Where("user_id = ?", auth.GetUser(ctx).ID).Find(old).RecordNotFound() { 17 | return nil, fmt.Errorf("time span with id %d does not exist", id) 18 | } 19 | 20 | return r.CreateTimeSpan(ctx, start, end, tagsToInputTag(old.Tags), old.Note) 21 | } 22 | -------------------------------------------------------------------------------- /timespan/create.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | // CreateTimeSpan creates a time span 12 | func (r *ResolverForTimeSpan) CreateTimeSpan(ctx context.Context, start model.Time, end *model.Time, tags []*gqlmodel.InputTimeSpanTag, note string) (*gqlmodel.TimeSpan, error) { 13 | timeSpan, err := timespanToInternal(auth.GetUser(ctx).ID, start, end, tags, note) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | if err := tagsExist(r.DB, auth.GetUser(ctx).ID, timeSpan.Tags); err != nil { 19 | return nil, err 20 | } 21 | 22 | r.DB.Create(&timeSpan) 23 | 24 | external := timeSpanToExternal(timeSpan) 25 | return external, nil 26 | } 27 | -------------------------------------------------------------------------------- /timespan/get.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // TimeSpans returns all time spans for a user 13 | func (r *ResolverForTimeSpan) TimeSpans(ctx context.Context, fromInclusive *model.Time, toInclusive *model.Time, cursor *gqlmodel.InputCursor) (*gqlmodel.PagedTimeSpans, error) { 14 | user := auth.GetUser(ctx) 15 | cursor = normalize(cursor) 16 | 17 | if cursor.StartID == nil { 18 | var s model.TimeSpan 19 | if err := r.DB.Model(new(model.TimeSpan)).Select("max(id) as id").Find(&s).Error; err != nil { 20 | return nil, err 21 | } 22 | cursor.StartID = &s.ID 23 | } 24 | 25 | call := r.DB.Preload("Tags").Where("user_id = ?", user.ID).Not("end_user_time is NULL").Order("start_user_time DESC").Limit(*cursor.PageSize) 26 | if cursor.Offset != nil && cursor.StartID != nil { 27 | call = call.Where("id <= ?", *cursor.StartID).Offset(*cursor.Offset) 28 | } 29 | if fromInclusive != nil { 30 | if toInclusive != nil { 31 | if fromInclusive.Time().After(toInclusive.Time()) { 32 | return nil, errors.New("fromInclusive must be before toInclusive") 33 | } 34 | 35 | call = call.Where("start_user_time <= ? AND end_user_time >= ?", toInclusive.OmitTimeZone(), fromInclusive.OmitTimeZone()) 36 | } else { 37 | call = call.Where("start_user_time >= ? OR end_user_time >= ?", fromInclusive.OmitTimeZone(), fromInclusive.OmitTimeZone()) 38 | } 39 | } else if toInclusive != nil { 40 | call = call.Where("end_user_time <= ? OR start_user_time <= ?", toInclusive.OmitTimeZone(), toInclusive.OmitTimeZone()) 41 | } 42 | 43 | var timeSpans []model.TimeSpan 44 | call.Find(&timeSpans) 45 | 46 | var result []*gqlmodel.TimeSpan 47 | for _, span := range timeSpans { 48 | result = append(result, timeSpanToExternal(span)) 49 | } 50 | return &gqlmodel.PagedTimeSpans{ 51 | TimeSpans: result, 52 | Cursor: &gqlmodel.Cursor{ 53 | HasMore: len(timeSpans) != 0 && *cursor.Offset%*cursor.PageSize == 0, 54 | Offset: *cursor.Offset + len(timeSpans), 55 | StartID: *cursor.StartID, 56 | PageSize: *cursor.PageSize}, 57 | }, nil 58 | } 59 | 60 | func normalize(cursor *gqlmodel.InputCursor) *gqlmodel.InputCursor { 61 | if cursor == nil { 62 | cursor = &gqlmodel.InputCursor{} 63 | } 64 | 65 | maxPageSize := 100 66 | if cursor.PageSize == nil || maxPageSize < *cursor.PageSize { 67 | cursor.PageSize = &maxPageSize 68 | } 69 | 70 | if cursor.Offset == nil { 71 | zero := 0 72 | cursor.Offset = &zero 73 | } 74 | 75 | return cursor 76 | } 77 | -------------------------------------------------------------------------------- /timespan/remove.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // RemoveTimeSpan removes a timespan. 13 | func (r *ResolverForTimeSpan) RemoveTimeSpan(ctx context.Context, id int) (*gqlmodel.TimeSpan, error) { 14 | timeSpan := model.TimeSpan{ID: id} 15 | if r.DB.Preload("Tags").Where("user_id = ?", auth.GetUser(ctx).ID).Find(&timeSpan).RecordNotFound() { 16 | return nil, fmt.Errorf("timespan with id %d does not exist", timeSpan.ID) 17 | } 18 | 19 | remove := r.DB.Where(&model.TimeSpan{ID: id}).Delete(new(model.TimeSpan)) 20 | 21 | external := timeSpanToExternal(timeSpan) 22 | return external, remove.Error 23 | } 24 | -------------------------------------------------------------------------------- /timespan/remove_test.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/traggo/server/generated/gqlmodel" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | "github.com/traggo/server/test/fake" 12 | ) 13 | 14 | func TestRemoveTimeSpan_succeeds_removesTimeSpan(t *testing.T) { 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | user := db.User(3) 18 | ts := user.TimeSpan("2019-06-11T18:00:00Z", "2019-06-11T18:00:00Z") 19 | ts.Tag("hello", "world") 20 | 21 | resolver := ResolverForTimeSpan{DB: db.DB} 22 | actual, err := resolver.RemoveTimeSpan(fake.User(3), ts.TimeSpan.ID) 23 | require.NoError(t, err) 24 | expected := &gqlmodel.TimeSpan{ 25 | ID: ts.TimeSpan.ID, 26 | Start: test.ModelTime("2019-06-11T18:00:00Z"), 27 | End: test.ModelTimeP("2019-06-11T18:00:00Z"), 28 | Tags: []*gqlmodel.TimeSpanTag{ 29 | {Key: "hello", Value: "world"}, 30 | }, 31 | } 32 | require.Equal(t, expected, actual) 33 | assertTimeSpanCount(t, db, 0) 34 | } 35 | 36 | func TestRemoveTimeSpan_succeeds_removesTags(t *testing.T) { 37 | db := test.InMemoryDB(t) 38 | defer db.Close() 39 | user := db.User(3) 40 | ts := user.TimeSpan("2019-06-11T18:00:00Z", "2019-06-11T18:00:00Z") 41 | ts.Tag("hello", "world") 42 | 43 | resolver := ResolverForTimeSpan{DB: db.DB} 44 | _, err := resolver.RemoveTimeSpan(fake.User(3), ts.TimeSpan.ID) 45 | require.NoError(t, err) 46 | 47 | ts.AssertHasTag("hello", "world", false) 48 | } 49 | 50 | func TestRemoveTimeSpan_fails_notExistingTimeSpan(t *testing.T) { 51 | db := test.InMemoryDB(t) 52 | defer db.Close() 53 | db.User(3) 54 | 55 | resolver := ResolverForTimeSpan{DB: db.DB} 56 | _, err := resolver.RemoveTimeSpan(fake.User(3), 5) 57 | require.EqualError(t, err, "timespan with id 5 does not exist") 58 | } 59 | 60 | func TestRemoveTimeSpan_fails_noPermission(t *testing.T) { 61 | db := test.InMemoryDB(t) 62 | defer db.Close() 63 | db.User(3) 64 | db.User(5) 65 | db.Create(&model.TimeSpan{ 66 | StartUserTime: test.Time("2019-06-11T18:00:00Z"), 67 | StartUTC: test.Time("2019-06-11T18:00:00Z"), 68 | EndUserTime: nil, 69 | EndUTC: nil, 70 | OffsetUTC: 0, 71 | ID: 1, 72 | UserID: 3, 73 | }) 74 | 75 | resolver := ResolverForTimeSpan{DB: db.DB} 76 | _, err := resolver.RemoveTimeSpan(fake.User(5), 1) 77 | require.EqualError(t, err, "timespan with id 1 does not exist") 78 | } 79 | -------------------------------------------------------------------------------- /timespan/replace_tags.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | // ReplaceTimeSpanTags replaces time span tags. 12 | func (r *ResolverForTimeSpan) ReplaceTimeSpanTags(ctx context.Context, fromExternal gqlmodel.InputTimeSpanTag, toExternal gqlmodel.InputTimeSpanTag, opt gqlmodel.InputReplaceOptions) (*bool, error) { 13 | userID := auth.GetUser(ctx).ID 14 | 15 | from := tagToInternal(fromExternal) 16 | to := tagToInternal(toExternal) 17 | 18 | if err := tagsExist(r.DB, userID, []model.TimeSpanTag{from}); err != nil { 19 | return nil, err 20 | } 21 | if err := tagsExist(r.DB, userID, []model.TimeSpanTag{to}); err != nil { 22 | return nil, err 23 | } 24 | 25 | tx := r.DB.Begin() 26 | 27 | hasToKey := tx.Table("time_span_tags as innertst"). 28 | Where("innertst.key = ?", to.Key). 29 | Where("innerts.id = innertst.time_span_id"). 30 | SubQuery() 31 | 32 | if opt.Override == gqlmodel.OverrideModeOverride { 33 | timeSpanIdsWithExistingToKey := tx.Table("time_spans as innerts"). 34 | Select("id"). 35 | Where("innerts.user_id = ?", userID). 36 | Where("EXISTS ?", hasToKey). 37 | SubQuery() 38 | 39 | if err := tx.Where("time_span_id in ?", timeSpanIdsWithExistingToKey). 40 | Where(&model.TimeSpanTag{Key: to.Key}). 41 | Delete(new(model.TimeSpanTag)).Error; err != nil { 42 | tx.Rollback() 43 | return nil, err 44 | } 45 | } 46 | 47 | timeSpansIdsOfUser := tx.Table("time_spans as innerts"). 48 | Select("id"). 49 | Where("innerts.user_id = ?", userID). 50 | Where("NOT(EXISTS ?)", hasToKey). 51 | SubQuery() 52 | 53 | if update := tx.Model(&model.TimeSpanTag{}). 54 | Where("time_span_id in ?", timeSpansIdsOfUser). 55 | Where(from). 56 | Updates(to); update.Error != nil { 57 | tx.Rollback() 58 | return nil, update.Error 59 | } 60 | 61 | if opt.Override == gqlmodel.OverrideModeDiscard { 62 | if err := tx.Where(&from).Delete(new(model.TimeSpanTag)).Error; err != nil { 63 | tx.Rollback() 64 | return nil, err 65 | } 66 | } 67 | 68 | commit := tx.Commit() 69 | 70 | return nil, commit.Error 71 | } 72 | -------------------------------------------------------------------------------- /timespan/stop.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // StopTimeSpan sets an end date to an existing time span. 13 | func (r *ResolverForTimeSpan) StopTimeSpan(ctx context.Context, id int, end model.Time) (*gqlmodel.TimeSpan, error) { 14 | old := &model.TimeSpan{ID: id} 15 | 16 | if r.DB.Preload("Tags").Where("user_id = ?", auth.GetUser(ctx).ID).Find(old).RecordNotFound() { 17 | return nil, fmt.Errorf("time span with id %d does not exist", id) 18 | } 19 | 20 | if old.EndUTC != nil { 21 | return nil, fmt.Errorf("timespan with id %d has already an end date", id) 22 | } 23 | 24 | utc := end.UTC() 25 | old.EndUTC = &utc 26 | userTime := end.OmitTimeZone() 27 | old.EndUserTime = &userTime 28 | r.DB.Where("time_span_id = ?", old.ID).Delete(new(model.TimeSpanTag)) 29 | r.DB.Save(old) 30 | 31 | external := timeSpanToExternal(*old) 32 | return external, nil 33 | } 34 | -------------------------------------------------------------------------------- /timespan/suggestvalue.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | // SuggestTagValue suggests a tag value. 12 | func (r *ResolverForTimeSpan) SuggestTagValue(ctx context.Context, key string, query string) ([]string, error) { 13 | var suggestions []model.TimeSpanTag 14 | find := r.DB. 15 | Select("DISTINCT time_span_tags.string_value"). 16 | Joins("JOIN time_spans on time_spans.id = time_span_tags.time_span_id"). 17 | Where("user_id = ?", auth.GetUser(ctx).ID). 18 | Where("key = ?", key).Where("LOWER(string_value) LIKE LOWER(?)", "%"+query+"%"). 19 | Limit(10). 20 | Find(&suggestions) 21 | var result []string 22 | for _, value := range suggestions { 23 | result = append(result, value.StringValue) 24 | } 25 | 26 | return result, find.Error 27 | } 28 | -------------------------------------------------------------------------------- /timespan/suggestvalue_test.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/test" 8 | "github.com/traggo/server/test/fake" 9 | ) 10 | 11 | const date = "2019-06-10T18:30:00Z" 12 | 13 | func TestGQL_SuggestTagValue(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | user := db.User(1) 17 | user.TimeSpan(date, date).Tag("proj", "gotify").Tag("issue", "3") 18 | user.TimeSpan(date, date).Tag("proj", "traggo").Tag("issue", "3") 19 | user.TimeSpan(date, date).Tag("proj", "traggo").Tag("issue", "3") 20 | user.TimeSpan(date, date).Tag("proj", "meh").Tag("issue", "3") 21 | other := db.User(2) 22 | other.TimeSpan(date, date).Tag("proj", "secret").Tag("issue", "3") 23 | resolver := ResolverForTimeSpan{DB: db.DB} 24 | 25 | tags, err := resolver.SuggestTagValue(fake.User(1), "proj", "") 26 | 27 | require.Nil(t, err) 28 | expected := []string{"gotify", "traggo", "meh"} 29 | require.Equal(t, expected, tags) 30 | 31 | tags, err = resolver.SuggestTagValue(fake.User(1), "proj", "uff") 32 | 33 | require.Nil(t, err) 34 | require.Empty(t, tags) 35 | } 36 | -------------------------------------------------------------------------------- /timespan/tagcheck.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/traggo/server/model" 8 | ) 9 | 10 | func tagsExist(db *gorm.DB, userID int, tags []model.TimeSpanTag) error { 11 | existingTags := make(map[string]struct{}) 12 | 13 | for _, tag := range tags { 14 | if _, ok := existingTags[tag.Key]; ok { 15 | return fmt.Errorf("tag '%s' is present multiple times", tag.Key) 16 | } 17 | 18 | if db.Where("key = ?", tag.Key).Where("user_id = ?", userID).Find(new(model.TagDefinition)).RecordNotFound() { 19 | return fmt.Errorf("tag '%s' does not exist", tag.Key) 20 | } 21 | 22 | existingTags[tag.Key] = struct{}{} 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /timespan/timers.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/traggo/server/auth" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | // Timers returns all running timers for a user 12 | func (r *ResolverForTimeSpan) Timers(ctx context.Context) ([]*gqlmodel.TimeSpan, error) { 13 | user := auth.GetUser(ctx) 14 | 15 | var timeSpans []model.TimeSpan 16 | r.DB.Preload("Tags"). 17 | Where("user_id = ?", user.ID). 18 | Where("end_user_time is null"). 19 | Order("start_user_time DESC"). 20 | Find(&timeSpans) 21 | 22 | result := []*gqlmodel.TimeSpan{} 23 | for _, span := range timeSpans { 24 | result = append(result, timeSpanToExternal(span)) 25 | } 26 | return result, nil 27 | } 28 | -------------------------------------------------------------------------------- /timespan/timers_test.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/test" 9 | "github.com/traggo/server/test/fake" 10 | ) 11 | 12 | func TestTimers(t *testing.T) { 13 | db := test.InMemoryDB(t) 14 | db.User(5) 15 | db.Create(timeSpan1) 16 | db.Create(runningTimeSpan) 17 | defer db.Close() 18 | 19 | resolver := ResolverForTimeSpan{DB: db.DB} 20 | timeSpans, err := resolver.Timers(fake.User(5)) 21 | require.NoError(t, err) 22 | 23 | expected := []*gqlmodel.TimeSpan{&modelRunningTimeSpan} 24 | require.Equal(t, expected, timeSpans) 25 | } 26 | -------------------------------------------------------------------------------- /timespan/timespanresolver.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | // ResolverForTimeSpan resolves time span specific things. 6 | type ResolverForTimeSpan struct { 7 | DB *gorm.DB 8 | } 9 | -------------------------------------------------------------------------------- /timespan/update.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/traggo/server/auth" 9 | "github.com/traggo/server/generated/gqlmodel" 10 | "github.com/traggo/server/model" 11 | ) 12 | 13 | // UpdateTimeSpan update a time span 14 | func (r *ResolverForTimeSpan) UpdateTimeSpan(ctx context.Context, id int, start model.Time, end *model.Time, tags []*gqlmodel.InputTimeSpanTag, oldStart *model.Time, note string) (*gqlmodel.TimeSpan, error) { 15 | timeSpan, err := timespanToInternal(auth.GetUser(ctx).ID, start, end, tags, note) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | oldTimeSpan := model.TimeSpan{ID: id} 21 | if r.DB.Where("user_id = ?", auth.GetUser(ctx).ID).Find(&oldTimeSpan).RecordNotFound() { 22 | return nil, fmt.Errorf("time span with id %d does not exist", id) 23 | } 24 | 25 | if err := tagsExist(r.DB, auth.GetUser(ctx).ID, timeSpan.Tags); err != nil { 26 | return nil, err 27 | } 28 | 29 | timeSpan.ID = id 30 | 31 | r.DB.Where("time_span_id = ?", timeSpan.ID).Delete(new(model.TimeSpanTag)) 32 | r.DB.Save(&timeSpan) 33 | 34 | external := timeSpanToExternal(timeSpan) 35 | if oldStart == nil { 36 | location := time.FixedZone("unknown", oldTimeSpan.OffsetUTC) 37 | old := model.Time(oldTimeSpan.StartUTC.In(location)) 38 | oldStart = &old 39 | } 40 | external.OldStart = oldStart 41 | return external, nil 42 | } 43 | -------------------------------------------------------------------------------- /timespan/utils_test.go: -------------------------------------------------------------------------------- 1 | package timespan 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/test" 9 | ) 10 | 11 | func assertTimeSpanExist(t *testing.T, db *test.Database, expected model.TimeSpan) { 12 | found := new(model.TimeSpan) 13 | find := db.Preload("Tags").Where("user_id = ?", expected.UserID).Where("id = ?", expected.ID).Find(found) 14 | require.Nil(t, find.Error) 15 | require.NotNil(t, found) 16 | require.Equal(t, expected, *found) 17 | } 18 | 19 | func assertTimeSpanCount(t *testing.T, db *test.Database, expected int) { 20 | count := new(int) 21 | db.Model(new(model.TimeSpan)).Count(count) 22 | require.Equal(t, expected, *count) 23 | } 24 | -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | __generated__ -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 130, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": true, 10 | "arrowParens": "always" 11 | } -------------------------------------------------------------------------------- /ui/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/favicon-192x192.png -------------------------------------------------------------------------------- /ui/public/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/favicon-256x256.png -------------------------------------------------------------------------------- /ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/traggo/server/4aa48b385abb1728e46881964ce90a420a25f590/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Traggo 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Traggo", 3 | "short_name": "Traggo", 4 | "description": "Tag-based time tracking", 5 | "theme_color": "#9bd7fe", 6 | "background_color": "#3f51b5", 7 | "display": "standalone", 8 | "scope": "./", 9 | "start_url": "./", 10 | "icons": [ 11 | { 12 | "src": "/favicon-16x16.png", 13 | "sizes": "16x16", 14 | "type": "image/png" 15 | }, { 16 | "src": "/favicon-32x32.png", 17 | "sizes": "32x32", 18 | "type": "image/png" 19 | }, { 20 | "src": "/favicon-192x192.png", 21 | "sizes": "192x192", 22 | "type": "image/png" 23 | }, { 24 | "src": "/favicon-256x256.png", 25 | "sizes": "256x256", 26 | "type": "image/png" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /ui/serve.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "net/http" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | //go:embed build 15 | var uiDir embed.FS 16 | var buildDir, _ = fs.Sub(uiDir, "build") 17 | 18 | // Register registers the ui on the root path. 19 | func Register(r *mux.Router) { 20 | r.Handle("/", serveFile("index.html", "text/html")) 21 | r.Handle("/index.html", serveFile("index.html", "text/html")) 22 | r.Handle("/manifest.json", serveFile("manifest.json", "application/json")) 23 | r.Handle("/service-worker.js", serveFile("service-worker.js", "text/javascript")) 24 | r.Handle("/asset-manifest.json", serveFile("asset-manifest.json", "application/json")) 25 | r.Handle("/static/{type}/{resource}", http.FileServer(http.FS(buildDir))) 26 | 27 | r.Handle("/favicon.ico", serveFile("favicon.ico", "image/x-icon")) 28 | for _, size := range []string{"16x16", "32x32", "192x192", "256x256"} { 29 | fileName := fmt.Sprintf("favicon-%s.png", size) 30 | r.Handle("/"+fileName, serveFile(fileName, "image/png")) 31 | } 32 | } 33 | 34 | func serveFile(name, contentType string) http.HandlerFunc { 35 | file, err := buildDir.Open(name) 36 | if err != nil { 37 | log.Panic().Err(err).Msgf("could not find %s", file) 38 | } 39 | defer file.Close() 40 | content, err := io.ReadAll(file) 41 | if err != nil { 42 | log.Panic().Err(err).Msgf("could not read %s", file) 43 | } 44 | 45 | return func(writer http.ResponseWriter, reg *http.Request) { 46 | writer.Header().Set("Content-Type", contentType) 47 | _, _ = writer.Write(content) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/Root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import './global.css'; 3 | import 'react-resizable/css/styles.css'; 4 | import 'react-grid-layout/css/styles.css'; 5 | import 'typeface-roboto'; 6 | import {ThemeProvider} from './provider/ThemeProvider'; 7 | import {ApolloProvider} from './provider/ApolloProvider'; 8 | import {SnackbarProvider} from './provider/SnackbarProvider'; 9 | import {MuiPickersUtilsProvider} from '@material-ui/pickers'; 10 | import MomentUtils from '@date-io/moment'; 11 | import {Router} from './Router'; 12 | import {HashRouter} from 'react-router-dom'; 13 | import {BootUserSettings} from './provider/UserSettingsProvider'; 14 | 15 | export const Root = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /ui/src/common/Center.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const Center: React.FC = ({children}) => { 4 | return
{children}
; 5 | }; 6 | -------------------------------------------------------------------------------- /ui/src/common/CenteredSpinner.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from '@material-ui/core/CircularProgress'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import * as React from 'react'; 4 | 5 | export const CenteredSpinner = () => ( 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /ui/src/common/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogActions from '@material-ui/core/DialogActions'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogContentText from '@material-ui/core/DialogContentText'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import React from 'react'; 8 | 9 | interface Props { 10 | title: string; 11 | fClose: () => void; 12 | fOnSubmit: () => void; 13 | } 14 | 15 | export const ConfirmDialog: React.FC = ({children, title, fClose, fOnSubmit}) => { 16 | const submitAndClose = () => { 17 | fOnSubmit(); 18 | fClose(); 19 | }; 20 | return ( 21 | 22 | {title} 23 | 24 | {children} 25 | 26 | 27 | 30 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /ui/src/common/DefaultPaper.tsx: -------------------------------------------------------------------------------- 1 | import Paper from '@material-ui/core/Paper'; 2 | import {makeStyles} from '@material-ui/core/styles'; 3 | import * as React from 'react'; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | ...theme.mixins.gutters(), 8 | paddingTop: theme.spacing(4), 9 | paddingBottom: theme.spacing(3), 10 | textAlign: 'center', 11 | maxWidth: 400, 12 | borderTop: `5px solid ${theme.palette.primary.main}`, 13 | }, 14 | })); 15 | export const DefaultPaper: React.FC = ({children}) => { 16 | const classes = useStyles(); 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/common/Fade.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {CSSTransition} from 'react-transition-group'; 3 | 4 | const duration = 100; 5 | 6 | const defaultStyle = { 7 | transition: `all ${duration}ms ease-in-out`, 8 | opacity: 0, 9 | }; 10 | 11 | export const Fade: React.FC<{fullyVisible: boolean; opacity?: number}> = ({fullyVisible, children, opacity = 0.4}) => { 12 | const transitionStyles: Record = { 13 | entering: {opacity: 1}, 14 | entered: {opacity: 1}, 15 | exiting: {opacity}, 16 | exited: {opacity}, 17 | }; 18 | return ( 19 | 20 | {(state) => ( 21 |
26 | {children} 27 |
28 | )} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /ui/src/common/RelativeDateTimeSelector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {TextField} from '@material-ui/core'; 3 | import {parseRelativeTime} from '../utils/time'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import useTimeout from '@rooks/use-timeout'; 6 | 7 | interface RelativeDateTimeSelectorProps { 8 | value: string; 9 | onChange: (value: string, valid: boolean) => void; 10 | type: 'startOf' | 'endOf'; 11 | label?: string; 12 | disabled?: boolean; 13 | disableUnderline?: boolean; 14 | style?: React.CSSProperties; 15 | small?: boolean; 16 | } 17 | 18 | export const RelativeDateTimeSelector: React.FC = ({ 19 | value, 20 | onChange: setValue, 21 | type, 22 | style, 23 | label, 24 | small = false, 25 | disableUnderline = false, 26 | disabled = false, 27 | }) => { 28 | const [errVisible, setErrVisible] = React.useState(false); 29 | const [error, setError] = React.useState(''); 30 | const {start, stop} = useTimeout(() => setErrVisible(true), 200); 31 | 32 | const parsed = parseRelativeTime(value, type); 33 | return ( 34 | { 41 | const newValue = e.target.value; 42 | const result = parseRelativeTime(newValue, type); 43 | setErrVisible(false); 44 | stop(); 45 | if (!result.success) { 46 | setError(result.error); 47 | start(); 48 | } else { 49 | setError(''); 50 | } 51 | setValue(newValue, result.success); 52 | }} 53 | error={error !== ''} 54 | helperText={ 55 | small ? ( 56 | undefined 57 | ) : errVisible ? ( 58 | 59 | {error} 60 | 61 | ) : ( 62 | {!parsed.success ? '...' : parsed.value.format('llll')} 63 | ) 64 | } 65 | label={label} 66 | /> 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /ui/src/common/RelativeTime.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import * as React from 'react'; 3 | import {timeRunning} from '../timespan/timeutils'; 4 | import useInterval from '@rooks/use-interval'; 5 | 6 | export const RelativeToNow: React.FC<{from: moment.Moment}> = ({from}) => { 7 | const [now, setNow] = React.useState(moment()); 8 | 9 | useInterval( 10 | () => { 11 | setNow(moment()); 12 | }, 13 | 1000, 14 | true 15 | ); 16 | return ; 17 | }; 18 | 19 | export const RelativeTime: React.FC<{from: moment.Moment; to: moment.Moment}> = ({from, to}) => { 20 | return <>{timeRunning(from, to)}; 21 | }; 22 | -------------------------------------------------------------------------------- /ui/src/common/TagChip.tsx: -------------------------------------------------------------------------------- 1 | import Chip from '@material-ui/core/Chip'; 2 | import * as React from 'react'; 3 | // @ts-ignore 4 | import bestContrast from 'get-best-contrast-color'; 5 | 6 | export const TagChip = ({color, label}: {label: string; color: string}) => { 7 | const textColor = bestContrast(color, ['#fff', '#000']); 8 | return ( 9 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /ui/src/common/tsutil.ts: -------------------------------------------------------------------------------- 1 | export type Diff = ({[P in T]: P} & {[P in U]: never} & {[x: string]: never})[T]; 2 | export type Omit> = Pick, K>>; 3 | -------------------------------------------------------------------------------- /ui/src/dashboard/AddDashboardDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import Dialog from '@material-ui/core/Dialog'; 5 | import DialogActions from '@material-ui/core/DialogActions'; 6 | import DialogContent from '@material-ui/core/DialogContent'; 7 | import DialogTitle from '@material-ui/core/DialogTitle'; 8 | import {useMutation} from '@apollo/react-hooks'; 9 | import {useSnackbar} from 'notistack'; 10 | import {handleError} from '../utils/errors'; 11 | import * as gqlDashboard from '../gql/dashboard'; 12 | import {CreateDashboard, CreateDashboardVariables} from '../gql/__generated__/CreateDashboard'; 13 | 14 | interface AddTagDialogProps { 15 | open: boolean; 16 | close: () => void; 17 | } 18 | 19 | export const AddDashboardDialog: React.FC = ({close, open}) => { 20 | const [name, setName] = React.useState(''); 21 | const {enqueueSnackbar} = useSnackbar(); 22 | 23 | const [addUser] = useMutation(gqlDashboard.CreateDashboard, { 24 | refetchQueries: [{query: gqlDashboard.Dashboards}], 25 | }); 26 | const submit = (e: React.FormEvent) => { 27 | e.preventDefault(); 28 | addUser({variables: {name}}) 29 | .then(() => { 30 | enqueueSnackbar('Dashboard created', {variant: 'success'}); 31 | close(); 32 | }) 33 | .catch(handleError('Create Dashboard', enqueueSnackbar)); 34 | }; 35 | 36 | return ( 37 | 38 |
39 | Create Dashboard 40 | 41 | setName(e.target.value)} 50 | /> 51 | 52 | 53 | 56 | 59 | 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /ui/src/dashboard/Entry/DashboardPieChart.tsx: -------------------------------------------------------------------------------- 1 | import {Stats_stats_entries} from '../../gql/__generated__/Stats'; 2 | import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, TooltipProps} from 'recharts'; 3 | import * as React from 'react'; 4 | import {Colors} from './colors'; 5 | import {Typography} from '@material-ui/core'; 6 | import prettyMs from 'pretty-ms'; 7 | import Paper from '@material-ui/core/Paper'; 8 | 9 | interface DashboardPieChartProps { 10 | entries: Stats_stats_entries[]; 11 | } 12 | 13 | export const DashboardPieChart: React.FC = ({entries}) => { 14 | const total = entries.map((e) => e.timeSpendInSeconds).reduce((x, y) => x + y, 0); 15 | return ( 16 | 17 | 18 | { 22 | // tslint:disable-next-line:no-any 23 | return (entry.key + ':' + entry.value) as any; 24 | }} 25 | data={entries} 26 | labelLine={false} 27 | fill="#8884d8" 28 | legendType={'square'}> 29 | {entries.map((_, index) => ( 30 | 31 | ))} 32 | 33 | } /> 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | interface CustomTooltipProps extends TooltipProps { 41 | total: number; 42 | } 43 | 44 | const CustomTooltip = ({active, payload, total}: CustomTooltipProps) => { 45 | if (active && payload) { 46 | const first = payload[0]; 47 | return ( 48 | 49 | 50 | {first.payload.key}:{first.payload.value}: {prettyMs(first.payload.timeSpendInSeconds * 1000)} ( 51 | {total > 0 ? ((first.payload.timeSpendInSeconds / total) * 100).toFixed(2) : '0.00'}%) 52 | 53 | 54 | ); 55 | } 56 | 57 | return null; 58 | }; 59 | -------------------------------------------------------------------------------- /ui/src/dashboard/Entry/TagTooltip.tsx: -------------------------------------------------------------------------------- 1 | import {TooltipProps, TooltipPayload} from 'recharts'; 2 | import {FInterval} from './dateformat'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import {Typography} from '@material-ui/core'; 5 | import moment from 'moment-timezone'; 6 | import prettyMs from 'pretty-ms'; 7 | import * as React from 'react'; 8 | 9 | export const TagTooltip = ({active, payload, dateFormat, total}: TooltipProps & {dateFormat: FInterval; total: boolean}) => { 10 | if (active && payload) { 11 | const first = payload[0]; 12 | const start = dateFormat(moment(first.payload.start)); 13 | const end = dateFormat(moment(first.payload.end)); 14 | 15 | return ( 16 | 17 | {first && {start === end ? `${start}` : `${start} - ${end}`}} 18 | {payload.map((entry) => ( 19 | 20 | {entry.name}: {prettyMs((entry.payload.data[entry.name] as number) * 1000)} 21 | 22 | ))} 23 | {total ? ( 24 | 25 | Total: 26 | {prettyMs(sum(payload) * 1000)} 27 | 28 | ) : ( 29 | undefined 30 | )} 31 | 32 | ); 33 | } 34 | 35 | return null; 36 | }; 37 | 38 | const sum = (payload: readonly TooltipPayload[]): number => 39 | payload.reduce((acc, entry) => (acc + entry.payload.data[entry.name]) as number, 0); 40 | -------------------------------------------------------------------------------- /ui/src/dashboard/Entry/colors.ts: -------------------------------------------------------------------------------- 1 | export const Colors = ['#5DA5DA', '#60BD68', '#B2912F', '#F17CB0', '#FAA43A', '#F15854', '#DECF3F', '#B276B2', '#4D4D4D']; 2 | -------------------------------------------------------------------------------- /ui/src/dashboard/Entry/dateformat.ts: -------------------------------------------------------------------------------- 1 | import {StatsInterval} from '../../gql/__generated__/globalTypes'; 2 | import * as moment from 'moment-timezone'; 3 | import {expectNever} from '../../utils/never'; 4 | 5 | export type FInterval = (date: moment.Moment) => string; 6 | 7 | export const ofInterval = (interval: StatsInterval): FInterval => { 8 | switch (interval) { 9 | case StatsInterval.Weekly: 10 | case StatsInterval.Monthly: 11 | case StatsInterval.Yearly: 12 | return (d) => d.tz('utc').format('l'); 13 | case StatsInterval.Hourly: 14 | return (d) => d.tz('utc').format('lll'); 15 | case StatsInterval.Single: 16 | case StatsInterval.Daily: 17 | return (d) => d.tz('utc').format('dddd') + ', ' + d.format('l'); 18 | default: 19 | return expectNever(interval); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /ui/src/dashboard/Entry/unit.ts: -------------------------------------------------------------------------------- 1 | interface Unit { 2 | toUnit: (seconds: number) => number; 3 | short: string; 4 | name: string; 5 | } 6 | 7 | const Minutes: Unit = { 8 | toUnit: (s) => s / 60, 9 | name: 'minutes', 10 | short: 'm', 11 | }; 12 | const Hours: Unit = { 13 | toUnit: (s) => Minutes.toUnit(s) / 60, 14 | name: 'hours', 15 | short: 'h', 16 | }; 17 | const Days: Unit = { 18 | toUnit: (s) => Hours.toUnit(s) / 24, 19 | name: 'days', 20 | short: 'd', 21 | }; 22 | 23 | export const ofSeconds = (seconds: number): Unit => { 24 | if (seconds < 2 * 60 * 60) { 25 | return Minutes; 26 | } 27 | 28 | if (seconds < 30 * 60 * 60) { 29 | return Hours; 30 | } 31 | 32 | return Days; 33 | }; 34 | -------------------------------------------------------------------------------- /ui/src/devices/typeutils.ts: -------------------------------------------------------------------------------- 1 | import {DeviceType} from '../gql/__generated__/globalTypes'; 2 | import {expectNever} from '../utils/never'; 3 | 4 | export const deviceTypeToString = (type: DeviceType) => { 5 | switch (type) { 6 | case DeviceType.LongExpiry: 7 | return 'A month of inactivity'; 8 | case DeviceType.ShortExpiry: 9 | return 'An hour of inactivity'; 10 | case DeviceType.NoExpiry: 11 | return 'Never'; 12 | default: 13 | return expectNever(type); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /ui/src/global.css: -------------------------------------------------------------------------------- 1 | #root, 2 | body, 3 | html { 4 | height: 100%; 5 | } 6 | 7 | /* Allows the tag Chips to wrap lines */ 8 | .MuiChip-label { 9 | white-space: normal !important; 10 | } 11 | 12 | /* The wrapping box around the input and icon for the datetime picker */ 13 | .time-picker .MuiInputBase-root.MuiInput-root.MuiInputBase-formControl.MuiInput-formControl.MuiInputBase-adornedEnd { 14 | margin-top: 0; 15 | } 16 | 17 | /* The calendar icon for the datetime picker */ 18 | .time-picker 19 | .MuiInputBase-root.MuiInputBase-formControl.MuiInputBase-adornedEnd 20 | .MuiInputAdornment-root.MuiInputAdornment-positionEnd 21 | .MuiButtonBase-root.MuiIconButton-root { 22 | position: absolute; 23 | left: 0; 24 | } 25 | 26 | /* The label for the datetime picker */ 27 | .time-picker.MuiFormControl-root.MuiTextField-root .MuiFormLabel-root.MuiInputLabel-root.MuiInputLabel-formControl { 28 | margin-left: 48px; 29 | } 30 | 31 | /* The input box for the datetime picker */ 32 | .time-picker.MuiFormControl-root.MuiTextField-root .MuiInputBase-input.MuiInput-input.MuiInputBase-inputAdornedEnd { 33 | margin-left: 48px; 34 | /* margin-top: 8px; */ 35 | margin-top: 12px; 36 | min-width: calc(100% - 48px); 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/gql/device.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | 3 | export const Devices = gql` 4 | query Devices { 5 | devices { 6 | id 7 | name 8 | type 9 | createdAt 10 | activeAt 11 | } 12 | currentDevice { 13 | id 14 | } 15 | } 16 | `; 17 | export const RemoveDevice = gql` 18 | mutation RemoveDevice($id: Int!) { 19 | removeDevice(id: $id) { 20 | id 21 | } 22 | } 23 | `; 24 | export const UpdateDevice = gql` 25 | mutation UpdateDevice($id: Int!, $name: String!, $deviceType: DeviceType!) { 26 | updateDevice(id: $id, name: $name, type: $deviceType) { 27 | id 28 | } 29 | } 30 | `; 31 | 32 | export const CreateDevice = gql` 33 | mutation CreateDevice($name: String!, $deviceType: DeviceType!) { 34 | device: createDevice(name: $name, type: $deviceType) { 35 | token 36 | } 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /ui/src/gql/settings.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | import {useQuery} from '@apollo/react-hooks'; 3 | import {Settings as SettingsQueryResponse} from './__generated__/Settings'; 4 | import {DateLocale, Theme, WeekDay, DateTimeInputStyle} from './__generated__/globalTypes'; 5 | import {stripTypename} from '../utils/strip'; 6 | 7 | export const Settings = gql` 8 | query Settings { 9 | userSettings { 10 | theme 11 | dateLocale 12 | firstDayOfTheWeek 13 | dateTimeInputStyle 14 | } 15 | } 16 | `; 17 | 18 | export const SetSettings = gql` 19 | mutation SetSettings($settings: InputUserSettings!) { 20 | setUserSettings(settings: $settings) { 21 | theme 22 | dateTimeInputStyle 23 | } 24 | } 25 | `; 26 | 27 | const defaultSettings = { 28 | theme: Theme.GruvboxDark, 29 | dateLocale: DateLocale.American, 30 | firstDayOfTheWeek: WeekDay.Monday, 31 | dateTimeInputStyle: DateTimeInputStyle.Fancy, 32 | } as const; 33 | 34 | export const useSettings = (): {done: boolean} & Omit => { 35 | const data = useQuery(Settings); 36 | 37 | if (data.loading || data.error || !data.data) { 38 | return {...defaultSettings, done: false}; 39 | } 40 | return {done: true, ...stripTypename(data.data.userSettings)}; 41 | }; 42 | -------------------------------------------------------------------------------- /ui/src/gql/statistics.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | 3 | export const Stats = gql` 4 | query Stats($ranges: [Range!], $tags: [String!], $excludeTags: [InputTimeSpanTag!], $requireTags: [InputTimeSpanTag!]) { 5 | stats(ranges: $ranges, tags: $tags, excludeTags: $excludeTags, requireTags: $requireTags) { 6 | start 7 | end 8 | entries { 9 | key 10 | value 11 | timeSpendInSeconds 12 | } 13 | } 14 | } 15 | `; 16 | export const Stats2 = gql` 17 | query Stats2($now: Time!, $stats: InputStatsSelection!) { 18 | stats: stats2(now: $now, stats: $stats) { 19 | start 20 | end 21 | entries { 22 | key 23 | value 24 | timeSpendInSeconds 25 | } 26 | } 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /ui/src/gql/tags.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | 3 | export const SuggestTag = gql` 4 | query SuggestTag($query: String!) { 5 | tags: suggestTag(query: $query) { 6 | color 7 | key 8 | } 9 | } 10 | `; 11 | 12 | export const Tags = gql` 13 | query Tags { 14 | tags { 15 | color 16 | key 17 | usages 18 | } 19 | } 20 | `; 21 | export const SuggestTagValue = gql` 22 | query SuggestTagValue($tag: String!, $query: String!) { 23 | values: suggestTagValue(key: $tag, query: $query) 24 | } 25 | `; 26 | 27 | export const AddTag = gql` 28 | mutation AddTag($name: String!, $color: String!) { 29 | createTag(key: $name, color: $color) { 30 | color 31 | key 32 | } 33 | } 34 | `; 35 | 36 | export const UpdateTag = gql` 37 | mutation UpdateTag($key: String!, $newKey: String, $color: String!) { 38 | updateTag(key: $key, newKey: $newKey, color: $color) { 39 | color 40 | key 41 | } 42 | } 43 | `; 44 | 45 | export const RemoveTag = gql` 46 | mutation RemoveTag($key: String!) { 47 | removeTag(key: $key) { 48 | color 49 | key 50 | } 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /ui/src/gql/user.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | 3 | export const CurrentUser = gql` 4 | query CurrentUser { 5 | user: currentUser { 6 | name 7 | admin 8 | id 9 | } 10 | } 11 | `; 12 | 13 | export const Login = gql` 14 | mutation Login($name: String!, $pass: String!, $deviceType: DeviceType!) { 15 | login(username: $name, pass: $pass, deviceName: "web ui", type: $deviceType, cookie: true) { 16 | user { 17 | id 18 | name 19 | admin 20 | } 21 | } 22 | } 23 | `; 24 | export const Logout = gql` 25 | mutation Logout { 26 | user: removeCurrentDevice { 27 | name 28 | } 29 | } 30 | `; 31 | 32 | export const Users = gql` 33 | query Users { 34 | users { 35 | id 36 | name 37 | admin 38 | } 39 | currentUser { 40 | id 41 | } 42 | } 43 | `; 44 | 45 | export const RemoveUser = gql` 46 | mutation RemoveUser($id: Int!) { 47 | removeUser(id: $id) { 48 | id 49 | name 50 | admin 51 | } 52 | } 53 | `; 54 | 55 | export const UpdateUser = gql` 56 | mutation UpdateUser($id: Int!, $name: String!, $admin: Boolean!, $pass: String) { 57 | updateUser(id: $id, name: $name, admin: $admin, pass: $pass) { 58 | id 59 | name 60 | admin 61 | } 62 | } 63 | `; 64 | export const CreateUser = gql` 65 | mutation CreateUser($name: String!, $admin: Boolean!, $pass: String!) { 66 | createUser(name: $name, admin: $admin, pass: $pass) { 67 | id 68 | name 69 | admin 70 | } 71 | } 72 | `; 73 | -------------------------------------------------------------------------------- /ui/src/gql/version.ts: -------------------------------------------------------------------------------- 1 | import {gql} from 'apollo-boost'; 2 | import {Version as VersionResponse} from './__generated__/Version'; 3 | 4 | export const Version = gql` 5 | query Version { 6 | version { 7 | name 8 | commit 9 | buildDate 10 | } 11 | } 12 | `; 13 | 14 | export const VersionDefault: VersionResponse = { 15 | version: {__typename: 'Version', commit: 'unknown', buildDate: 'unknown', name: 'vUnknown'}, 16 | }; 17 | -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {Root} from './Root'; 4 | import 'moment/locale/de'; 5 | import 'moment/locale/en-au'; 6 | import 'moment/locale/en-gb'; 7 | 8 | ReactDOM.render(, document.getElementById('root')); 9 | -------------------------------------------------------------------------------- /ui/src/login/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import {LoginForm} from './LoginForm'; 5 | import Link from '@material-ui/core/Link'; 6 | import {DefaultPaper} from '../common/DefaultPaper'; 7 | import * as gqlVersion from '../gql/version'; 8 | import {useQuery} from '@apollo/react-hooks'; 9 | import {Version} from '../gql/__generated__/Version'; 10 | import makeStyles from '@material-ui/core/styles/makeStyles'; 11 | 12 | const useStyles = makeStyles(() => ({ 13 | footerLink: { 14 | margin: '0 2px', 15 | }, 16 | })); 17 | 18 | export const LoginPage = () => { 19 | const classes = useStyles(); 20 | const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery( 21 | gqlVersion.Version 22 | ); 23 | return ( 24 | 25 | 26 | 27 | 28 | traggo 29 | 30 | 31 | 32 | 33 |
34 | 35 | Source Code 36 | 37 | 38 | | 39 | 40 | 41 | Bug Tracker 42 | 43 | 44 | | 45 | 46 | 47 | {version.name}@{version.commit.slice(0, 8)} 48 | 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /ui/src/provider/ApolloProvider.tsx: -------------------------------------------------------------------------------- 1 | import ApolloClient from 'apollo-boost'; 2 | import * as React from 'react'; 3 | import {ApolloProvider as Provider} from 'react-apollo'; 4 | import {ApolloProvider as ApolloProviderHooks} from '@apollo/react-hooks'; 5 | 6 | const client = new ApolloClient({ 7 | uri: './graphql', 8 | }); 9 | 10 | export const ApolloProvider: React.FC = ({children}) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /ui/src/provider/SnackbarProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {SnackbarProvider as Provider} from 'notistack'; 3 | import {makeStyles} from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles(() => ({ 6 | error: { 7 | background: '#E53935', 8 | color: '#fff', 9 | }, 10 | warning: { 11 | background: '#d35400', 12 | color: '#fff', 13 | }, 14 | info: { 15 | background: '#2980b9', 16 | color: '#fff', 17 | }, 18 | })); 19 | 20 | export const SnackbarProvider: React.FC = ({children}) => { 21 | const classes = useStyles(); 22 | return ( 23 | 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /ui/src/provider/UserSettingsProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useSettings} from '../gql/settings'; 3 | import {CenteredSpinner} from '../common/CenteredSpinner'; 4 | import moment, {LocaleSpecification} from 'moment'; 5 | import {DateLocale, WeekDay} from '../gql/__generated__/globalTypes'; 6 | import {expectNever} from '../utils/never'; 7 | 8 | const setLocale = (locale: DateLocale, spec: LocaleSpecification) => { 9 | switch (locale) { 10 | case DateLocale.American: 11 | moment.locale('en', spec); 12 | return; 13 | case DateLocale.American24h: 14 | moment.locale('en', { 15 | ...spec, 16 | longDateFormat: { 17 | LTS: 'HH:mm:ss', 18 | LT: 'HH:mm', 19 | L: 'MM/DD/YYYY', 20 | LL: 'MMMM D, YYYY', 21 | LLL: 'MMMM D, YYYY HH:mm', 22 | LLLL: 'dddd, MMMM D, YYYY HH:mm', 23 | }, 24 | }); 25 | return; 26 | case DateLocale.Australian: 27 | moment.locale('en-au', spec); 28 | return; 29 | case DateLocale.British: 30 | moment.locale('en-gb', spec); 31 | return; 32 | case DateLocale.German: 33 | moment.locale('de', spec); 34 | return; 35 | default: 36 | expectNever(locale); 37 | return; 38 | } 39 | }; 40 | 41 | const weekDayToMoment = (s: WeekDay): number => { 42 | switch (s) { 43 | case WeekDay.Sunday: 44 | return 0; 45 | case WeekDay.Monday: 46 | return 1; 47 | case WeekDay.Tuesday: 48 | return 2; 49 | case WeekDay.Wednesday: 50 | return 3; 51 | case WeekDay.Thursday: 52 | return 4; 53 | case WeekDay.Friday: 54 | return 5; 55 | case WeekDay.Saturday: 56 | return 6; 57 | default: 58 | return expectNever(s); 59 | } 60 | }; 61 | 62 | export const BootUserSettings: React.FC = ({children}): React.ReactElement => { 63 | const {done, firstDayOfTheWeek, dateLocale} = useSettings(); 64 | 65 | React.useEffect(() => { 66 | if (!done) { 67 | return; 68 | } 69 | setLocale(dateLocale, { 70 | week: { 71 | dow: weekDayToMoment(firstDayOfTheWeek), 72 | doy: moment.localeData(moment.locale()).firstDayOfYear(), 73 | }, 74 | }); 75 | }, [dateLocale, firstDayOfTheWeek, done]); 76 | 77 | if (!done) { 78 | return ; 79 | } 80 | 81 | return <>{children}; 82 | }; 83 | -------------------------------------------------------------------------------- /ui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /ui/src/tag/suggest.ts: -------------------------------------------------------------------------------- 1 | import {Tags} from '../gql/__generated__/Tags'; 2 | import * as gqlTags from '../gql/tags'; 3 | import {useQuery} from '@apollo/react-hooks'; 4 | import {SuggestTagValue, SuggestTagValueVariables} from '../gql/__generated__/SuggestTagValue'; 5 | import {TagSelectorEntry, specialTag} from './tagSelectorEntry'; 6 | import {QueryResult} from 'react-apollo'; 7 | 8 | export const useSuggest = ( 9 | tagResult: QueryResult, 10 | inputValue: string, 11 | usedTags: string[], 12 | skipValue = false 13 | ): TagSelectorEntry[] => { 14 | const [tagKeySomeCase, tagValue] = inputValue.split(':'); 15 | const tagKey = tagKeySomeCase.toLowerCase(); 16 | 17 | const exactMatch = ((tagResult.data && tagResult.data.tags) || []).find((tag) => tag.key === tagKey); 18 | 19 | const valueResult = useQuery(gqlTags.SuggestTagValue, { 20 | variables: {tag: tagKey, query: tagValue}, 21 | skip: exactMatch === undefined || skipValue, 22 | }); 23 | 24 | if (exactMatch && tagValue !== undefined && usedTags.indexOf(exactMatch.key) === -1 && !skipValue) { 25 | return suggestTagValue(exactMatch, tagValue, valueResult); 26 | } else { 27 | return suggestTag(exactMatch, tagResult, tagKey, usedTags); 28 | } 29 | }; 30 | 31 | const suggestTag = ( 32 | exactMatch: TagSelectorEntry['tag'] | undefined, 33 | tagResult: QueryResult, 34 | tagKey: string, 35 | usedTags: string[] 36 | ) => { 37 | if (!tagResult.data || tagResult.data.tags === null) { 38 | return []; 39 | } 40 | 41 | let availableTags = (tagResult.data.tags || []) 42 | .filter((tag) => usedTags.indexOf(tag.key) === -1) 43 | .filter((tag) => tag.key.indexOf(tagKey) === 0); 44 | 45 | if (tagKey && !exactMatch) { 46 | availableTags = [specialTag(tagKey, 'new'), ...availableTags]; 47 | } 48 | 49 | if (usedTags.indexOf(tagKey) !== -1) { 50 | availableTags = [specialTag(tagKey, 'used'), ...availableTags]; 51 | } 52 | 53 | return availableTags 54 | .sort((a, b) => b.usages - a.usages) 55 | .slice(0, 5) 56 | .map((tag) => ({tag, value: ''})); 57 | }; 58 | 59 | const suggestTagValue = ( 60 | exactMatch: TagSelectorEntry['tag'], 61 | tagValue: string, 62 | valueResult: QueryResult 63 | ): TagSelectorEntry[] => { 64 | let someValues = (valueResult.data && valueResult.data.values) || []; 65 | 66 | if (someValues.indexOf(tagValue) === -1) { 67 | someValues = [tagValue, ...someValues]; 68 | } 69 | 70 | return someValues.map((val) => ({tag: exactMatch, value: val})); 71 | }; 72 | -------------------------------------------------------------------------------- /ui/src/timespan/ActiveTrackers.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useQuery} from '@apollo/react-hooks'; 3 | import * as gqlTimeSpan from '../gql/timeSpan'; 4 | import * as gqlTag from '../gql/tags'; 5 | import {Trackers} from '../gql/__generated__/Trackers'; 6 | import {Tags} from '../gql/__generated__/Tags'; 7 | import {TimeSpan} from './TimeSpan'; 8 | import {toTimeSpanProps} from './timespanutils'; 9 | import {Typography} from '@material-ui/core'; 10 | 11 | export const ActiveTrackers = () => { 12 | const trackersResult = useQuery(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'}); 13 | const tagsResult = useQuery(gqlTag.Tags, {fetchPolicy: 'cache-and-network'}); 14 | const values = React.useMemo(() => { 15 | if ( 16 | trackersResult.error || 17 | trackersResult.loading || 18 | !trackersResult.data || 19 | trackersResult.data.timers === null || 20 | tagsResult.error || 21 | tagsResult.loading || 22 | !tagsResult.data || 23 | tagsResult.data.tags === null 24 | ) { 25 | return []; 26 | } 27 | return toTimeSpanProps(trackersResult.data.timers, tagsResult.data.tags); 28 | }, [tagsResult, trackersResult]); 29 | 30 | if (!values.length) { 31 | return null; 32 | } 33 | 34 | return ( 35 | <> 36 | 37 | Active Timers 38 | 39 | {values.map((value) => { 40 | return ; 41 | })} 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /ui/src/timespan/DailyPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Tracker} from './Tracker'; 3 | import {ActiveTrackers} from './ActiveTrackers'; 4 | import {DoneTrackers} from './DoneTrackers'; 5 | import {TagSelectorEntry} from '../tag/tagSelectorEntry'; 6 | import {RefreshTimeSpans} from './RefreshTimespans'; 7 | 8 | export const DailyPage = () => { 9 | const [selectedEntries, setSelectedEntries] = React.useState([]); 10 | return ( 11 |
12 | 13 | 14 | setSelectedEntries(selectedEntries.concat(entries)) : undefined 17 | } 18 | /> 19 | 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/timespan/RefreshTimespans.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useQuery} from '@apollo/react-hooks'; 3 | import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans'; 4 | import * as gqlTimeSpan from '../gql/timeSpan'; 5 | import {useSnackbar} from 'notistack'; 6 | import {isSameDate} from '../utils/time'; 7 | import moment from 'moment'; 8 | import {Fab, Zoom} from '@material-ui/core'; 9 | import RefreshIcon from '@material-ui/icons/Refresh'; 10 | 11 | export const RefreshTimeSpans: React.FC = () => { 12 | const {refetch, data} = useQuery(gqlTimeSpan.TimeSpans, { 13 | variables: {cursor: {pageSize: 30}}, 14 | }); 15 | const {enqueueSnackbar, closeSnackbar} = useSnackbar(); 16 | const hasMovedEntries = 17 | data && 18 | data.timeSpans && 19 | data.timeSpans.timeSpans.some(({start, oldStart}) => !isSameDate(moment(start), oldStart ? moment(oldStart) : undefined)); 20 | React.useEffect(() => { 21 | if (hasMovedEntries) { 22 | const id = enqueueSnackbar('Some messages where moved, use the refresh button to reorder the timespans', { 23 | variant: 'info', 24 | persist: true, 25 | preventDuplicate: true, 26 | }); 27 | return () => { 28 | if (id) { 29 | closeSnackbar(id); 30 | } 31 | }; 32 | } 33 | return () => {}; 34 | }, [hasMovedEntries, enqueueSnackbar, closeSnackbar]); 35 | 36 | if (!hasMovedEntries) { 37 | return <>; 38 | } 39 | 40 | return ( 41 | 48 | { 51 | refetch({cursor: {pageSize: 30}}).then(() => { 52 | enqueueSnackbar('Refreshed messages', {variant: 'success'}); 53 | }); 54 | }} 55 | style={{position: 'fixed', bottom: '30px', right: '30px', zIndex: 100000}} 56 | color="primary"> 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /ui/src/timespan/colorutils.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import colorHash from 'color-hash'; 3 | 4 | const dark = new colorHash({saturation: 0.35, lightness: 0.35}); 5 | const darkNone = new colorHash({saturation: 0.35, lightness: 0.5}); 6 | 7 | const light = new colorHash({saturation: 0.5, lightness: 0.6}); 8 | const lightNone = new colorHash({saturation: 0.5, lightness: 0.4}); 9 | 10 | export enum ColorMode { 11 | Bold, 12 | None, 13 | } 14 | 15 | const mapping: Record string>> = { 16 | [ColorMode.Bold]: { 17 | dark: (s) => dark.hex(s), 18 | light: (s) => light.hex(s), 19 | }, 20 | [ColorMode.None]: { 21 | dark: (s) => darkNone.hex(s), 22 | light: (s) => lightNone.hex(s), 23 | }, 24 | }; 25 | 26 | export const calculateColor = (s: string, mode: ColorMode, theme: 'light' | 'dark') => { 27 | return mapping[mode][theme](s); 28 | }; 29 | -------------------------------------------------------------------------------- /ui/src/timespan/timespanutils.ts: -------------------------------------------------------------------------------- 1 | import {Trackers_timers} from '../gql/__generated__/Trackers'; 2 | import {Tags_tags} from '../gql/__generated__/Tags'; 3 | import {toTagSelectorEntry} from '../tag/tagSelectorEntry'; 4 | import moment from 'moment'; 5 | import {TimeSpanProps} from './TimeSpan'; 6 | import {TimeSpans_timeSpans_timeSpans} from '../gql/__generated__/TimeSpans'; 7 | 8 | export const toTimeSpanProps = (timers: Trackers_timers[], tags: Tags_tags[]): TimeSpanProps[] => { 9 | return [...timers].map((timer) => { 10 | const tagEntries = toTagSelectorEntry(tags, timer.tags || []); 11 | const range: TimeSpanProps['range'] = {from: moment.parseZone(timer.start)}; 12 | if (timer.end) { 13 | range.to = moment.parseZone(timer.end); 14 | } 15 | return { 16 | id: timer.id, 17 | range: { 18 | ...range, 19 | oldFrom: timer.oldStart ? moment(timer.oldStart) : undefined, 20 | }, 21 | initialTags: tagEntries, 22 | note: timer.note, 23 | }; 24 | }); 25 | }; 26 | 27 | type GroupedByIndex = Record; 28 | const group = (startOfTomorrow: moment.Moment, startOfToday: moment.Moment, startOfYesterday: moment.Moment) => ( 29 | a: GroupedByIndex, 30 | current: TimeSpans_timeSpans_timeSpans 31 | ): GroupedByIndex => { 32 | const startTime = moment(current.oldStart || current.start); 33 | let date = `${startTime.format('dddd')}, ${startTime.format('LL')}`; 34 | if (startTime.isBetween(startOfToday, startOfTomorrow)) { 35 | date = `${date} (today)`; 36 | } else if (startTime.isBetween(startOfYesterday, startOfToday)) { 37 | date = `${date} (yesterday)`; 38 | } 39 | a[date] = [...(a[date] || []), current]; 40 | return a; 41 | }; 42 | 43 | export type GroupedTimeSpanProps = Array<{key: string; timeSpans: TimeSpanProps[]}>; 44 | 45 | export const toGroupedTimeSpanProps = ( 46 | timeSpans: TimeSpans_timeSpans_timeSpans[], 47 | tags: Tags_tags[], 48 | now: moment.Moment 49 | ): GroupedTimeSpanProps => { 50 | const datesWithTimeSpans: GroupedByIndex = timeSpans.reduce( 51 | group( 52 | moment(now) 53 | .add(1, 'day') 54 | .startOf('day'), 55 | moment(now).startOf('day'), 56 | moment(now) 57 | .subtract(1, 'day') 58 | .startOf('day') 59 | ), 60 | {} 61 | ); 62 | return Object.keys(datesWithTimeSpans).map((key) => { 63 | const groupedTimeSpans = datesWithTimeSpans[key]; 64 | return { 65 | key, 66 | timeSpans: toTimeSpanProps(groupedTimeSpans, tags), 67 | }; 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /ui/src/timespan/timeutils.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment-timezone'; 2 | import prettyMs from 'pretty-ms'; 3 | 4 | export const timeRunning = (date: moment.Moment, now: moment.Moment) => { 5 | const d = inUserTz(now).unix() - inUserTz(date).unix(); 6 | return prettyMs(d * 1000, {unitCount: 2}).substring(1); 7 | }; 8 | 9 | export const timeRunningCalendar = (date: moment.Moment, now: moment.Moment) => { 10 | const d = inUserTz(now).unix() - inUserTz(date).unix(); 11 | 12 | if (d < 60) { 13 | return '<1m'; 14 | } 15 | 16 | return prettyMs(d * 1000, {unitCount: d < 60 * 24 ? 1 : 2}).substring(1); 17 | }; 18 | 19 | export const uglyConvertToLocalTime = (m: moment.Moment): moment.Moment => { 20 | const withoutTimeZone: string = m.format('YYYY-MM-DDTHH:mm:ss'); 21 | return moment(withoutTimeZone); 22 | }; 23 | 24 | export const inUserTz = (m: moment.Moment): moment.Moment => m.tz(moment.tz.guess()); 25 | -------------------------------------------------------------------------------- /ui/src/utils/errors.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {withSnackbarProps} from 'notistack'; 3 | import {ApolloError} from 'apollo-boost'; 4 | 5 | export const handleError = (prefix: string, enqueue: withSnackbarProps['enqueueSnackbar']): ((error: ApolloError) => void) => { 6 | return (error) => { 7 | error.graphQLErrors.forEach((gqlError) => { 8 | enqueue(`${prefix}: ${gqlError.message}`, {variant: 'warning'}); 9 | }); 10 | }; 11 | }; 12 | 13 | export const useError = (timeout = 1000): [boolean, string, (s: string) => void] => { 14 | const [error, setError] = React.useState(''); 15 | const [active, setActive] = React.useState(false); 16 | 17 | React.useLayoutEffect(() => { 18 | if (!active) { 19 | return; 20 | } 21 | const handle = setTimeout(() => { 22 | setActive(false); 23 | }, timeout); 24 | return () => clearTimeout(handle); 25 | }, [active, timeout]); 26 | 27 | return [ 28 | active, 29 | error, 30 | (message: string) => { 31 | setError(message); 32 | setActive(true); 33 | }, 34 | ]; 35 | }; 36 | -------------------------------------------------------------------------------- /ui/src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import useTimeout from '@rooks/use-timeout'; 3 | 4 | export const useStateAndDelegateWithDelayOnChange: ( 5 | initialState: T, 6 | delegate: React.Dispatch>, 7 | delay?: number 8 | ) => [T, React.Dispatch>] = (initialState, delegate, delay = 50) => { 9 | const [value, setValue] = React.useState(initialState); 10 | const {start} = useTimeout(() => delegate(value), delay); 11 | return [ 12 | value, 13 | (newValue) => { 14 | if (value !== newValue) { 15 | start(); 16 | } 17 | setValue(newValue); 18 | }, 19 | ]; 20 | }; 21 | -------------------------------------------------------------------------------- /ui/src/utils/never.ts: -------------------------------------------------------------------------------- 1 | export const expectNever = (value: never): never => { 2 | throw new Error('expected never but was ' + value); 3 | }; 4 | -------------------------------------------------------------------------------- /ui/src/utils/range.ts: -------------------------------------------------------------------------------- 1 | export interface Range { 2 | from: string; 3 | to: string; 4 | } 5 | 6 | export const findRange = (selection: {range: Range | null; rangeId: number | null}, ranges: Record): Range => { 7 | if (selection.rangeId !== null) { 8 | return exclusiveRange(ranges[selection.rangeId]); 9 | } 10 | if (selection.range === null) { 11 | throw new Error('expected rangeId or range to be non null'); 12 | } 13 | return exclusiveRange(selection.range); 14 | }; 15 | 16 | export const exclusiveRange = (range: Range) => ({from: range.from, to: range.to}); 17 | -------------------------------------------------------------------------------- /ui/src/utils/strip.ts: -------------------------------------------------------------------------------- 1 | export const stripTypename = (value: T): T => { 2 | if (value === null || value === undefined) { 3 | return value; 4 | } 5 | 6 | if (Array.isArray(value)) { 7 | // tslint:disable-next-line:no-any 8 | return (value as any).map((x: any) => stripTypename(x)); 9 | } 10 | 11 | if (typeof value !== 'object') { 12 | return value; 13 | } 14 | 15 | Object.values(value).forEach(stripTypename); 16 | if ('__typename' in value) { 17 | // @ts-ignore 18 | delete value.__typename; 19 | } 20 | 21 | return value; 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/utils/time.test.ts: -------------------------------------------------------------------------------- 1 | import {isValidDate, parseRelativeTime} from './time'; 2 | import moment from 'moment'; 3 | 4 | moment.updateLocale('en', { 5 | week: { 6 | dow: 1, // monday 7 | doy: moment.localeData('en').firstDayOfYear(), 8 | }, 9 | }); 10 | 11 | it('should test for valid date', () => { 12 | expect(isValidDate('2017-05-05')).toBe(false); 13 | expect(isValidDate('2017-05-05T15:23')).toBe(false); 14 | expect(isValidDate('2017-05-05 15:23')).toBe(true); 15 | }); 16 | 17 | // 2018-10-15 Monday 18 | // 2018-10-22 Monday 19 | 20 | // 2019-10-07 Monday 21 | // 2019-10-14 Monday 22 | // 2019-10-21 Monday 23 | 24 | it('should parse', () => { 25 | expectSuccess(parseRelativeTime('now-1d', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 15:55:00'); 26 | expectSuccess(parseRelativeTime('now-120s', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 15:53:15'); 27 | expectSuccess(parseRelativeTime('now-1d-1h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 14:55:00'); 28 | expectSuccess(parseRelativeTime('now/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-14 00:00:00'); 29 | expectSuccess(parseRelativeTime('now/w', 'endOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 23:59:59'); 30 | expectSuccess(parseRelativeTime('now-1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-07 00:00:00'); 31 | expectSuccess(parseRelativeTime('now-1y+1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2018-10-22 00:00:00'); 32 | expectSuccess(parseRelativeTime('now/d+5h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-20 05:00:00'); 33 | expectSuccess(parseRelativeTime('now/y', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-01-01 00:00:00'); 34 | }); 35 | 36 | const expectSuccess = (value: ReturnType) => { 37 | if (value.success) { 38 | return expect(value.value.format('YYYY-MM-DD HH:mm:ss')); 39 | } 40 | expect(value.error).toEqual('no error'); 41 | return expect(''); 42 | }; 43 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": false, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "allowUnreachableCode": false, 21 | "allowUnusedLabels": false, 22 | "alwaysStrict": true, 23 | "noImplicitAny": true, 24 | "noImplicitReturns": true, 25 | "noImplicitThis": true, 26 | "suppressImplicitAnyIndexErrors": false, 27 | "strictPropertyInitialization": true, 28 | "strictNullChecks": true, 29 | "noUnusedLocals": true, 30 | "noUnusedParameters": true, 31 | "strictFunctionTypes": true, 32 | "jsx": "preserve" 33 | }, 34 | "include": [ 35 | "src" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /user/create.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // CreateUser creates a user. 13 | func (r *ResolverForUser) CreateUser(ctx context.Context, name string, pass string, admin bool) (*gqlmodel.User, error) { 14 | newUser := &model.User{ 15 | Name: name, 16 | Pass: createPassword(pass, r.PassStrength), 17 | Admin: admin, 18 | } 19 | 20 | if !r.DB.Where("name = ?", newUser.Name).Find(&model.User{}).RecordNotFound() { 21 | return nil, fmt.Errorf("user with name '%s' does already exist", newUser.Name) 22 | } 23 | 24 | create := r.DB.Create(&newUser) 25 | gqlUser := &gqlmodel.User{} 26 | copier.Copy(gqlUser, newUser) 27 | return gqlUser, create.Error 28 | } 29 | -------------------------------------------------------------------------------- /user/create_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | ) 12 | 13 | func TestGQL_CreateUser_succeeds_addsUser(t *testing.T) { 14 | createPassword = fakePassword 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | 18 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 19 | user, err := resolver.CreateUser(context.Background(), "jmattheis", "unicorn", true) 20 | 21 | require.Nil(t, err) 22 | expected := &gqlmodel.User{ 23 | Name: "jmattheis", 24 | Admin: true, 25 | ID: 1, 26 | } 27 | require.Equal(t, expected, user) 28 | assertUserExist(t, db, model.User{ 29 | Name: "jmattheis", 30 | Pass: unicornPW, 31 | ID: 1, 32 | Admin: true, 33 | }) 34 | assertUserCount(t, db, 1) 35 | } 36 | 37 | func TestGQL_CreateUser_fails_userAlreadyExists(t *testing.T) { 38 | createPassword = fakePassword 39 | db := test.InMemoryDB(t) 40 | defer db.Close() 41 | db.Create(&model.User{ 42 | Name: "jmattheis", 43 | Pass: unicornPW, 44 | ID: 1, 45 | Admin: true, 46 | }) 47 | 48 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 49 | _, err := resolver.CreateUser(context.Background(), "jmattheis", "unicorn", true) 50 | require.EqualError(t, err, "user with name 'jmattheis' does already exist") 51 | assertUserCount(t, db, 1) 52 | } 53 | -------------------------------------------------------------------------------- /user/current.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/auth" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | ) 10 | 11 | // CurrentUser returns the current user. 12 | func (r *ResolverForUser) CurrentUser(ctx context.Context) (*gqlmodel.User, error) { 13 | user := auth.GetUser(ctx) 14 | if user == nil { 15 | return nil, nil 16 | } 17 | var result gqlmodel.User 18 | copier.Copy(&result, user) 19 | return &result, nil 20 | } 21 | -------------------------------------------------------------------------------- /user/current_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/test" 10 | "github.com/traggo/server/test/fake" 11 | ) 12 | 13 | func TestGQL_CurrentUser_withUser(t *testing.T) { 14 | db := test.InMemoryDB(t) 15 | defer db.Close() 16 | 17 | resolver := ResolverForUser{DB: db.DB} 18 | result, err := resolver.CurrentUser(fake.UserWithPerm(2, true)) 19 | 20 | require.Nil(t, err) 21 | expected := &gqlmodel.User{ 22 | ID: 2, 23 | Name: "fake", 24 | Admin: true, 25 | } 26 | 27 | require.Equal(t, expected, result) 28 | } 29 | 30 | func TestGQL_CurrentUser_noUser(t *testing.T) { 31 | db := test.InMemoryDB(t) 32 | defer db.Close() 33 | 34 | resolver := ResolverForUser{DB: db.DB} 35 | result, err := resolver.CurrentUser(context.Background()) 36 | 37 | require.Nil(t, err) 38 | require.Nil(t, result) 39 | } 40 | -------------------------------------------------------------------------------- /user/get.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jinzhu/copier" 7 | "github.com/traggo/server/generated/gqlmodel" 8 | "github.com/traggo/server/model" 9 | ) 10 | 11 | // Users returns all users. 12 | func (r *ResolverForUser) Users(ctx context.Context) ([]*gqlmodel.User, error) { 13 | var users []model.User 14 | find := r.DB.Find(&users) 15 | var result []*gqlmodel.User 16 | copier.Copy(&result, &users) 17 | return result, find.Error 18 | } 19 | -------------------------------------------------------------------------------- /user/get_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/test" 10 | ) 11 | 12 | func TestGQL_Users(t *testing.T) { 13 | db := test.InMemoryDB(t) 14 | defer db.Close() 15 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 16 | db.NewUserPass(1, "jmattheis", unicornPW, true) 17 | db.NewUserPass(2, "broderpeters", ponyPW, false) 18 | 19 | users, err := resolver.Users(context.Background()) 20 | 21 | require.Nil(t, err) 22 | expected := []*gqlmodel.User{ 23 | { 24 | Name: "jmattheis", 25 | ID: 1, 26 | Admin: true, 27 | }, 28 | { 29 | Name: "broderpeters", 30 | ID: 2, 31 | Admin: false, 32 | }, 33 | } 34 | require.Equal(t, expected, users) 35 | } 36 | -------------------------------------------------------------------------------- /user/password/password.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | // CreatePassword returns a hashed version of the given password. 6 | func CreatePassword(pw string, strength int) []byte { 7 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pw), strength) 8 | if err != nil { 9 | panic(err) 10 | } 11 | return hashedPassword 12 | } 13 | 14 | // ComparePassword compares a hashed password with its possible plaintext equivalent. 15 | func ComparePassword(hashedPassword, password []byte) bool { 16 | return bcrypt.CompareHashAndPassword(hashedPassword, password) == nil 17 | } 18 | -------------------------------------------------------------------------------- /user/password/password_test.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPasswordSuccess(t *testing.T) { 10 | password := CreatePassword("secret", 4) 11 | assert.Equal(t, true, ComparePassword(password, []byte("secret"))) 12 | } 13 | 14 | func TestPasswordFailure(t *testing.T) { 15 | password := CreatePassword("secret", 4) 16 | assert.Equal(t, false, ComparePassword(password, []byte("secretx"))) 17 | } 18 | 19 | func TestBCryptFailure(t *testing.T) { 20 | assert.Panics(t, func() { CreatePassword("secret", 12312) }) 21 | } 22 | -------------------------------------------------------------------------------- /user/remove.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // RemoveUser removes a user 13 | func (r *ResolverForUser) RemoveUser(ctx context.Context, id int) (*gqlmodel.User, error) { 14 | user := model.User{ID: id} 15 | if r.DB.Find(&user).RecordNotFound() { 16 | return nil, fmt.Errorf("user with id %d does not exist", user.ID) 17 | } 18 | 19 | remove := r.DB.Delete(&user) 20 | gqlUser := &gqlmodel.User{} 21 | copier.Copy(gqlUser, &user) 22 | return gqlUser, remove.Error 23 | } 24 | -------------------------------------------------------------------------------- /user/update.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jinzhu/copier" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | ) 11 | 12 | // UpdateUser updates a user. 13 | func (r *ResolverForUser) UpdateUser(ctx context.Context, id int, name string, pass *string, admin bool) (*gqlmodel.User, error) { 14 | user := new(model.User) 15 | if r.DB.Find(user, id).RecordNotFound() { 16 | return nil, fmt.Errorf("user with id %d does not exist", id) 17 | } 18 | 19 | user.Name = name 20 | user.Admin = admin 21 | 22 | if pass != nil { 23 | user.Pass = createPassword(*pass, r.PassStrength) 24 | } 25 | 26 | update := r.DB.Save(user) 27 | gqlUser := &gqlmodel.User{} 28 | copier.Copy(gqlUser, user) 29 | return gqlUser, update.Error 30 | } 31 | -------------------------------------------------------------------------------- /user/update_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "github.com/traggo/server/generated/gqlmodel" 9 | "github.com/traggo/server/model" 10 | "github.com/traggo/server/test" 11 | ) 12 | 13 | func TestGQL_UpdateUser_succeeds_updatesUser(t *testing.T) { 14 | createPassword = fakePassword 15 | db := test.InMemoryDB(t) 16 | defer db.Close() 17 | db.NewUserPass(1, "jmattheis", unicornPW, true) 18 | 19 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 20 | user, err := resolver.UpdateUser(context.Background(), 1, "broder", pointer("pony"), false) 21 | require.Nil(t, err) 22 | 23 | expected := &gqlmodel.User{ 24 | Name: "broder", 25 | ID: 1, 26 | Admin: false, 27 | } 28 | require.Equal(t, expected, user) 29 | assertUserCount(t, db, 1) 30 | assertUserExist(t, db, model.User{ 31 | Name: "broder", 32 | ID: 1, 33 | Admin: false, 34 | Pass: ponyPW, 35 | }) 36 | } 37 | 38 | func TestGQL_UpdateUser_succeeds_preservesPassword(t *testing.T) { 39 | createPassword = fakePassword 40 | db := test.InMemoryDB(t) 41 | defer db.Close() 42 | db.NewUserPass(1, "jmattheis", unicornPW, true) 43 | 44 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 45 | user, err := resolver.UpdateUser(context.Background(), 1, "broder", nil, false) 46 | require.Nil(t, err) 47 | 48 | expected := &gqlmodel.User{ 49 | Name: "broder", 50 | ID: 1, 51 | Admin: false, 52 | } 53 | require.Equal(t, expected, user) 54 | assertUserCount(t, db, 1) 55 | assertUserExist(t, db, model.User{ 56 | Name: "broder", 57 | ID: 1, 58 | Admin: false, 59 | Pass: unicornPW, 60 | }) 61 | } 62 | 63 | func TestGQL_UpdateUser_fails_notExistingUser(t *testing.T) { 64 | createPassword = fakePassword 65 | db := test.InMemoryDB(t) 66 | defer db.Close() 67 | 68 | resolver := ResolverForUser{DB: db.DB, PassStrength: 4} 69 | _, err := resolver.UpdateUser(context.Background(), 1, "broder", nil, false) 70 | require.EqualError(t, err, "user with id 1 does not exist") 71 | 72 | assertUserCount(t, db, 0) 73 | } 74 | 75 | func pointer(s string) *string { 76 | return &s 77 | } 78 | -------------------------------------------------------------------------------- /user/userresolver.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/traggo/server/user/password" 6 | ) 7 | 8 | var createPassword = password.CreatePassword 9 | 10 | // ResolverForUser resolves user specific things. 11 | type ResolverForUser struct { 12 | DB *gorm.DB 13 | PassStrength int 14 | } 15 | -------------------------------------------------------------------------------- /user/utils_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/traggo/server/model" 8 | "github.com/traggo/server/test" 9 | ) 10 | 11 | var ( 12 | ponyPW = []byte{1} 13 | unicornPW = []byte{2} 14 | fakePassword = func(pw string, strength int) []byte { 15 | if pw == "pony" { 16 | return ponyPW 17 | } else if pw == "unicorn" { 18 | return unicornPW 19 | } 20 | panic("unknown pw") 21 | } 22 | ) 23 | 24 | func assertUserExist(t *testing.T, db *test.Database, expected model.User) { 25 | foundUser := new(model.User) 26 | find := db.Find(foundUser, expected.ID) 27 | require.Nil(t, find.Error) 28 | require.NotNil(t, foundUser) 29 | require.Equal(t, expected, *foundUser) 30 | } 31 | 32 | func assertUserCount(t *testing.T, db *test.Database, expected int) { 33 | count := new(int) 34 | db.Model(new(model.User)).Count(count) 35 | require.Equal(t, expected, *count) 36 | } 37 | --------------------------------------------------------------------------------