├── ui ├── tests │ ├── unit │ │ ├── __mocks__ │ │ │ └── .keep │ │ ├── global-teardown.js │ │ ├── global-setup.js │ │ └── matchers.js │ ├── e2e │ │ ├── support │ │ │ ├── utils.js │ │ │ ├── commands.js │ │ │ └── setup.js │ │ ├── .eslintrc.js │ │ ├── specs │ │ │ ├── home.e2e.js │ │ │ ├── profile.e2e.js │ │ │ └── auth.e2e.js │ │ └── plugins │ │ │ └── index.js │ └── mock-api │ │ ├── index.js │ │ ├── routes │ │ ├── users.js │ │ └── auth.js │ │ └── resources │ │ └── users.js ├── .dockerignore ├── .browserslistrc ├── .env ├── .eslintignore ├── src │ ├── design │ │ ├── _durations.scss │ │ ├── _layers.scss │ │ ├── _colors.scss │ │ ├── index.scss │ │ ├── _fonts.scss │ │ └── _sizes.scss │ ├── assets │ │ └── images │ │ │ └── logo.png │ ├── app.config.json │ ├── components │ │ ├── _base-button.vue │ │ ├── _base-button.unit.js │ │ ├── nav-bar.unit.js │ │ ├── _base-icon.unit.js │ │ ├── _base-icon.vue │ │ ├── _globals.js │ │ ├── nav-bar-routes.unit.js │ │ ├── _base-input-text.vue │ │ ├── _base-input-text.unit.js │ │ ├── nav-bar-routes.vue │ │ ├── quickEntryDisplay.vue │ │ ├── mileageChart.vue │ │ ├── _base-link.vue │ │ ├── nav-bar.vue │ │ ├── shareVehicle.vue │ │ ├── createQuickEntry.vue │ │ └── _base-link.unit.js │ ├── router │ │ ├── views │ │ │ ├── _404.unit.js │ │ │ ├── import.unit.js │ │ │ ├── _loading.unit.js │ │ │ ├── _timeout.unit.js │ │ │ ├── import-fuelly.unit.js │ │ │ ├── import-generic.unit.js │ │ │ ├── home.unit.js │ │ │ ├── profile.unit.js │ │ │ ├── _404.vue │ │ │ ├── profile.vue │ │ │ ├── _loading.vue │ │ │ ├── _timeout.vue │ │ │ ├── import.vue │ │ │ ├── login.unit.js │ │ │ ├── login.vue │ │ │ ├── siteSettings.vue │ │ │ └── quickEntries.vue │ │ └── layouts │ │ │ ├── main.vue │ │ │ └── main.unit.js │ ├── state │ │ ├── helpers.js │ │ ├── store.js │ │ └── modules │ │ │ ├── utils.js │ │ │ ├── users.unit.js │ │ │ ├── users.js │ │ │ ├── auth.js │ │ │ ├── index.js │ │ │ └── auth.unit.js │ ├── utils │ │ ├── format-date-relative.js │ │ ├── format-date.js │ │ ├── format-date.unit.js │ │ ├── format-date-relative.unit.js │ │ ├── dispatch-action-for-all-modules.js │ │ └── dispatch-action-for-all-modules.unit.js │ ├── i18n.js │ ├── app.vue │ └── main.js ├── cypress.json ├── .prettierignore ├── public │ ├── hammond.png │ ├── touch-icon.png │ └── index.html ├── .postcssrc.js ├── babel.config.js ├── generators │ └── new │ │ ├── e2e │ │ ├── e2e.ejs.t │ │ └── prompt.js │ │ ├── module │ │ ├── module.ejs.t │ │ ├── prompt.js │ │ └── unit.ejs.t │ │ ├── util │ │ ├── util.ejs.t │ │ ├── prompt.js │ │ └── unit.ejs.t │ │ ├── layout │ │ ├── prompt.js │ │ ├── layout.ejs.t │ │ └── unit.ejs.t │ │ ├── view │ │ ├── prompt.js │ │ ├── unit.ejs.t │ │ └── view.ejs.t │ │ └── component │ │ ├── unit.ejs.t │ │ ├── component.ejs.t │ │ └── prompt.js ├── docker-compose.yml ├── Dockerfile ├── docker-dev.dockerfile ├── .markdownlint.yml ├── jsconfig.template.js ├── .prettierrc.js ├── docs │ ├── production.md │ ├── editors.md │ ├── routing.md │ ├── troubleshooting.md │ ├── state.md │ ├── linting.md │ └── architecture.md ├── .gitignore ├── .vuepress │ └── config.js ├── lint-staged.config.js ├── .gitattributes ├── vue.config.js ├── aliases.config.js ├── stylelint.config.js ├── jest.config.js ├── .eslintrc.js └── package.json ├── images ├── users.jpg ├── settings.jpg ├── screenshot.jpg ├── create_fillup.jpg ├── vehicles_add.jpg ├── create_expense.jpg └── vehicle_detail.jpg ├── .gitignore ├── server ├── .env ├── models │ ├── files.go │ ├── errors.go │ ├── misc.go │ ├── alert.go │ ├── import.go │ ├── auth.go │ └── report.go ├── internal │ └── sanitize │ │ ├── .gitignore │ │ ├── LICENSE │ │ └── README.md ├── .gitignore ├── db │ ├── base.go │ ├── migrations.go │ ├── db.go │ └── enums.go ├── go.mod ├── service │ ├── miscService.go │ ├── userService.go │ ├── genericImportService.go │ ├── reportService.go │ ├── importService.go │ ├── fuellyImportService.go │ └── drivvoImportService.go ├── Dockerfile ├── controllers │ ├── reports.go │ ├── users.go │ ├── setup.go │ ├── masters.go │ ├── import.go │ └── middlewares.go ├── main.go └── common │ └── utils.go ├── docker-compose.yml ├── .github ├── workflows │ ├── test-go.yml │ └── hub.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── Screenshots.md ├── Dockerfile └── docs └── ubuntu-install.md /ui/tests/unit/__mocks__/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /ui/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:3000 2 | -------------------------------------------------------------------------------- /ui/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /tests/unit/coverage/ 3 | -------------------------------------------------------------------------------- /ui/src/design/_durations.scss: -------------------------------------------------------------------------------- 1 | $duration-animation-base: 300ms; 2 | -------------------------------------------------------------------------------- /ui/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /images/users.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/users.jpg -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /tests/unit/coverage/** 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't track .vscode directory 2 | .vscode 3 | !.vscode/launch.json 4 | 5 | -------------------------------------------------------------------------------- /images/settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/settings.jpg -------------------------------------------------------------------------------- /images/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/screenshot.jpg -------------------------------------------------------------------------------- /ui/public/hammond.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/public/hammond.png -------------------------------------------------------------------------------- /images/create_fillup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/create_fillup.jpg -------------------------------------------------------------------------------- /images/vehicles_add.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/vehicles_add.jpg -------------------------------------------------------------------------------- /ui/.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /ui/public/touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/public/touch-icon.png -------------------------------------------------------------------------------- /images/create_expense.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/create_expense.jpg -------------------------------------------------------------------------------- /images/vehicle_detail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/vehicle_detail.jpg -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | CONFIG=. 2 | DATA=./assets 3 | JWT_SECRET="A super strong secret that needs to be changed" 4 | -------------------------------------------------------------------------------- /ui/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/src/assets/images/logo.png -------------------------------------------------------------------------------- /ui/src/app.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Hammond", 3 | "description": "Like Clarkson, but better" 4 | } 5 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/utils.js: -------------------------------------------------------------------------------- 1 | // Returns the Vuex store. 2 | export const getStore = () => cy.window().its('__app__.$store') 3 | -------------------------------------------------------------------------------- /ui/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | 'cypress/globals': true, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /server/models/files.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CreateQuickEntryModel struct { 4 | Comments string `json:"comments" form:"comments"` 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/_base-button.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app 3 | presets: ['@vue/cli-plugin-babel/preset'], 4 | } 5 | -------------------------------------------------------------------------------- /ui/generators/new/e2e/e2e.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: tests/e2e/specs/<%= h.changeCase.kebab(name) %>.e2e.js 3 | --- 4 | describe('<%= h.changeCase.pascal(name) %>', () => { 5 | 6 | }) 7 | -------------------------------------------------------------------------------- /ui/src/router/views/_404.unit.js: -------------------------------------------------------------------------------- 1 | import View404 from './_404.vue' 2 | 3 | describe('@views/404', () => { 4 | it('is a valid view', () => { 5 | expect(View404).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/import.unit.js: -------------------------------------------------------------------------------- 1 | import Import from './import' 2 | 3 | describe('@views/import', () => { 4 | it('is a valid view', () => { 5 | expect(Import).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/design/_layers.scss: -------------------------------------------------------------------------------- 1 | $layer-negative-z-index: -1; 2 | $layer-page-z-index: 1; 3 | $layer-dropdown-z-index: 2; 4 | $layer-modal-z-index: 3; 5 | $layer-popover-z-index: 4; 6 | $layer-tooltip-z-index: 5; 7 | -------------------------------------------------------------------------------- /ui/src/router/views/_loading.unit.js: -------------------------------------------------------------------------------- 1 | import Loading from './_loading.vue' 2 | 3 | describe('@views/loading', () => { 4 | it('is a valid view', () => { 5 | expect(Loading).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/_timeout.unit.js: -------------------------------------------------------------------------------- 1 | import Timeout from './_timeout.vue' 2 | 3 | describe('@views/timeout', () => { 4 | it('is a valid view', () => { 5 | expect(Timeout).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/tests/unit/global-teardown.js: -------------------------------------------------------------------------------- 1 | // Shut down the mock API once all the tests are complete. 2 | 3 | module.exports = () => { 4 | return new Promise((resolve, reject) => { 5 | global.mockApiServer.close(resolve) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/router/views/import-fuelly.unit.js: -------------------------------------------------------------------------------- 1 | import ImportFuelly from './import-fuelly' 2 | 3 | describe('@views/import-fuelly', () => { 4 | it('is a valid view', () => { 5 | expect(ImportFuelly).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/import-generic.unit.js: -------------------------------------------------------------------------------- 1 | import ImportGeneric from './import-generic' 2 | 3 | describe('@views/import-generic', () => { 4 | it('is a valid view', () => { 5 | expect(ImportGeneric).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/tests/e2e/specs/home.e2e.js: -------------------------------------------------------------------------------- 1 | describe('Home Page', () => { 2 | it('has the correct title and heading', () => { 3 | cy.visit('/') 4 | cy.title().should('equal', 'Home | Hammond') 5 | cy.contains('h1', 'Home Page') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/generators/new/module/module.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/state/modules/<%= h.changeCase.kebab(name) %>.js 3 | --- 4 | export const state = {} 5 | 6 | export const getters = {} 7 | 8 | export const mutations = {} 9 | 10 | export const actions = {} 11 | -------------------------------------------------------------------------------- /server/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "fmt" 4 | 5 | type VehicleAlreadyExistsError struct { 6 | Registration string 7 | } 8 | 9 | func (e *VehicleAlreadyExistsError) Error() string { 10 | return fmt.Sprintf("Vehicle with this url already exists") 11 | } 12 | -------------------------------------------------------------------------------- /ui/generators/new/util/util.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/utils/<%= h.changeCase.kebab(name) %>.js" 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.camel(fileName) 7 | %>export default function <%= importName %>() { 8 | return 'hello' 9 | } 10 | -------------------------------------------------------------------------------- /ui/generators/new/e2e/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name:', 6 | validate(value) { 7 | if (!value.length) { 8 | return 'Components must have a name.' 9 | } 10 | return true 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /ui/generators/new/module/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name:', 6 | validate(value) { 7 | if (!value.length) { 8 | return 'Vuex modules must have a name.' 9 | } 10 | return true 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /ui/generators/new/util/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name:', 6 | validate(value) { 7 | if (!value.length) { 8 | return 'Utility functions must have a name.' 9 | } 10 | return true 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /ui/src/router/layouts/main.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /ui/generators/new/layout/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name:', 6 | validate(value) { 7 | if (!value.length) { 8 | return 'Layout components must have a name.' 9 | } 10 | return true 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /ui/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | volumes: 4 | dependencies: 5 | 6 | services: 7 | dev: 8 | build: 9 | context: . 10 | dockerfile: ./docker-dev.dockerfile 11 | ports: 12 | - 8080:8080 13 | volumes: 14 | - .:/app 15 | - dependencies:/app/node_modules 16 | tty: true 17 | -------------------------------------------------------------------------------- /ui/src/state/helpers.js: -------------------------------------------------------------------------------- 1 | import { mapState, mapGetters, mapActions } from 'vuex' 2 | 3 | export const authComputed = { 4 | ...mapState('auth', { 5 | currentUser: (state) => state.currentUser, 6 | }), 7 | ...mapGetters('auth', ['loggedIn']), 8 | } 9 | 10 | export const authMethods = mapActions('auth', ['logIn', 'logOut']) 11 | -------------------------------------------------------------------------------- /ui/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest as build-stage 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . 6 | RUN npm run build 7 | 8 | # production stage 9 | FROM nginx:stable-alpine as production-stage 10 | COPY --from=build-stage /app/dist /usr/share/nginx/html 11 | EXPOSE 80 12 | CMD ["nginx", "-g", "daemon off;"] 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | hammond: 4 | image: alfhou/hammond 5 | container_name: hammond 6 | environment: 7 | - JWT_SECRET=somethingverystrong 8 | volumes: 9 | - /path/to/config:/config 10 | - /path/to/data:/assets 11 | ports: 12 | - 3000:3000 13 | restart: unless-stopped 14 | -------------------------------------------------------------------------------- /ui/src/router/views/home.unit.js: -------------------------------------------------------------------------------- 1 | import Home from './home.vue' 2 | 3 | describe('@views/home', () => { 4 | it('is a valid view', () => { 5 | expect(Home).toBeAViewComponent() 6 | }) 7 | 8 | it('renders an element', () => { 9 | const { element } = shallowMountView(Home) 10 | expect(element.textContent).toContain('Home Page') 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /server/internal/sanitize/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | -------------------------------------------------------------------------------- /ui/src/utils/format-date-relative.js: -------------------------------------------------------------------------------- 1 | // https://date-fns.org/docs/formatDistance 2 | import formatDistance from 'date-fns/formatDistance' 3 | // https://date-fns.org/docs/isToday 4 | import isToday from 'date-fns/isToday' 5 | 6 | export default function formatDateRelative(fromDate, toDate = new Date()) { 7 | return formatDistance(fromDate, toDate) + (isToday(toDate) ? ' ago' : '') 8 | } 9 | -------------------------------------------------------------------------------- /ui/tests/unit/global-setup.js: -------------------------------------------------------------------------------- 1 | const app = require('express')() 2 | 3 | app.use((request, response, next) => { 4 | response.header('Access-Control-Allow-Origin', '*') 5 | next() 6 | }) 7 | 8 | require('../mock-api')(app) 9 | 10 | module.exports = () => { 11 | return new Promise((resolve, reject) => { 12 | global.mockApiServer = app.listen(process.env.MOCK_API_PORT, resolve) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/router/layouts/main.unit.js: -------------------------------------------------------------------------------- 1 | import MainLayout from './main.vue' 2 | 3 | describe('@layouts/main.vue', () => { 4 | it('renders its content', () => { 5 | const slotContent = '

Hello!

' 6 | const { element } = shallowMount(MainLayout, { 7 | slots: { 8 | default: slotContent, 9 | }, 10 | }) 11 | expect(element.innerHTML).toContain(slotContent) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/generators/new/view/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name:', 6 | validate(value) { 7 | if (!value.length) { 8 | return 'View components must have a name.' 9 | } 10 | return true 11 | }, 12 | }, 13 | { 14 | type: 'confirm', 15 | name: 'useStyles', 16 | message: 'Add 19 | -------------------------------------------------------------------------------- /ui/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # === 4 | # Rule customizations for markdownlint go here 5 | # https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md 6 | # === 7 | 8 | # Disable line length restrictions, because editor soft-wrapping is being 9 | # used instead. 10 | line-length: false 11 | 12 | # === 13 | # Prettier overrides 14 | # === 15 | 16 | no-multiple-blanks: false 17 | list-marker-space: false 18 | -------------------------------------------------------------------------------- /ui/src/components/_base-button.unit.js: -------------------------------------------------------------------------------- 1 | import BaseButton from './_base-button.vue' 2 | 3 | describe('@components/_base-button', () => { 4 | it('renders its content', () => { 5 | const slotContent = 'foo' 6 | const { element } = shallowMount(BaseButton, { 7 | slots: { 8 | default: slotContent, 9 | }, 10 | }) 11 | expect(element.innerHTML).toContain(slotContent) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/generators/new/view/unit.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/router/views/<%= h.changeCase.kebab(name) %>.unit.js" 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.pascal(fileName) 7 | %>import <%= importName %> from './<%= fileName %>' 8 | 9 | describe('@views/<%= fileName %>', () => { 10 | it('is a valid view', () => { 11 | expect(<%= importName %>).toBeAViewComponent() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/generators/new/util/unit.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/utils/<%= h.changeCase.kebab(name) %>.unit.js" 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.camel(fileName) 7 | %>import <%= importName %> from './<%= fileName %>' 8 | 9 | describe('@utils/<%= fileName %>', () => { 10 | it('says hello', () => { 11 | const result = <%= importName %>() 12 | expect(result).toEqual('hello') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /ui/jsconfig.template.js: -------------------------------------------------------------------------------- 1 | // This is a template for a jsconfig.json file which will be 2 | // generated when starting the dev server or a build. 3 | 4 | module.exports = { 5 | baseUrl: '.', 6 | include: ['src/**/*', 'tests/**/*'], 7 | compilerOptions: { 8 | baseUrl: '.', 9 | target: 'esnext', 10 | module: 'es2015', 11 | // ... 12 | // `paths` will be automatically generated using aliases.config.js 13 | // ... 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/test-go.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test server 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.17.x, 1.18.x] 8 | os: [ubuntu-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - uses: actions/setup-go@v3 12 | with: 13 | go-version: ${{ matrix.go-version }} 14 | - uses: actions/checkout@v3 15 | - run: go test ./... 16 | working-directory: server 17 | -------------------------------------------------------------------------------- /ui/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | endOfLine: 'lf', 5 | htmlWhitespaceSensitivity: 'strict', 6 | jsxBracketSameLine: false, 7 | jsxSingleQuote: true, 8 | printWidth: 150, 9 | proseWrap: 'never', 10 | quoteProps: 'as-needed', 11 | semi: false, 12 | singleQuote: true, 13 | tabWidth: 2, 14 | trailingComma: 'es5', 15 | useTabs: false, 16 | vueIndentScriptAndStyle: false, 17 | } 18 | -------------------------------------------------------------------------------- /ui/tests/mock-api/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const bodyParser = require('body-parser') 4 | 5 | module.exports = (app) => { 6 | app.use(bodyParser.json()) 7 | // Register all routes inside tests/mock-api/routes. 8 | fs.readdirSync(path.join(__dirname, 'routes')).forEach((routeFileName) => { 9 | if (/\.js$/.test(routeFileName)) { 10 | require(`./routes/${routeFileName}`)(app) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /ui/generators/new/module/unit.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: src/state/modules/<%= h.changeCase.kebab(name) %>.unit.js 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.camel(fileName) + 'Module' 7 | %>import * as <%= importName %> from './<%= fileName %>' 8 | 9 | describe('@state/modules/<%= fileName %>', () => { 10 | it('exports a valid Vuex module', () => { 11 | expect(<%= importName %>).toBeAVuexModule() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/src/design/_colors.scss: -------------------------------------------------------------------------------- 1 | // CONTENT 2 | $color-body-bg: #f9f7f5; 3 | $color-text: #444; 4 | $color-heading-text: #35495e; 5 | 6 | // LINKS 7 | $color-link-text: #39a275; 8 | $color-link-text-active: $color-text; 9 | 10 | // INPUTS 11 | $color-input-border: lighten($color-heading-text, 50%); 12 | 13 | // BUTTONS 14 | $color-button-bg: $color-link-text; 15 | $color-button-disabled-bg: darken(desaturate($color-button-bg, 20%), 10%); 16 | $color-button-text: white; 17 | -------------------------------------------------------------------------------- /ui/src/utils/format-date.js: -------------------------------------------------------------------------------- 1 | // https://date-fns.org/docs/format 2 | import format from 'date-fns/format' 3 | import parseISO from 'date-fns/parseISO' 4 | 5 | export default function formatDate(date) { 6 | return format(date, 'MMM do, yyyy') 7 | } 8 | 9 | export function parseAndFormatDate(date) { 10 | return format(parseISO(date), 'MMM dd, yyyy') 11 | } 12 | 13 | export function parseAndFormatDateTime(date) { 14 | return format(parseISO(date), 'MMM dd, yyyy hh:mm aa') 15 | } 16 | -------------------------------------------------------------------------------- /server/models/misc.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "hammond/db" 4 | 5 | type UpdateSettingModel struct { 6 | Currency string `json:"currency" form:"currency" query:"currency"` 7 | DateFormat string `json:"dateFormat" form:"dateFormat" query:"dateFormat"` 8 | DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" ` 9 | } 10 | 11 | type ClarksonMigrationModel struct { 12 | Url string `json:"url" form:"url" query:"url"` 13 | } 14 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.db 14 | 15 | # MS VSCode 16 | .vscode 17 | !.vscode/launch.json 18 | __debug_bin 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | assets/* 23 | keys/* 24 | backups/* 25 | nodemon.json 26 | dist/* 27 | -------------------------------------------------------------------------------- /ui/docs/production.md: -------------------------------------------------------------------------------- 1 | # Building and deploying to production 2 | 3 | - [From the terminal](#from-the-terminal) 4 | - [From Circle CI](#from-circle-ci) 5 | 6 | ## From the terminal 7 | 8 | ```bash 9 | # Build for production with minification 10 | yarn build 11 | ``` 12 | 13 | This results in your compiled application in a `dist` directory. 14 | 15 | ## From Circle CI 16 | 17 | Update `.circleci/config.yml` to automatically deploy to staging and/or production on a successful build. See comments in that file for details. 18 | -------------------------------------------------------------------------------- /ui/src/router/views/profile.unit.js: -------------------------------------------------------------------------------- 1 | import Profile from './profile.vue' 2 | 3 | describe('@views/profile', () => { 4 | it('is a valid view', () => { 5 | expect(Profile).toBeAViewComponentUsing({ user: { name: '' } }) 6 | }) 7 | 8 | it(`includes the provided user's name`, () => { 9 | const { element } = shallowMountView(Profile, { 10 | propsData: { 11 | user: { name: 'My Name' }, 12 | }, 13 | }) 14 | 15 | expect(element.textContent).toMatch(/My Name\s+Profile/) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /server/db/base.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | 6 | uuid "github.com/satori/go.uuid" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | //Base is 11 | type Base struct { 12 | ID string `sql:"type:uuid;primary_key" json:"id"` 13 | CreatedAt time.Time `json:"createdAt"` 14 | UpdatedAt time.Time `json:"updatedAt"` 15 | DeletedAt *time.Time `gorm:"index" json:"deletedAt"` 16 | } 17 | 18 | //BeforeCreate 19 | func (base *Base) BeforeCreate(tx *gorm.DB) error { 20 | tx.Statement.SetColumn("ID", uuid.NewV4().String()) 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # OS Files 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Dependencies 6 | node_modules/ 7 | 8 | # Dev/Build Artifacts 9 | /dist/ 10 | /tests/e2e/videos/ 11 | /tests/e2e/screenshots/ 12 | /tests/unit/coverage/ 13 | jsconfig.json 14 | 15 | # Local Env Files 16 | .env.local 17 | .env.*.local 18 | 19 | # Log Files 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Unconfigured Editors 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw* 32 | 33 | #Vs code files 34 | .vscode 35 | !.vscode/launch.json 36 | 37 | -------------------------------------------------------------------------------- /ui/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | const appConfig = require('../src/app.config') 2 | 3 | module.exports = { 4 | title: appConfig.title + ' Docs', 5 | description: appConfig.description, 6 | themeConfig: { 7 | sidebar: [ 8 | ['/', 'Introduction'], 9 | '/docs/development', 10 | '/docs/architecture', 11 | '/docs/tech', 12 | '/docs/routing', 13 | '/docs/state', 14 | '/docs/tests', 15 | '/docs/linting', 16 | '/docs/editors', 17 | '/docs/production', 18 | '/docs/troubleshooting', 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // Create custom Cypress commands and overwrite existing ones. 2 | // https://on.cypress.io/custom-commands 3 | 4 | import { getStore } from './utils' 5 | 6 | Cypress.Commands.add( 7 | 'logIn', 8 | ({ username = 'admin', password = 'password' } = {}) => { 9 | // Manually log the user in 10 | cy.location('pathname').then((pathname) => { 11 | if (pathname === 'blank') { 12 | cy.visit('/') 13 | } 14 | }) 15 | getStore().then((store) => 16 | store.dispatch('auth/logIn', { username, password }) 17 | ) 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /Screenshots.md: -------------------------------------------------------------------------------- 1 | ## Home Page / Summary 2 | 3 | ![Product Name Screen Shot][product-screenshot] 4 | 5 | ## Create Vehicle 6 | 7 | ![Podcast Episodes](images/vehicles_add.jpg) 8 | 9 | ## Vehicle Detail 10 | 11 | ![All Episodes](images/vehicle_detail.jpg) 12 | 13 | ## Create Fillup 14 | 15 | ![Podcast Episodes](images/create_fillup.jpg) 16 | 17 | ## Create Expense 18 | 19 | ![Player](images/create_expense.jpg) 20 | 21 | ## User Management 22 | 23 | ![Player](images/users.jpg) 24 | 25 | ## Settings 26 | 27 | ![Podcast Episodes](images/settings.jpg) 28 | 29 | [product-screenshot]: images/screenshot.jpg 30 | -------------------------------------------------------------------------------- /ui/generators/new/component/unit.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.unit.js" 3 | --- 4 | <% 5 | let fileName = h.changeCase.kebab(name).toLowerCase() 6 | const importName = h.changeCase.pascal(fileName) 7 | if (fileName.slice(0, 5) === 'base-') { 8 | fileName = '_' + fileName 9 | } 10 | %>import <%= importName %> from './<%= fileName %>' 11 | 12 | describe('@components/<%= fileName %>', () => { 13 | it('exports a valid component', () => { 14 | expect(<%= importName %>).toBeAComponent() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /ui/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['yarn lint:eslint', 'yarn lint:prettier', 'yarn test:unit:file'], 3 | '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [ 4 | 'yarn lint:prettier --parser json', 5 | ], 6 | 'package.json': ['yarn lint:prettier'], 7 | '*.vue': [ 8 | 'yarn lint:eslint', 9 | 'yarn lint:stylelint', 10 | 'yarn lint:prettier', 11 | 'yarn test:unit:file', 12 | ], 13 | '*.scss': ['yarn lint:stylelint', 'yarn lint:prettier'], 14 | '*.md': ['yarn lint:markdownlint', 'yarn lint:prettier'], 15 | '*.{png,jpeg,jpg,gif,svg}': ['imagemin-lint-staged'], 16 | } 17 | -------------------------------------------------------------------------------- /ui/generators/new/layout/unit.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.unit.js" 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.pascal(fileName) + 'Layout' 7 | %>import <%= importName %> from './<%= fileName %>' 8 | 9 | describe('@layouts/<%= fileName %>', () => { 10 | it('renders its content', () => { 11 | const slotContent = '

Hello!

' 12 | const { element } = shallowMount(<%= importName %>, { 13 | slots: { 14 | default: slotContent, 15 | }, 16 | }) 17 | expect(element.innerHTML).toContain(slotContent) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /ui/src/state/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import dispatchActionForAllModules from '@utils/dispatch-action-for-all-modules' 4 | 5 | import modules from './modules' 6 | 7 | Vue.use(Vuex) 8 | 9 | const store = new Vuex.Store({ 10 | modules, 11 | // Enable strict mode in development to get a warning 12 | // when mutating state outside of a mutation. 13 | // https://vuex.vuejs.org/guide/strict.html 14 | strict: process.env.NODE_ENV !== 'production', 15 | }) 16 | 17 | export default store 18 | 19 | // Automatically run the `init` action for every module, 20 | // if one exists. 21 | dispatchActionForAllModules('init') 22 | -------------------------------------------------------------------------------- /ui/src/design/index.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'durations'; 3 | @import 'fonts'; 4 | @import 'layers'; 5 | @import 'sizes'; 6 | @import 'typography'; 7 | 8 | :export { 9 | // Any values that need to be accessible from JavaScript 10 | // outside of a Vue component can be defined here, prefixed 11 | // with `global-` to avoid conflicts with classes. For 12 | // example: 13 | // 14 | // global-grid-padding: $size-grid-padding; 15 | // 16 | // Then in a JavaScript file, you can import this object 17 | // as you would normally with: 18 | // 19 | // import design from '@design' 20 | // 21 | // console.log(design['global-grid-padding']) 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/design/_fonts.scss: -------------------------------------------------------------------------------- 1 | $system-default-font-family: -apple-system, 'BlinkMacSystemFont', 'Segoe UI', 2 | 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 3 | 'Segoe UI Symbol'; 4 | 5 | $heading-font-family: $system-default-font-family; 6 | $heading-font-weight: 600; 7 | 8 | $content-font-family: $system-default-font-family; 9 | $content-font-weight: 400; 10 | 11 | %font-heading { 12 | font-family: $heading-font-family; 13 | font-weight: $heading-font-weight; 14 | color: $color-heading-text; 15 | } 16 | 17 | %font-content { 18 | font-family: $content-font-family; 19 | font-weight: $content-font-weight; 20 | color: $color-text; 21 | } 22 | -------------------------------------------------------------------------------- /ui/generators/new/component/component.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.vue" 3 | --- 4 | <% 5 | if (blocks.indexOf('script') !== -1) { 6 | %> 14 | <% 15 | } 16 | 17 | if (blocks.indexOf('template') !== -1) { 18 | %> 19 | 22 | <% 23 | } 24 | 25 | if (blocks.indexOf('style') !== -1) { 26 | %> 27 | <% 30 | } 31 | %> 32 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/setup.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | -------------------------------------------------------------------------------- /ui/src/design/_sizes.scss: -------------------------------------------------------------------------------- 1 | // GRID 2 | $size-grid-padding: 1.3rem; 3 | 4 | // CONTENT 5 | $size-content-width-max: 50rem; 6 | $size-content-width-min: 25rem; 7 | 8 | // INPUTS 9 | $size-input-padding-vertical: 0.75em; 10 | $size-input-padding-horizontal: 1em; 11 | $size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal; 12 | $size-input-border: 1px; 13 | $size-input-border-radius: calc((1em + $size-input-padding-vertical * 2) / 10); 14 | 15 | // BUTTONS 16 | $size-button-padding-vertical: calc($size-grid-padding / 2); 17 | $size-button-padding-horizontal: calc($size-grid-padding / 1.5); 18 | $size-button-padding: $size-button-padding-vertical 19 | $size-button-padding-horizontal; 20 | -------------------------------------------------------------------------------- /ui/src/router/views/_404.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /ui/src/router/views/profile.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /ui/src/i18n.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueI18n from 'vue-i18n'; 3 | 4 | Vue.use(VueI18n); 5 | 6 | function loadLocaleMessages () { 7 | const locales = require.context('./locales', true, /[A-Za-z0-9-_,\s]+\.json$/i) 8 | const messages = {} 9 | locales.keys().forEach(key => { 10 | const matched = key.match(/([A-Za-z0-9-_]+)\./i) 11 | if (matched && matched.length > 1) { 12 | const locale = matched[1] 13 | messages[locale] = locales(key) 14 | } 15 | }) 16 | return messages 17 | } 18 | 19 | const i18n = new VueI18n({ 20 | locale: navigator.language.split('-')[0] || 'en', 21 | fallbackLocale: 'en', 22 | messages: loadLocaleMessages() 23 | }); 24 | 25 | export default i18n; -------------------------------------------------------------------------------- /ui/generators/new/view/view.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: "src/router/views/<%= h.changeCase.kebab(name) %>.vue" 3 | --- 4 | <% 5 | const fileName = h.changeCase.kebab(name) 6 | const importName = h.changeCase.pascal(fileName) 7 | const titleName = h.changeCase.title(name) 8 | %> 19 | 20 | 25 | <% 26 | 27 | if (useStyles) { %> 28 | 31 | <% } %> 32 | -------------------------------------------------------------------------------- /ui/tests/mock-api/routes/users.js: -------------------------------------------------------------------------------- 1 | const Users = require('../resources/users') 2 | 3 | module.exports = (app) => { 4 | app.get('/api/users/:username', (request, response) => { 5 | const currentUser = Users.findBy('token', request.headers.authorization) 6 | 7 | if (!currentUser) { 8 | return response.status(401).json({ 9 | message: 10 | 'The token is either invalid or has expired. Please log in again.', 11 | }) 12 | } 13 | 14 | const matchedUser = Users.findBy('username', request.params.username) 15 | 16 | if (!matchedUser) { 17 | return response.status(400).json({ 18 | message: 'No user with this name was found.', 19 | }) 20 | } 21 | 22 | response.json(matchedUser) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/utils/format-date.unit.js: -------------------------------------------------------------------------------- 1 | import formatDate from './format-date' 2 | 3 | describe('@utils/format-date', () => { 4 | it('correctly compares dates years apart', () => { 5 | const date = new Date(2002, 5, 1) 6 | const formattedDate = formatDate(date) 7 | expect(formattedDate).toEqual('Jun 1st, 2002') 8 | }) 9 | 10 | it('correctly compares dates months apart', () => { 11 | const date = new Date(2017, 8, 1) 12 | const formattedDate = formatDate(date) 13 | expect(formattedDate).toEqual('Sep 1st, 2017') 14 | }) 15 | 16 | it('correctly compares dates days apart', () => { 17 | const date = new Date(2017, 11, 11) 18 | const formattedDate = formatDate(date) 19 | expect(formattedDate).toEqual('Dec 11th, 2017') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module hammond 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/gin-contrib/location v0.0.2 8 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 // indirect 9 | github.com/gin-gonic/gin v1.7.1 10 | github.com/go-playground/validator/v10 v10.4.1 11 | github.com/jasonlvhit/gocron v0.0.1 12 | github.com/joho/godotenv v1.3.0 13 | github.com/leekchan/accounting v1.0.0 // indirect 14 | github.com/satori/go.uuid v1.2.0 15 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 16 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 18 | gorm.io/driver/mysql v1.0.5 19 | gorm.io/driver/sqlite v1.1.4 20 | gorm.io/gorm v1.21.3 21 | ) 22 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar.unit.js: -------------------------------------------------------------------------------- 1 | import NavBar from './nav-bar.vue' 2 | 3 | describe('@components/nav-bar', () => { 4 | it(`displays the user's name in the profile link`, () => { 5 | const { vm } = shallowMount( 6 | NavBar, 7 | createComponentMocks({ 8 | store: { 9 | auth: { 10 | state: { 11 | currentUser: { 12 | name: 'My Name', 13 | }, 14 | }, 15 | getters: { 16 | loggedIn: () => true, 17 | }, 18 | }, 19 | }, 20 | }) 21 | ) 22 | 23 | const profileRoute = vm.loggedInNavRoutes.find( 24 | (route) => route.name === 'profile' 25 | ) 26 | expect(profileRoute.title()).toEqual('Logged in as My Name') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /server/models/alert.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "hammond/db" 7 | ) 8 | 9 | type CreateAlertModel struct { 10 | Comments string `json:"comments"` 11 | Title string `json:"title"` 12 | StartDate time.Time `json:"date"` 13 | StartOdoReading int `json:"startOdoReading"` 14 | DistanceUnit *db.DistanceUnit `json:"distanceUnit"` 15 | AlertFrequency *db.AlertFrequency `json:"alertFrequency"` 16 | OdoFrequency int `json:"odoFrequency"` 17 | DayFrequency int `json:"dayFrequency"` 18 | AlertAllUsers bool `json:"alertAllUsers"` 19 | IsActive bool `json:"isActive"` 20 | AlertType *db.AlertType `json:"alertType"` 21 | } 22 | -------------------------------------------------------------------------------- /ui/.gitattributes: -------------------------------------------------------------------------------- 1 | # Fix end-of-lines in Git versions older than 2.10 2 | # https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248 3 | * text=auto eol=lf 4 | 5 | # === 6 | # Binary Files (don't diff, don't fix line endings) 7 | # === 8 | 9 | # Images 10 | *.png binary 11 | *.jpg binary 12 | *.jpeg binary 13 | *.gif binary 14 | *.ico binary 15 | *.tiff binary 16 | 17 | # Fonts 18 | *.oft binary 19 | *.ttf binary 20 | *.eot binary 21 | *.woff binary 22 | *.woff2 binary 23 | 24 | # Videos 25 | *.mov binary 26 | *.mp4 binary 27 | *.webm binary 28 | *.ogg binary 29 | *.mpg binary 30 | *.3gp binary 31 | *.avi binary 32 | *.wmv binary 33 | *.flv binary 34 | *.asf binary 35 | 36 | # Audio 37 | *.mp3 binary 38 | *.wav binary 39 | *.flac binary 40 | 41 | # Compressed 42 | *.gz binary 43 | *.zip binary 44 | *.7z binary 45 | -------------------------------------------------------------------------------- /server/service/miscService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "hammond/db" 5 | ) 6 | 7 | func CanInitializeSystem() (bool, error) { 8 | return db.CanInitializeSystem() 9 | } 10 | 11 | func UpdateSettings(currency string, distanceUnit db.DistanceUnit) error { 12 | setting := db.GetOrCreateSetting() 13 | setting.Currency = currency 14 | setting.DistanceUnit = distanceUnit 15 | return db.UpdateSettings(setting) 16 | } 17 | func UpdateUserSettings(userId, currency string, distanceUnit db.DistanceUnit, dateFormat string) error { 18 | user, err := db.GetUserById(userId) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | user.Currency = currency 24 | user.DistanceUnit = distanceUnit 25 | user.DateFormat = dateFormat 26 | return db.UpdateUser(user) 27 | } 28 | 29 | func GetSettings() *db.Setting { 30 | return db.GetOrCreateSetting() 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/components/_base-icon.unit.js: -------------------------------------------------------------------------------- 1 | import BaseIcon from './_base-icon.vue' 2 | 3 | describe('@components/_base-icon', () => { 4 | it('renders a font-awesome icon', () => { 5 | const { element } = mount(BaseIcon, { 6 | propsData: { 7 | name: 'sync', 8 | }, 9 | }) 10 | 11 | expect(element.tagName).toEqual('svg') 12 | expect(element.classList).toContain('svg-inline--fa', 'fa-sync', 'fa-w-16') 13 | }) 14 | 15 | it('renders a custom icon', () => { 16 | const { element } = shallowMount(BaseIcon, { 17 | ...createComponentMocks({ 18 | style: { 19 | iconCustomSomeIcon: 'generated-class-name', 20 | }, 21 | }), 22 | propsData: { 23 | source: 'custom', 24 | name: 'some-icon', 25 | }, 26 | }) 27 | 28 | expect(element.className).toEqual('generated-class-name') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /ui/src/router/views/_loading.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | 21 | 40 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= webpackConfig.name %> 10 | 11 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.15.2 2 | 3 | FROM golang:${GO_VERSION}-alpine AS builder 4 | 5 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/* 6 | 7 | RUN mkdir -p /api 8 | WORKDIR /api 9 | 10 | COPY go.mod . 11 | COPY go.sum . 12 | RUN go mod download 13 | 14 | COPY . . 15 | RUN go build -o ./app ./main.go 16 | 17 | FROM alpine:latest 18 | 19 | LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond" 20 | 21 | ENV CONFIG=/config 22 | ENV DATA=/assets 23 | ENV UID=998 24 | ENV PID=100 25 | ENV GIN_MODE=release 26 | VOLUME ["/config", "/assets"] 27 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 28 | RUN mkdir -p /config; \ 29 | mkdir -p /assets; \ 30 | mkdir -p /api 31 | 32 | RUN chmod 777 /config; \ 33 | chmod 777 /assets 34 | 35 | WORKDIR /api 36 | COPY --from=builder /api/app . 37 | COPY dist ./dist 38 | 39 | EXPOSE 3000 40 | 41 | ENTRYPOINT ["./app"] 42 | -------------------------------------------------------------------------------- /server/models/import.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ImportData struct { 4 | Data []ImportFillup `json:"data" binding:"required"` 5 | VehicleId string `json:"vehicleId" binding:"required"` 6 | TimeZone string `json:"timezone" binding:"required"` 7 | } 8 | 9 | type ImportFillup struct { 10 | VehicleID string `json:"vehicleId"` 11 | FuelQuantity float32 `json:"fuelQuantity"` 12 | PerUnitPrice float32 `json:"perUnitPrice"` 13 | TotalAmount float32 `json:"totalAmount"` 14 | OdoReading int `json:"odoReading"` 15 | IsTankFull *bool `json:"isTankFull"` 16 | HasMissedFillup *bool `json:"hasMissedFillup"` 17 | Comments string `json:"comments"` 18 | FillingStation string `json:"fillingStation"` 19 | UserID string `json:"userId"` 20 | Date string `json:"date"` 21 | FuelSubType string `json:"fuelSubType"` 22 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | *Before creating a bug report please make sure you are using the latest docker image / code base.* 11 | 12 | **Please complete the following information** 13 | - Installation Type: [Docker/Native] 14 | - Have you tried using the latest docker image / code base [yes/no] 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /ui/src/app.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 36 | -------------------------------------------------------------------------------- /ui/tests/e2e/specs/profile.e2e.js: -------------------------------------------------------------------------------- 1 | describe('Profile Page', () => { 2 | it('redirects to login when logged out', () => { 3 | cy.visit('/profile') 4 | cy.location('pathname').should('equal', '/login') 5 | }) 6 | 7 | it('nav link exists when logged in', () => { 8 | cy.logIn() 9 | cy.contains('a', 'Logged in as Vue Master').should( 10 | 'have.attr', 11 | 'href', 12 | '/profile' 13 | ) 14 | }) 15 | 16 | it('shows the current user profile when logged in', () => { 17 | cy.logIn() 18 | cy.visit('/profile') 19 | cy.contains('h1', 'Vue Master') 20 | }) 21 | 22 | it('shows non-current users at username routes when logged in', () => { 23 | cy.logIn() 24 | cy.visit('/profile/user1') 25 | cy.contains('h1', 'User One') 26 | }) 27 | 28 | it('shows a user 404 page when looking for a user that does not exist', () => { 29 | cy.logIn() 30 | cy.visit('/profile/non-existant-user') 31 | cy.contains('h1', /User\s+Not\s+Found/) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /ui/src/router/views/_timeout.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /ui/tests/mock-api/routes/auth.js: -------------------------------------------------------------------------------- 1 | const Users = require('../resources/users') 2 | 3 | module.exports = (app) => { 4 | // Log in a user with a username and password 5 | app.post('/api/session', (request, response) => { 6 | Users.authenticate(request.body) 7 | .then((user) => { 8 | response.json(user) 9 | }) 10 | .catch((error) => { 11 | response.status(401).json({ message: error.message }) 12 | }) 13 | }) 14 | 15 | // Get the user of a provided token, if valid 16 | app.get('/api/session', (request, response) => { 17 | const currentUser = Users.findBy('token', request.headers.authorization) 18 | 19 | if (!currentUser) { 20 | return response.status(401).json({ 21 | message: 22 | 'The token is either invalid or has expired. Please log in again.', 23 | }) 24 | } 25 | 26 | response.json(currentUser) 27 | }) 28 | 29 | // A simple ping for checking online status 30 | app.get('/api/ping', (request, response) => { 31 | response.send('OK') 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /ui/docs/editors.md: -------------------------------------------------------------------------------- 1 | # Editor integration 2 | 3 | - [Visual Studio Code](#visual-studio-code) 4 | - [Configuration](#configuration) 5 | - [FAQ](#faq) 6 | 7 | ## Visual Studio Code 8 | 9 | This project is best developed in VS Code. With the [recommended extensions](https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions) and settings in `.vscode`, you get: 10 | 11 | - Syntax highlighting for all files 12 | - Intellisense for all files 13 | - Lint-on-save for all files 14 | - In-editor results on save for unit tests 15 | 16 | ### Configuration 17 | 18 | To configure 19 | 20 | - `.vscode/extensions.json` 21 | - `.vscode/settings.json` 22 | 23 | ## FAQ 24 | 25 | **What kinds of editor settings and extensions should be added to the project?** 26 | 27 | All additions must: 28 | 29 | - be specific to this project 30 | - not interfere with any team member's workflow 31 | 32 | For example, an extension to add syntax highlighting for an included language will almost certainly be welcome, but a setting to change the editor's color theme wouldn't be appropriate. 33 | -------------------------------------------------------------------------------- /server/controllers/reports.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "hammond/common" 7 | "hammond/models" 8 | "hammond/service" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func RegisterReportsController(router *gin.RouterGroup) { 14 | router.GET("/vehicles/:id/mileage", getMileageForVehicle) 15 | } 16 | 17 | func getMileageForVehicle(c *gin.Context) { 18 | 19 | var searchByIdQuery models.SearchByIdQuery 20 | 21 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 22 | var model models.MileageQueryModel 23 | err := c.BindQuery(&model) 24 | if err != nil { 25 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err)) 26 | return 27 | } 28 | 29 | fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since, model.MileageOption) 30 | if err != nil { 31 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err)) 32 | return 33 | } 34 | c.JSON(http.StatusOK, fillups) 35 | } else { 36 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/tests/mock-api/resources/users.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = { 4 | all: [ 5 | { 6 | id: 1, 7 | username: 'admin', 8 | password: 'password', 9 | name: 'Vue Master', 10 | }, 11 | { 12 | id: 2, 13 | username: 'user1', 14 | password: 'password', 15 | name: 'User One', 16 | }, 17 | ].map((user) => { 18 | return { 19 | ...user, 20 | token: `valid-token-for-${user.username}`, 21 | } 22 | }), 23 | authenticate({ username, password }) { 24 | return new Promise((resolve, reject) => { 25 | const matchedUser = this.all.find( 26 | (user) => user.username === username && user.password === password 27 | ) 28 | if (matchedUser) { 29 | resolve(this.json(matchedUser)) 30 | } else { 31 | reject(new Error('Invalid user credentials.')) 32 | } 33 | }) 34 | }, 35 | findBy(propertyName, value) { 36 | const matchedUser = this.all.find((user) => user[propertyName] === value) 37 | return this.json(matchedUser) 38 | }, 39 | json(user) { 40 | return user && _.omit(user, ['password']) 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /server/db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type localMigration struct { 12 | Name string 13 | Query string 14 | } 15 | 16 | var migrations = []localMigration{ 17 | { 18 | Name: "2021_06_24_04_42_SetUserDisabledFalse", 19 | Query: "update users set is_disabled=0", 20 | }, 21 | { 22 | Name: "2021_02_07_00_09_LowerCaseEmails", 23 | Query: "update users set email=lower(email)", 24 | 25 | }, 26 | { 27 | Name: "2022_03_08_13_16_AddVIN", 28 | Query: "ALTER TABLE vehicles ADD COLUMN vin text", 29 | }, 30 | } 31 | 32 | func RunMigrations() { 33 | for _, mig := range migrations { 34 | ExecuteAndSaveMigration(mig.Name, mig.Query) 35 | } 36 | } 37 | func ExecuteAndSaveMigration(name string, query string) error { 38 | var migration Migration 39 | result := DB.Where("name=?", name).First(&migration) 40 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 41 | fmt.Println(query) 42 | result = DB.Debug().Exec(query) 43 | if result.Error == nil { 44 | DB.Save(&Migration{ 45 | Date: time.Now(), 46 | Name: name, 47 | }) 48 | } 49 | return result.Error 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.20.6 2 | FROM golang:${GO_VERSION}-alpine AS builder 3 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/* 4 | RUN mkdir -p /api 5 | WORKDIR /api 6 | COPY ./server/go.mod . 7 | COPY ./server/go.sum . 8 | RUN go mod download 9 | COPY ./server . 10 | RUN go build -o ./app ./main.go 11 | 12 | FROM node:16-alpine as build-stage 13 | WORKDIR /app 14 | COPY ./ui/package*.json ./ 15 | RUN apk add --no-cache autoconf automake build-base nasm libc6-compat python3 py3-pip make g++ libpng-dev zlib-dev pngquant 16 | 17 | RUN npm install 18 | COPY ./ui . 19 | RUN npm run build 20 | 21 | 22 | FROM alpine:latest 23 | LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond" 24 | ENV CONFIG=/config 25 | ENV DATA=/assets 26 | ENV UID=998 27 | ENV PID=100 28 | ENV GIN_MODE=release 29 | VOLUME ["/config", "/assets"] 30 | RUN apk update && apk add ca-certificates tzdata && rm -rf /var/cache/apk/* 31 | RUN mkdir -p /config; \ 32 | mkdir -p /assets; \ 33 | mkdir -p /api 34 | RUN chmod 777 /config; \ 35 | chmod 777 /assets 36 | WORKDIR /api 37 | COPY --from=builder /api/app . 38 | #COPY dist ./dist 39 | COPY --from=build-stage /app/dist ./dist 40 | EXPOSE 3000 41 | ENTRYPOINT ["./app"] 42 | -------------------------------------------------------------------------------- /ui/src/utils/format-date-relative.unit.js: -------------------------------------------------------------------------------- 1 | import formatDateRelative from './format-date-relative' 2 | 3 | describe('@utils/format-date-relative', () => { 4 | it('correctly compares dates years apart', () => { 5 | const fromDate = new Date(2002, 5, 1) 6 | const toDate = new Date(2017, 4, 10) 7 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 8 | expect(timeAgoInWords).toEqual('almost 15 years') 9 | }) 10 | 11 | it('correctly compares dates months apart', () => { 12 | const fromDate = new Date(2017, 8, 1) 13 | const toDate = new Date(2017, 11, 10) 14 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 15 | expect(timeAgoInWords).toEqual('3 months') 16 | }) 17 | 18 | it('correctly compares dates days apart', () => { 19 | const fromDate = new Date(2017, 11, 1) 20 | const toDate = new Date(2017, 11, 10) 21 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 22 | expect(timeAgoInWords).toEqual('9 days') 23 | }) 24 | 25 | it('compares to now when passed only one date', () => { 26 | const fromDate = new Date(2010, 11, 1) 27 | const timeAgoInWords = formatDateRelative(fromDate) 28 | expect(timeAgoInWords).toContain('years ago') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /ui/src/components/_base-icon.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 48 | -------------------------------------------------------------------------------- /ui/generators/new/component/prompt.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = [ 4 | { 5 | type: 'input', 6 | name: 'name', 7 | message: 'Name:', 8 | validate(value) { 9 | if (!value.length) { 10 | return 'Components must have a name.' 11 | } 12 | const fileName = _.kebabCase(value) 13 | if (fileName.indexOf('-') === -1) { 14 | return 'Component names should contain at least two words to avoid conflicts with existing and future HTML elements.' 15 | } 16 | return true 17 | }, 18 | }, 19 | { 20 | type: 'multiselect', 21 | name: 'blocks', 22 | message: 'Blocks:', 23 | initial: ['script', 'template', 'style'], 24 | choices: [ 25 | { 26 | name: 'script', 27 | message: ' 33 | 34 | 49 | -------------------------------------------------------------------------------- /ui/src/utils/dispatch-action-for-all-modules.js: -------------------------------------------------------------------------------- 1 | import allModules from '@state/modules' 2 | import store from '@state/store' 3 | 4 | export default function dispatchActionForAllModules( 5 | actionName, 6 | { modules = allModules, modulePrefix = '', flags = {} } = {} 7 | ) { 8 | // For every module... 9 | for (const moduleName in modules) { 10 | const moduleDefinition = modules[moduleName] 11 | 12 | // If the action is defined on the module... 13 | if (moduleDefinition.actions && moduleDefinition.actions[actionName]) { 14 | // Dispatch the action if the module is namespaced. Otherwise, 15 | // set a flag to dispatch the action globally at the end. 16 | if (moduleDefinition.namespaced) { 17 | store.dispatch(`${modulePrefix}${moduleName}/${actionName}`) 18 | } else { 19 | flags.dispatchGlobal = true 20 | } 21 | } 22 | 23 | // If there are any nested sub-modules... 24 | if (moduleDefinition.modules) { 25 | // Also dispatch the action for these sub-modules. 26 | dispatchActionForAllModules(actionName, { 27 | modules: moduleDefinition.modules, 28 | modulePrefix: modulePrefix + moduleName + '/', 29 | flags, 30 | }) 31 | } 32 | } 33 | 34 | // If this is the root and at least one non-namespaced module 35 | // was found with the action... 36 | if (!modulePrefix && flags.dispatchGlobal) { 37 | // Dispatch the action globally. 38 | store.dispatch(actionName) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path" 8 | 9 | "gorm.io/driver/sqlite" 10 | 11 | "gorm.io/gorm" 12 | ) 13 | 14 | //DB is 15 | var DB *gorm.DB 16 | 17 | //Init is used to Initialize Database 18 | func Init() (*gorm.DB, error) { 19 | // github.com/mattn/go-sqlite3 20 | configPath := os.Getenv("CONFIG") 21 | dbPath := path.Join(configPath, "hammond.db") 22 | log.Println(dbPath) 23 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ 24 | DisableForeignKeyConstraintWhenMigrating: true, 25 | }) 26 | if err != nil { 27 | fmt.Println("db err: ", err) 28 | return nil, err 29 | } 30 | 31 | localDB, _ := db.DB() 32 | localDB.SetMaxIdleConns(10) 33 | //db.LogMode(true) 34 | DB = db 35 | return DB, nil 36 | } 37 | 38 | //Migrate Database 39 | func Migrate() { 40 | err := DB.AutoMigrate(&Attachment{}, &QuickEntry{}, &User{}, &Vehicle{}, &UserVehicle{}, &VehicleAttachment{}, &Fillup{}, &Expense{}, &Setting{}, &JobLock{}, &Migration{}) 41 | if err != nil { 42 | fmt.Println("1 " + err.Error()) 43 | } 44 | err = DB.SetupJoinTable(&User{}, "Vehicles", &UserVehicle{}) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | } 48 | err = DB.SetupJoinTable(&Vehicle{}, "Attachments", &VehicleAttachment{}) 49 | if err != nil { 50 | fmt.Println(err.Error()) 51 | } 52 | RunMigrations() 53 | } 54 | 55 | // Using this function to get a connection, you can create your connection pool here. 56 | func GetDB() *gorm.DB { 57 | return DB 58 | } 59 | -------------------------------------------------------------------------------- /ui/src/state/modules/utils.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const state = { 4 | isMobile: false, 5 | settings: null, 6 | } 7 | export const mutations = { 8 | CACHE_ISMOBILE(state, isMobile) { 9 | state.isMobile = isMobile 10 | }, 11 | CACHE_SETTINGS(state, settings) { 12 | state.settings = settings 13 | }, 14 | } 15 | export const getters = {} 16 | export const actions = { 17 | init({ dispatch, rootState }) { 18 | dispatch('checkSize') 19 | const { currentUser } = rootState.auth 20 | if (currentUser) { 21 | dispatch('getSettings') 22 | } 23 | }, 24 | checkSize({ commit }) { 25 | commit('CACHE_ISMOBILE', window.innerWidth < 600) 26 | return window.innerWidth < 600 27 | }, 28 | getSettings({ commit }) { 29 | return axios.get(`/api/settings`).then((response) => { 30 | const data = response.data 31 | commit('CACHE_SETTINGS', data) 32 | return data 33 | }) 34 | }, 35 | saveSettings({ commit, dispatch }, { settings }) { 36 | return axios.post(`/api/settings`, { ...settings }).then((response) => { 37 | const data = response.data 38 | dispatch('getSettings') 39 | return data 40 | }) 41 | }, 42 | saveUserSettings({ commit, dispatch }, { settings }) { 43 | return axios.post(`/api/me/settings`, { ...settings }).then((response) => { 44 | const data = response.data 45 | dispatch('users/forceMe', {}, { root: true }).then((data) => {}) 46 | return data 47 | }) 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /server/service/genericImportService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "hammond/db" 5 | "hammond/models" 6 | "time" 7 | ) 8 | 9 | func GenericParseRefuelings(content []models.ImportFillup, user *db.User, vehicle *db.Vehicle, timezone string) ([]db.Fillup, []string) { 10 | var errors []string 11 | var fillups []db.Fillup 12 | dateLayout := "2006-01-02T15:04:05.000Z" 13 | loc, _ := time.LoadLocation(timezone) 14 | for _, record := range content { 15 | date, err := time.ParseInLocation(dateLayout, record.Date, loc) 16 | if err != nil { 17 | date = time.Date(2000, time.December, 0, 0, 0, 0, 0, loc) 18 | } 19 | 20 | var missedFillup bool 21 | if record.HasMissedFillup == nil { 22 | missedFillup = false 23 | } else { 24 | missedFillup = *record.HasMissedFillup 25 | } 26 | 27 | fillups = append(fillups, db.Fillup{ 28 | VehicleID: vehicle.ID, 29 | UserID: user.ID, 30 | Date: date, 31 | IsTankFull: record.IsTankFull, 32 | HasMissedFillup: &missedFillup, 33 | FuelQuantity: float32(record.FuelQuantity), 34 | PerUnitPrice: float32(record.PerUnitPrice), 35 | FillingStation: record.FillingStation, 36 | OdoReading: record.OdoReading, 37 | TotalAmount: float32(record.TotalAmount), 38 | FuelUnit: vehicle.FuelUnit, 39 | Currency: user.Currency, 40 | DistanceUnit: user.DistanceUnit, 41 | Comments: record.Comments, 42 | Source: "Generic Import", 43 | }) 44 | } 45 | 46 | return fillups, errors 47 | } 48 | -------------------------------------------------------------------------------- /server/models/report.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "hammond/db" 8 | ) 9 | 10 | type MileageModel struct { 11 | Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"` 12 | VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"` 13 | FuelUnit db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"` 14 | FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"` 15 | PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"` 16 | Currency string `json:"currency"` 17 | DistanceUnit db.DistanceUnit `form:"distanceUnit" json:"distanceUnit"` 18 | Mileage float32 `form:"mileage" json:"mileage" binding:"mileage"` 19 | CostPerMile float32 `form:"costPerMile" json:"costPerMile" binding:"costPerMile"` 20 | OdoReading int `form:"odoReading" json:"odoReading" binding:"odoReading"` 21 | } 22 | 23 | func (v *MileageModel) FuelUnitDetail() db.EnumDetail { 24 | return db.FuelUnitDetails[v.FuelUnit] 25 | } 26 | func (b *MileageModel) MarshalJSON() ([]byte, error) { 27 | return json.Marshal(struct { 28 | MileageModel 29 | FuelUnitDetail db.EnumDetail `json:"fuelUnitDetail"` 30 | }{ 31 | MileageModel: *b, 32 | FuelUnitDetail: b.FuelUnitDetail(), 33 | }) 34 | } 35 | 36 | type MileageQueryModel struct { 37 | Since time.Time `json:"since" query:"since" form:"since"` 38 | MileageOption string `json:"mileageOption" query:"mileageOption" form:"mileageOption"` 39 | } 40 | -------------------------------------------------------------------------------- /server/controllers/users.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "hammond/common" 7 | "hammond/db" 8 | "hammond/models" 9 | "hammond/service" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func RegisterUserController(router *gin.RouterGroup) { 15 | router.GET("/users", allUsers) 16 | router.POST("/users/:id/enable", ShouldBeAdmin(), enableUser) 17 | router.POST("/users/:id/disable", ShouldBeAdmin(), disableUser) 18 | } 19 | 20 | func allUsers(c *gin.Context) { 21 | users, err := db.GetAllUsers() 22 | if err != nil { 23 | c.JSON(http.StatusBadRequest, err) 24 | return 25 | } 26 | c.JSON(http.StatusOK, users) 27 | 28 | } 29 | func enableUser(c *gin.Context) { 30 | var searchByIdQuery models.SearchByIdQuery 31 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 32 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, false) 33 | if err != nil { 34 | c.JSON(http.StatusBadRequest, err) 35 | return 36 | } 37 | c.JSON(http.StatusOK, gin.H{}) 38 | } else { 39 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 40 | } 41 | 42 | } 43 | func disableUser(c *gin.Context) { 44 | var searchByIdQuery models.SearchByIdQuery 45 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 46 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, true) 47 | if err != nil { 48 | c.JSON(http.StatusBadRequest, err) 49 | return 50 | } 51 | c.JSON(http.StatusOK, gin.H{}) 52 | } else { 53 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /server/internal/sanitize/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Mechanism Design. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /ui/src/components/_base-input-text.unit.js: -------------------------------------------------------------------------------- 1 | import BaseInputText from './_base-input-text.vue' 2 | 3 | describe('@components/_base-input-text', () => { 4 | it('works with v-model', () => { 5 | const wrapper = mount(BaseInputText, { propsData: { value: 'aaa' } }) 6 | const inputWrapper = wrapper.find('input') 7 | const inputEl = inputWrapper.element 8 | 9 | // Has the correct starting value 10 | expect(inputEl.value).toEqual('aaa') 11 | 12 | // Emits an update event with the correct value when edited 13 | inputEl.value = 'bbb' 14 | inputWrapper.trigger('input') 15 | expect(wrapper.emitted().update).toEqual([['bbb']]) 16 | 17 | // Sets the input to the correct value when props change 18 | wrapper.setValue('ccc') 19 | expect(inputEl.value).toEqual('ccc') 20 | }) 21 | 22 | it('allows a type of "password"', () => { 23 | const consoleError = jest 24 | .spyOn(console, 'error') 25 | .mockImplementation(() => {}) 26 | mount(BaseInputText, { 27 | propsData: { value: 'aaa', type: 'password' }, 28 | }) 29 | expect(consoleError).not.toBeCalled() 30 | consoleError.mockRestore() 31 | }) 32 | 33 | it('does NOT allow a type of "checkbox"', () => { 34 | const consoleError = jest 35 | .spyOn(console, 'error') 36 | .mockImplementation(() => {}) 37 | mount(BaseInputText, { 38 | propsData: { value: 'aaa', type: 'checkbox' }, 39 | }) 40 | expect(consoleError.mock.calls[0][0]).toContain( 41 | 'custom validator check failed for prop "type"' 42 | ) 43 | consoleError.mockRestore() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /ui/vue.config.js: -------------------------------------------------------------------------------- 1 | const appConfig = require('./src/app.config') 2 | 3 | /** @type import('@vue/cli-service').ProjectOptions */ 4 | module.exports = { 5 | // https://github.com/neutrinojs/webpack-chain/tree/v4#getting-started 6 | chainWebpack(config) { 7 | // We provide the app's title in Webpack's name field, so that 8 | // it can be accessed in index.html to inject the correct title. 9 | config.set('name', appConfig.title) 10 | 11 | // Set up all the aliases we use in our app. 12 | config.resolve.alias.clear().merge(require('./aliases.config').webpack) 13 | 14 | // Don't allow importing .vue files without the extension, as 15 | // it's necessary for some Vetur autocompletions. 16 | config.resolve.extensions.delete('.vue') 17 | 18 | // Only enable performance hints for production builds, 19 | // outside of tests. 20 | config.performance.hints(process.env.NODE_ENV === 'production' && !process.env.VUE_APP_TEST && 'warning') 21 | }, 22 | css: { 23 | // Enable CSS source maps. 24 | sourceMap: true, 25 | }, 26 | // Configure Webpack's dev server. 27 | // https://cli.vuejs.org/guide/cli-service.html 28 | devServer: { 29 | ...(process.env.API_BASE_URL 30 | ? // Proxy API endpoints to the production base URL. 31 | { 32 | proxy: { 33 | '/api': { 34 | target: process.env.API_BASE_URL, 35 | // pathRewrite: { '^/api': '/' }, 36 | }, 37 | }, 38 | } 39 | : // Proxy API endpoints a local mock API. 40 | { before: require('./tests/mock-api') }), 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/hub.yml: -------------------------------------------------------------------------------- 1 | name: Build docker image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | multi: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Set up QEMU 14 | id: qemu 15 | uses: docker/setup-qemu-action@v2 16 | with: 17 | platforms: linux/amd64,linux/arm64,linux/arm/v7 18 | - name: Available platforms 19 | run: echo ${{ steps.qemu.outputs.platforms }} 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | - name: Parse the git tag 23 | id: get_tag 24 | run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3) 25 | - name: Login to DockerHub 26 | uses: docker/login-action@v2 27 | with: 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_TOKEN }} 30 | - name: Login to GitHub 31 | uses: docker/login-action@v1 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Build and push 37 | uses: docker/build-push-action@v4 38 | with: 39 | context: . 40 | file: ./Dockerfile 41 | platforms: linux/amd64,linux/arm64,linux/arm/v7 42 | push: true 43 | # cache-from: type=local,src=/tmp/.buildx-cache 44 | # cache-to: type=local,dest=/tmp/.buildx-cache 45 | tags: | 46 | alfhou/hammond:latest 47 | alfhou/hammond:${{ steps.get_tag.outputs.TAG }} 48 | ghcr.io/alfhou/hammond:latest 49 | ghcr.io/alfhou/hammond:${{ steps.get_tag.outputs.TAG }} 50 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar-routes.vue: -------------------------------------------------------------------------------- 1 | 63 | -------------------------------------------------------------------------------- /ui/src/state/modules/users.unit.js: -------------------------------------------------------------------------------- 1 | import * as usersModule from './users' 2 | 3 | describe('@state/modules/users', () => { 4 | it('exports a valid Vuex module', () => { 5 | expect(usersModule).toBeAVuexModule() 6 | }) 7 | 8 | describe('in a store when logged in', () => { 9 | let store 10 | beforeEach(() => { 11 | store = createModuleStore(usersModule, { 12 | currentUser: validUserExample, 13 | }) 14 | }) 15 | 16 | it('actions.fetchUser returns the current user without fetching it again', () => { 17 | expect.assertions(2) 18 | 19 | const axios = require('axios') 20 | const originalAxiosGet = axios.get 21 | axios.get = jest.fn() 22 | 23 | return store.dispatch('fetchUser', { username: 'admin' }).then((user) => { 24 | expect(user).toEqual(validUserExample) 25 | expect(axios.get).not.toHaveBeenCalled() 26 | axios.get = originalAxiosGet 27 | }) 28 | }) 29 | 30 | it('actions.fetchUser rejects with 400 when provided a bad username', () => { 31 | expect.assertions(1) 32 | 33 | return store 34 | .dispatch('fetchUser', { username: 'bad-username' }) 35 | .catch((error) => { 36 | expect(error.response.status).toEqual(400) 37 | }) 38 | }) 39 | }) 40 | 41 | describe('in a store when logged out', () => { 42 | let store 43 | beforeEach(() => { 44 | store = createModuleStore(usersModule) 45 | }) 46 | 47 | it('actions.fetchUser rejects with 401', () => { 48 | expect.assertions(1) 49 | 50 | return store 51 | .dispatch('fetchUser', { username: 'admin' }) 52 | .catch((error) => { 53 | expect(error.response.status).toEqual(401) 54 | }) 55 | }) 56 | }) 57 | }) 58 | 59 | const validUserExample = { 60 | id: 1, 61 | username: 'admin', 62 | name: 'Vue Master', 63 | token: 'valid-token-for-admin', 64 | } 65 | -------------------------------------------------------------------------------- /server/controllers/setup.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "hammond/common" 8 | "hammond/db" 9 | "hammond/models" 10 | "hammond/service" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func RegisterSetupController(router *gin.RouterGroup) { 16 | router.POST("/clarkson/check", canMigrate) 17 | router.POST("/clarkson/migrate", migrate) 18 | router.GET("/system/status", appInitialized) 19 | } 20 | 21 | func appInitialized(c *gin.Context) { 22 | canInitialize, err := service.CanInitializeSystem() 23 | message := "" 24 | if err != nil { 25 | message = err.Error() 26 | } 27 | c.JSON(http.StatusOK, gin.H{"initialized": !canInitialize, "message": message}) 28 | } 29 | 30 | func canMigrate(c *gin.Context) { 31 | var request models.ClarksonMigrationModel 32 | if err := c.ShouldBind(&request); err == nil { 33 | canMigrate, data, errr := db.CanMigrate(request.Url) 34 | errorMessage := "" 35 | if errr != nil { 36 | errorMessage = errr.Error() 37 | } 38 | 39 | c.JSON(http.StatusOK, gin.H{ 40 | "canMigrate": canMigrate, 41 | "data": data, 42 | "message": errorMessage, 43 | }) 44 | } else { 45 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 46 | } 47 | } 48 | 49 | func migrate(c *gin.Context) { 50 | var request models.ClarksonMigrationModel 51 | if err := c.ShouldBind(&request); err == nil { 52 | canMigrate, _, _ := db.CanMigrate(request.Url) 53 | 54 | if !canMigrate { 55 | c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string")) 56 | return 57 | } 58 | 59 | success, err := db.MigrateClarkson(request.Url) 60 | if !success { 61 | c.JSON(http.StatusBadRequest, err) 62 | return 63 | } 64 | 65 | c.JSON(http.StatusOK, gin.H{ 66 | "success": success, 67 | }) 68 | } else { 69 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/router/views/import.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 60 | -------------------------------------------------------------------------------- /ui/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | module.exports = (on, config) => { 3 | // Dynamic configuration 4 | // https://docs.cypress.io/guides/references/configuration.html 5 | return Object.assign({}, config, { 6 | // === 7 | // General 8 | // https://docs.cypress.io/guides/references/configuration.html#Global 9 | // === 10 | watchForFileChanges: true, 11 | // === 12 | // Environment variables 13 | // https://docs.cypress.io/guides/guides/environment-variables.html#Option-1-cypress-json 14 | // === 15 | env: { 16 | CI: process.env.CI, 17 | }, 18 | // === 19 | // Viewport 20 | // https://docs.cypress.io/guides/references/configuration.html#Viewport 21 | // === 22 | viewportWidth: 1280, 23 | viewportHeight: 720, 24 | // === 25 | // Animations 26 | // https://docs.cypress.io/guides/references/configuration.html#Animations 27 | // === 28 | waitForAnimations: true, 29 | animationDistanceThreshold: 4, 30 | // === 31 | // Timeouts 32 | // https://docs.cypress.io/guides/references/configuration.html#Timeouts 33 | // === 34 | defaultCommandTimeout: 4000, 35 | execTimeout: 60000, 36 | pageLoadTimeout: 60000, 37 | requestTimeout: 5000, 38 | responseTimeout: 30000, 39 | // === 40 | // Main Directories 41 | // https://docs.cypress.io/guides/references/configuration.html#Folders-Files 42 | // === 43 | supportFile: 'tests/e2e/support/setup.js', 44 | integrationFolder: 'tests/e2e/specs', 45 | fixturesFolder: 'tests/e2e/fixtures', 46 | // === 47 | // Videos & Screenshots 48 | // https://docs.cypress.io/guides/core-concepts/screenshots-and-videos.html 49 | // === 50 | videoUploadOnPasses: true, 51 | videoCompression: 32, 52 | videosFolder: 'tests/e2e/videos', 53 | screenshotsFolder: 'tests/e2e/screenshots', 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /server/db/enums.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type FuelUnit int 4 | 5 | const ( 6 | LITRE FuelUnit = iota 7 | GALLON 8 | US_GALLON 9 | KILOGRAM 10 | KILOWATT_HOUR 11 | MINUTE 12 | ) 13 | 14 | type FuelType int 15 | 16 | const ( 17 | PETROL FuelType = iota 18 | DIESEL 19 | ETHANOL 20 | CNG 21 | ELECTRIC 22 | LPG 23 | ) 24 | 25 | type DistanceUnit int 26 | 27 | const ( 28 | MILES DistanceUnit = iota 29 | KILOMETERS 30 | ) 31 | 32 | type Role int 33 | 34 | const ( 35 | ADMIN Role = iota 36 | USER 37 | ) 38 | 39 | type AlertFrequency int 40 | 41 | const ( 42 | ONETIME AlertFrequency = iota 43 | RECURRING 44 | ) 45 | 46 | type AlertType int 47 | 48 | const ( 49 | DISTANCE AlertType = iota 50 | TIME 51 | BOTH 52 | ) 53 | 54 | type EnumDetail struct { 55 | Key string `json:"key"` 56 | } 57 | 58 | var FuelUnitDetails map[FuelUnit]EnumDetail = map[FuelUnit]EnumDetail{ 59 | LITRE: { 60 | Key: "litre", 61 | }, 62 | GALLON: { 63 | Key: "gallon", 64 | }, KILOGRAM: { 65 | Key: "kilogram", 66 | }, KILOWATT_HOUR: { 67 | Key: "kilowatthour", 68 | }, US_GALLON: { 69 | Key: "usgallon", 70 | }, 71 | MINUTE: { 72 | Key: "minutes", 73 | }, 74 | } 75 | 76 | var FuelTypeDetails map[FuelType]EnumDetail = map[FuelType]EnumDetail{ 77 | PETROL: { 78 | Key: "petrol", 79 | }, 80 | DIESEL: { 81 | Key: "diesel", 82 | }, CNG: { 83 | Key: "cng", 84 | }, LPG: { 85 | Key: "lpg", 86 | }, ELECTRIC: { 87 | Key: "electric", 88 | }, ETHANOL: { 89 | Key: "ethanol", 90 | }, 91 | } 92 | 93 | var DistanceUnitDetails map[DistanceUnit]EnumDetail = map[DistanceUnit]EnumDetail{ 94 | KILOMETERS: { 95 | Key: "kilometers", 96 | }, 97 | MILES: { 98 | Key: "miles", 99 | }, 100 | } 101 | 102 | var RoleDetails map[Role]EnumDetail = map[Role]EnumDetail{ 103 | ADMIN: { 104 | Key: "ADMIN", 105 | }, 106 | USER: { 107 | Key: "USER", 108 | }, 109 | } 110 | -------------------------------------------------------------------------------- /ui/src/components/quickEntryDisplay.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 69 | -------------------------------------------------------------------------------- /server/controllers/masters.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "hammond/common" 7 | "hammond/db" 8 | "hammond/models" 9 | "hammond/service" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func RegisterAnonMasterConroller(router *gin.RouterGroup) { 15 | router.GET("/masters", func(c *gin.Context) { 16 | c.JSON(http.StatusOK, gin.H{ 17 | "fuelUnits": db.FuelUnitDetails, 18 | "fuelTypes": db.FuelTypeDetails, 19 | "distanceUnits": db.DistanceUnitDetails, 20 | "roles": db.RoleDetails, 21 | "currencies": models.GetCurrencyMasterList(), 22 | }) 23 | }) 24 | } 25 | func RegisterMastersController(router *gin.RouterGroup) { 26 | 27 | router.GET("/settings", getSettings) 28 | router.POST("/settings", udpateSettings) 29 | router.POST("/me/settings", udpateMySettings) 30 | 31 | } 32 | 33 | func getSettings(c *gin.Context) { 34 | 35 | c.JSON(http.StatusOK, service.GetSettings()) 36 | } 37 | func udpateSettings(c *gin.Context) { 38 | var model models.UpdateSettingModel 39 | if err := c.ShouldBind(&model); err == nil { 40 | err := service.UpdateSettings(model.Currency, *model.DistanceUnit) 41 | if err != nil { 42 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateSettings", err)) 43 | return 44 | } 45 | c.JSON(http.StatusOK, gin.H{}) 46 | } else { 47 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 48 | } 49 | 50 | } 51 | 52 | func udpateMySettings(c *gin.Context) { 53 | var model models.UpdateSettingModel 54 | if err := c.ShouldBind(&model); err == nil { 55 | err := service.UpdateUserSettings(c.MustGet("userId").(string), model.Currency, *model.DistanceUnit, model.DateFormat) 56 | if err != nil { 57 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateMySettings", err)) 58 | return 59 | } 60 | c.JSON(http.StatusOK, gin.H{}) 61 | } else { 62 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /ui/src/components/mileageChart.vue: -------------------------------------------------------------------------------- 1 | 75 | -------------------------------------------------------------------------------- /ui/aliases.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const prettier = require('prettier') 4 | 5 | const aliases = { 6 | '@': '.', 7 | '@src': 'src', 8 | '@router': 'src/router', 9 | '@views': 'src/router/views', 10 | '@layouts': 'src/router/layouts', 11 | '@components': 'src/components', 12 | '@assets': 'src/assets', 13 | '@utils': 'src/utils', 14 | '@state': 'src/state', 15 | '@design': 'src/design/index.scss', 16 | } 17 | 18 | module.exports = { 19 | webpack: {}, 20 | jest: {}, 21 | jsconfig: {}, 22 | } 23 | 24 | for (const alias in aliases) { 25 | const aliasTo = aliases[alias] 26 | module.exports.webpack[alias] = resolveSrc(aliasTo) 27 | const aliasHasExtension = /\.\w+$/.test(aliasTo) 28 | module.exports.jest[`^${alias}$`] = aliasHasExtension 29 | ? `/${aliasTo}` 30 | : `/${aliasTo}/index.js` 31 | module.exports.jest[`^${alias}/(.*)$`] = `/${aliasTo}/$1` 32 | module.exports.jsconfig[alias + '/*'] = [aliasTo + '/*'] 33 | module.exports.jsconfig[alias] = aliasTo.includes('/index.') 34 | ? [aliasTo] 35 | : [ 36 | aliasTo + '/index.js', 37 | aliasTo + '/index.json', 38 | aliasTo + '/index.vue', 39 | aliasTo + '/index.scss', 40 | aliasTo + '/index.css', 41 | ] 42 | } 43 | 44 | const jsconfigTemplate = require('./jsconfig.template') || {} 45 | const jsconfigPath = path.resolve(__dirname, 'jsconfig.json') 46 | 47 | fs.writeFile( 48 | jsconfigPath, 49 | prettier.format( 50 | JSON.stringify({ 51 | ...jsconfigTemplate, 52 | compilerOptions: { 53 | ...(jsconfigTemplate.compilerOptions || {}), 54 | paths: module.exports.jsconfig, 55 | }, 56 | }), 57 | { 58 | ...require('./.prettierrc'), 59 | parser: 'json', 60 | } 61 | ), 62 | (error) => { 63 | if (error) { 64 | console.error( 65 | 'Error while creating jsconfig.json from aliases.config.js.' 66 | ) 67 | throw error 68 | } 69 | } 70 | ) 71 | 72 | function resolveSrc(_path) { 73 | return path.resolve(__dirname, _path) 74 | } 75 | -------------------------------------------------------------------------------- /server/controllers/import.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "hammond/models" 8 | "hammond/service" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func RegisteImportController(router *gin.RouterGroup) { 14 | router.POST("/import/fuelly", fuellyImport) 15 | router.POST("/import/drivvo", drivvoImport) 16 | router.POST("/import/generic", genericImport) 17 | } 18 | 19 | func fuellyImport(c *gin.Context) { 20 | bytes, err := getFileBytes(c, "file") 21 | if err != nil { 22 | c.JSON(http.StatusUnprocessableEntity, err) 23 | return 24 | } 25 | errors := service.FuellyImport(bytes, c.MustGet("userId").(string)) 26 | if len(errors) > 0 { 27 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors}) 28 | return 29 | } 30 | c.JSON(http.StatusOK, gin.H{}) 31 | } 32 | 33 | func drivvoImport(c *gin.Context) { 34 | bytes, err := getFileBytes(c, "file") 35 | if err != nil { 36 | c.JSON(http.StatusUnprocessableEntity, err) 37 | return 38 | } 39 | vehicleId := c.PostForm("vehicleID") 40 | if vehicleId == "" { 41 | c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID") 42 | return 43 | } 44 | importLocation, err := strconv.ParseBool(c.PostForm("importLocation")) 45 | if err != nil { 46 | c.JSON(http.StatusUnprocessableEntity, "Please include importLocation option.") 47 | return 48 | } 49 | 50 | errors := service.DrivvoImport(bytes, c.MustGet("userId").(string), vehicleId, importLocation) 51 | if len(errors) > 0 { 52 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors}) 53 | return 54 | } 55 | c.JSON(http.StatusOK, gin.H{}) 56 | } 57 | 58 | func genericImport(c *gin.Context) { 59 | var json models.ImportData 60 | if err := c.ShouldBindJSON(&json); err != nil { 61 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 62 | return 63 | } 64 | if json.VehicleId == "" { 65 | c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID") 66 | return 67 | } 68 | errors := service.GenericImport(json, c.MustGet("userId").(string)) 69 | if len(errors) > 0 { 70 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors}) 71 | return 72 | } 73 | c.JSON(http.StatusOK, gin.H{}) 74 | } 75 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Buefy from 'buefy' 3 | import router from '@router' 4 | import store from '@state/store' 5 | import { library } from '@fortawesome/fontawesome-svg-core' 6 | import { 7 | faCheck, 8 | faTimes, 9 | faArrowUp, 10 | faArrowRotateLeft, 11 | faAngleLeft, 12 | faAngleRight, 13 | faCalendar, 14 | faEdit, 15 | faAngleDown, 16 | faAngleUp, 17 | faUpload, 18 | faExclamationCircle, 19 | faDownload, 20 | faEye, 21 | faEyeSlash, 22 | faTrash, 23 | faShare, 24 | faUserFriends, 25 | faTimesCircle, 26 | } from '@fortawesome/free-solid-svg-icons' 27 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 28 | import i18n from './i18n'; 29 | 30 | import App from './app.vue' 31 | 32 | // Globally register all `_base`-prefixed components 33 | import '@components/_globals' 34 | 35 | import 'buefy/dist/buefy.css' 36 | import 'nprogress/nprogress.css' 37 | 38 | library.add( 39 | faCheck, 40 | faTimes, 41 | faArrowUp, 42 | faArrowRotateLeft, 43 | faAngleLeft, 44 | faAngleRight, 45 | faCalendar, 46 | faEdit, 47 | faAngleDown, 48 | faAngleUp, 49 | faUpload, 50 | faExclamationCircle, 51 | faDownload, 52 | faEye, 53 | faEyeSlash, 54 | faTrash, 55 | faShare, 56 | faUserFriends, 57 | faTimesCircle 58 | ); 59 | Vue.component('VueFontawesome', FontAwesomeIcon) 60 | 61 | Vue.use(Buefy, { 62 | defaultIconComponent: 'vue-fontawesome', 63 | defaultIconPack: 'fas', 64 | }) 65 | 66 | // Don't warn about using the dev version of Vue in development. 67 | Vue.config.productionTip = process.env.NODE_ENV === 'production' 68 | 69 | // If running inside Cypress... 70 | if (process.env.VUE_APP_TEST === 'e2e') { 71 | // Ensure tests fail when Vue emits an error. 72 | Vue.config.errorHandler = window.Cypress.cy.onUncaughtException 73 | } 74 | 75 | const app = new Vue({ 76 | router, 77 | store, 78 | 79 | render: (h) => h(App), 80 | i18n, 81 | }).$mount('#app') 82 | 83 | // If running e2e tests... 84 | if (process.env.VUE_APP_TEST === 'e2e') { 85 | // Attach the app to the window, which can be useful 86 | // for manually setting state in Cypress commands 87 | // such as `cy.logIn()`. 88 | window.__app__ = app 89 | } 90 | -------------------------------------------------------------------------------- /ui/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | // Use the Standard config as the base 4 | // https://github.com/stylelint/stylelint-config-standard 5 | 'stylelint-config-standard', 6 | // Enforce a standard order for CSS properties 7 | // https://github.com/stormwarning/stylelint-config-recess-order 8 | 'stylelint-config-recess-order', 9 | // Override rules that would interfere with Prettier 10 | // https://github.com/shannonmoeller/stylelint-config-prettier 11 | 'stylelint-config-prettier', 12 | // Override rules to allow linting of CSS modules 13 | // https://github.com/pascalduez/stylelint-config-css-modules 14 | 'stylelint-config-css-modules', 15 | ], 16 | plugins: [ 17 | // Bring in some extra rules for SCSS 18 | 'stylelint-scss', 19 | ], 20 | // Rule lists: 21 | // - https://stylelint.io/user-guide/rules/ 22 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules 23 | rules: { 24 | // Allow newlines inside class attribute values 25 | 'string-no-newline': null, 26 | // Enforce camelCase for classes and ids, to work better 27 | // with CSS modules 28 | 'selector-class-pattern': /^[a-z][a-zA-Z]*(-(enter|leave)(-(active|to))?)?$/, 29 | 'selector-id-pattern': /^[a-z][a-zA-Z]*$/, 30 | // Limit the number of universal selectors in a selector, 31 | // to avoid very slow selectors 32 | 'selector-max-universal': 1, 33 | // Disallow allow global element/type selectors in scoped modules 34 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }], 35 | // === 36 | // SCSS 37 | // === 38 | 'scss/dollar-variable-colon-space-after': 'always', 39 | 'scss/dollar-variable-colon-space-before': 'never', 40 | 'scss/dollar-variable-no-missing-interpolation': true, 41 | 'scss/dollar-variable-pattern': /^[a-z-]+$/, 42 | 'scss/double-slash-comment-whitespace-inside': 'always', 43 | 'scss/operator-no-newline-before': true, 44 | 'scss/operator-no-unspaced': true, 45 | 'scss/selector-no-redundant-nesting-selector': true, 46 | // Allow SCSS and CSS module keywords beginning with `@` 47 | 'at-rule-no-unknown': null, 48 | 'scss/at-rule-no-unknown': true, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "hammond/controllers" 9 | "hammond/db" 10 | "hammond/service" 11 | 12 | "github.com/gin-contrib/location" 13 | "github.com/gin-gonic/contrib/static" 14 | "github.com/gin-gonic/gin" 15 | "github.com/jasonlvhit/gocron" 16 | _ "github.com/joho/godotenv/autoload" 17 | ) 18 | 19 | func main() { 20 | var err error 21 | db.DB, err = db.Init() 22 | if err != nil { 23 | fmt.Println("status: ", err) 24 | } else { 25 | db.Migrate() 26 | } 27 | r := gin.Default() 28 | 29 | r.Use(setupSettings()) 30 | r.Use(gin.Recovery()) 31 | r.Use(location.Default()) 32 | r.Use(static.Serve("/", static.LocalFile("./dist", true))) 33 | r.NoRoute(func(c *gin.Context) { 34 | //fmt.'Println(c.Request.URL.Path) 35 | c.File("dist/index.html") 36 | }) 37 | router := r.Group("/api") 38 | 39 | dataPath := os.Getenv("DATA") 40 | 41 | router.Static("/assets/", dataPath) 42 | 43 | controllers.RegisterAnonController(router) 44 | controllers.RegisterAnonMasterConroller(router) 45 | controllers.RegisterSetupController(router) 46 | 47 | router.Use(controllers.AuthMiddleware(true)) 48 | controllers.RegisterUserController(router) 49 | controllers.RegisterMastersController(router) 50 | controllers.RegisterAuthController(router) 51 | controllers.RegisterVehicleController(router) 52 | controllers.RegisterFilesController(router) 53 | controllers.RegisteImportController(router) 54 | controllers.RegisterReportsController(router) 55 | 56 | go assetEnv() 57 | go intiCron() 58 | 59 | r.Run(":3000") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080") 60 | 61 | } 62 | func setupSettings() gin.HandlerFunc { 63 | return func(c *gin.Context) { 64 | 65 | setting := db.GetOrCreateSetting() 66 | c.Set("setting", setting) 67 | c.Writer.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett") 68 | 69 | c.Next() 70 | } 71 | } 72 | 73 | func intiCron() { 74 | 75 | //gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingEpisodes) 76 | gocron.Every(2).Days().Do(service.CreateBackup) 77 | <-gocron.Start() 78 | } 79 | 80 | func assetEnv() { 81 | log.Println("Config Dir: ", os.Getenv("CONFIG")) 82 | log.Println("Assets Dir: ", os.Getenv("DATA")) 83 | } 84 | -------------------------------------------------------------------------------- /server/internal/sanitize/README.md: -------------------------------------------------------------------------------- 1 | sanitize [![GoDoc](https://godoc.org/github.com/kennygrant/sanitize?status.svg)](https://godoc.org/github.com/kennygrant/sanitize) [![Go Report Card](https://goreportcard.com/badge/github.com/kennygrant/sanitize)](https://goreportcard.com/report/github.com/kennygrant/sanitize) [![CircleCI](https://circleci.com/gh/kennygrant/sanitize.svg?style=svg)](https://circleci.com/gh/kennygrant/sanitize) 2 | ======== 3 | 4 | Package sanitize provides functions to sanitize html and paths with go (golang). 5 | 6 | FUNCTIONS 7 | 8 | 9 | ```go 10 | sanitize.Accents(s string) string 11 | ``` 12 | 13 | Accents replaces a set of accented characters with ascii equivalents. 14 | 15 | ```go 16 | sanitize.BaseName(s string) string 17 | ``` 18 | 19 | BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -. Unlike Name no attempt is made to normalise text as a path. 20 | 21 | ```go 22 | sanitize.HTML(s string) string 23 | ``` 24 | 25 | HTML strips html tags with a very simple parser, replace common entities, and escape < and > in the result. The result is intended to be used as plain text. 26 | 27 | ```go 28 | sanitize.HTMLAllowing(s string, args...[]string) (string, error) 29 | ``` 30 | 31 | HTMLAllowing parses html and allow certain tags and attributes from the lists optionally specified by args - args[0] is a list of allowed tags, args[1] is a list of allowed attributes. If either is missing default sets are used. 32 | 33 | ```go 34 | sanitize.Name(s string) string 35 | ``` 36 | 37 | Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters. 38 | 39 | ```go 40 | sanitize.Path(s string) string 41 | ``` 42 | 43 | Path makes a string safe to use as an url path. 44 | 45 | 46 | Changes 47 | ------- 48 | 49 | Version 1.2 50 | 51 | Adjusted HTML function to avoid linter warning 52 | Added more tests from https://githubengineering.com/githubs-post-csp-journey/ 53 | Chnaged name of license file 54 | Added badges and change log to readme 55 | 56 | Version 1.1 57 | Fixed type in comments. 58 | Merge pull request from Povilas Balzaravicius Pawka 59 | - replace br tags with newline even when they contain a space 60 | 61 | Version 1.0 62 | First release -------------------------------------------------------------------------------- /server/controllers/middlewares.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strings" 7 | 8 | "hammond/db" 9 | 10 | "github.com/dgrijalva/jwt-go" 11 | "github.com/dgrijalva/jwt-go/request" 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // Strips 'BEARER ' prefix from token string 16 | func stripBearerPrefixFromTokenString(tok string) (string, error) { 17 | // Should be a bearer token 18 | if len(tok) > 6 && strings.ToUpper(tok[0:6]) == "BEARER " { 19 | return tok[7:], nil 20 | } 21 | return tok, nil 22 | } 23 | 24 | // Extract token from Authorization header 25 | // Uses PostExtractionFilter to strip "TOKEN " prefix from header 26 | var AuthorizationHeaderExtractor = &request.PostExtractionFilter{ 27 | Extractor: request.HeaderExtractor{"Authorization"}, 28 | Filter: stripBearerPrefixFromTokenString, 29 | } 30 | 31 | // Extractor for OAuth2 access tokens. Looks in 'Authorization' 32 | // header then 'access_token' argument for a token. 33 | var MyAuth2Extractor = &request.MultiExtractor{ 34 | AuthorizationHeaderExtractor, 35 | request.ArgumentExtractor{"access_token"}, 36 | } 37 | 38 | // A helper to write user_id and user_model to the context 39 | func UpdateContextUserModel(c *gin.Context, my_user_id string) { 40 | var myUserModel db.User 41 | if my_user_id != "" { 42 | 43 | db.DB.First(&myUserModel, map[string]string{ 44 | "ID": my_user_id, 45 | }) 46 | } 47 | c.Set("userId", my_user_id) 48 | c.Set("userModel", myUserModel) 49 | } 50 | 51 | // You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware 52 | // r.Use(AuthMiddleware(true)) 53 | func AuthMiddleware(auto401 bool) gin.HandlerFunc { 54 | return func(c *gin.Context) { 55 | UpdateContextUserModel(c, "") 56 | token, err := request.ParseFromRequest(c.Request, MyAuth2Extractor, func(token *jwt.Token) (interface{}, error) { 57 | b := ([]byte(os.Getenv("JWT_SECRET"))) 58 | return b, nil 59 | }) 60 | if err != nil { 61 | if auto401 { 62 | c.AbortWithError(http.StatusUnauthorized, err) 63 | } 64 | return 65 | } 66 | if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { 67 | my_user_id := claims["id"].(string) 68 | //fmt.Println(my_user_id,claims["id"]) 69 | UpdateContextUserModel(c, my_user_id) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | // Use a random port number for the mock API by default, 3 | // to support multiple instances of Jest running 4 | // simultaneously, like during pre-commit lint. 5 | process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999) 6 | 7 | module.exports = { 8 | setupFiles: ['/tests/unit/setup'], 9 | globalSetup: '/tests/unit/global-setup', 10 | globalTeardown: '/tests/unit/global-teardown', 11 | setupFilesAfterEnv: ['/tests/unit/matchers'], 12 | testMatch: ['**/(*.)unit.js'], 13 | moduleFileExtensions: ['js', 'json', 'vue'], 14 | transform: { 15 | '^.+\\.vue$': 'vue-jest', 16 | '^.+\\.js$': 'babel-jest', 17 | '.+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$': 18 | 'jest-transform-stub', 19 | }, 20 | moduleNameMapper: require('./aliases.config').jest, 21 | snapshotSerializers: ['jest-serializer-vue'], 22 | coverageDirectory: '/tests/unit/coverage', 23 | collectCoverageFrom: [ 24 | 'src/**/*.{js,vue}', 25 | '!**/node_modules/**', 26 | '!src/main.js', 27 | '!src/app.vue', 28 | '!src/router/index.js', 29 | '!src/router/routes.js', 30 | '!src/state/store.js', 31 | '!src/state/helpers.js', 32 | '!src/state/modules/index.js', 33 | '!src/components/_globals.js', 34 | ], 35 | // https://facebook.github.io/jest/docs/en/configuration.html#testurl-string 36 | // Set the `testURL` to a provided base URL if one exists, or the mock API base URL 37 | // Solves: https://stackoverflow.com/questions/42677387/jest-returns-network-error-when-doing-an-authenticated-request-with-axios 38 | testURL: 39 | process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`, 40 | // https://github.com/jest-community/jest-watch-typeahead 41 | watchPlugins: [ 42 | 'jest-watch-typeahead/filename', 43 | 'jest-watch-typeahead/testname', 44 | ], 45 | globals: { 46 | 'vue-jest': { 47 | // Compilation errors in the 122 | -------------------------------------------------------------------------------- /server/common/utils.go: -------------------------------------------------------------------------------- 1 | // Common tools and helper functions 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "time" 9 | 10 | "hammond/db" 11 | 12 | "github.com/dgrijalva/jwt-go" 13 | "github.com/gin-gonic/gin" 14 | "github.com/gin-gonic/gin/binding" 15 | "github.com/go-playground/validator/v10" 16 | ) 17 | 18 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 19 | 20 | // A helper function to generate random string 21 | func RandString(n int) string { 22 | b := make([]rune, n) 23 | for i := range b { 24 | b[i] = letters[rand.Intn(len(letters))] 25 | } 26 | return string(b) 27 | } 28 | 29 | // A helper to convert from litres to gallon 30 | func LitreToGallon(litres float32) float32 { 31 | gallonConversionFactor := 0.21997 32 | return litres * float32(gallonConversionFactor); 33 | } 34 | 35 | // A helper to convert from gallon to litres 36 | func GallonToLitre(gallons float32) float32 { 37 | litreConversionFactor := 3.785412 38 | return gallons * float32(litreConversionFactor); 39 | } 40 | 41 | 42 | // A helper to convert from km to miles 43 | func KmToMiles(km float32) float32 { 44 | kmConversionFactor := 0.62137119 45 | return km * float32(kmConversionFactor); 46 | } 47 | 48 | // A helper to convert from miles to km 49 | func MilesToKm(miles float32) float32 { 50 | milesConversionFactor := 1.609344 51 | return miles * float32(milesConversionFactor); 52 | } 53 | 54 | 55 | 56 | // A Util function to generate jwt_token which can be used in the request header 57 | func GenToken(id string, role db.Role) (string, string) { 58 | jwt_token := jwt.New(jwt.GetSigningMethod("HS256")) 59 | // Set some claims 60 | jwt_token.Claims = jwt.MapClaims{ 61 | "id": id, 62 | "role": role, 63 | "exp": time.Now().Add(time.Hour * 24 * 3).Unix(), 64 | } 65 | // Sign and get the complete encoded token as a string 66 | token, _ := jwt_token.SignedString([]byte(os.Getenv("JWT_SECRET"))) 67 | 68 | refreshToken := jwt.New(jwt.SigningMethodHS256) 69 | rtClaims := refreshToken.Claims.(jwt.MapClaims) 70 | rtClaims["id"] = id 71 | rtClaims["exp"] = time.Now().Add(time.Hour * 24 * 20).Unix() 72 | 73 | rt, _ := refreshToken.SignedString([]byte(os.Getenv("JWT_SECRET"))) 74 | 75 | return token, rt 76 | } 77 | 78 | // My own Error type that will help return my customized Error info 79 | // {"database": {"hello":"no such table", error: "not_exists"}} 80 | type CommonError struct { 81 | Errors map[string]interface{} `json:"errors"` 82 | } 83 | 84 | // To handle the error returned by c.Bind in gin framework 85 | // https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go 86 | func NewValidatorError(err error) CommonError { 87 | res := CommonError{} 88 | res.Errors = make(map[string]interface{}) 89 | errs := err.(validator.ValidationErrors) 90 | for _, v := range errs { 91 | // can translate each error one at a time. 92 | //fmt.Println("gg",v.NameNamespace) 93 | if v.Param() != "" { 94 | res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param()) 95 | } else { 96 | res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag()) 97 | } 98 | 99 | } 100 | return res 101 | } 102 | 103 | // Warp the error info in a object 104 | func NewError(key string, err error) CommonError { 105 | res := CommonError{} 106 | res.Errors = make(map[string]interface{}) 107 | res.Errors[key] = err.Error() 108 | return res 109 | } 110 | 111 | // Changed the c.MustBindWith() -> c.ShouldBindWith(). 112 | // I don't want to auto return 400 when error happened. 113 | // origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go 114 | func Bind(c *gin.Context, obj interface{}) error { 115 | b := binding.Default(c.Request.Method, c.ContentType()) 116 | return c.ShouldBindWith(obj, b) 117 | } 118 | -------------------------------------------------------------------------------- /ui/src/components/_base-link.unit.js: -------------------------------------------------------------------------------- 1 | import BaseLink from './_base-link.vue' 2 | 3 | const mountBaseLink = (options = {}) => { 4 | return mount(BaseLink, { 5 | stubs: { 6 | RouterLink: { 7 | functional: true, 8 | render(h, { slots, data }) { 9 | return {slots().default} 10 | }, 11 | }, 12 | }, 13 | slots: { 14 | default: 'hello', 15 | }, 16 | ...options, 17 | }) 18 | } 19 | 20 | describe('@components/_base-link', () => { 21 | const originalConsoleWarn = global.console.warn 22 | let warning 23 | beforeEach(() => { 24 | warning = undefined 25 | global.console.warn = jest.fn((text) => { 26 | warning = text 27 | }) 28 | }) 29 | afterAll(() => { 30 | global.console.warn = originalConsoleWarn 31 | }) 32 | 33 | it('exports a valid component', () => { 34 | expect(BaseLink).toBeAComponent() 35 | }) 36 | 37 | it('warns about missing required props', () => { 38 | mountBaseLink() 39 | expect(console.warn).toHaveBeenCalledTimes(1) 40 | expect(warning).toMatch(/Invalid props/) 41 | }) 42 | 43 | it('warns about an invalid href', () => { 44 | mountBaseLink({ 45 | propsData: { 46 | href: '/some/local/path', 47 | }, 48 | }) 49 | expect(console.warn).toHaveBeenCalledTimes(1) 50 | expect(warning).toMatch(/Invalid href/) 51 | }) 52 | 53 | it('warns about an insecure href', () => { 54 | mountBaseLink({ 55 | propsData: { 56 | href: 'http://google.com', 57 | }, 58 | }) 59 | expect(console.warn).toHaveBeenCalledTimes(1) 60 | expect(warning).toMatch(/Insecure href/) 61 | }) 62 | 63 | it('renders an anchor element when passed an `href` prop', () => { 64 | const externalUrl = 'https://google.com/' 65 | const { element } = mountBaseLink({ 66 | propsData: { 67 | href: externalUrl, 68 | }, 69 | }) 70 | expect(console.warn).not.toHaveBeenCalled() 71 | expect(element.tagName).toEqual('A') 72 | expect(element.href).toEqual(externalUrl) 73 | expect(element.target).toEqual('_blank') 74 | expect(element.textContent).toEqual('hello') 75 | }) 76 | 77 | it('renders a RouterLink when passed a `name` prop', () => { 78 | const routeName = 'home' 79 | const { element, vm } = mountBaseLink({ 80 | propsData: { 81 | name: routeName, 82 | }, 83 | }) 84 | expect(console.warn).not.toHaveBeenCalled() 85 | expect(element.dataset.routerLink).toEqual('true') 86 | expect(element.textContent).toEqual('hello') 87 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} }) 88 | }) 89 | 90 | it('renders a RouterLink when passed `name` and `params` props', () => { 91 | const routeName = 'home' 92 | const routeParams = { foo: 'bar' } 93 | const { element, vm } = mountBaseLink({ 94 | propsData: { 95 | name: routeName, 96 | params: routeParams, 97 | }, 98 | }) 99 | expect(console.warn).not.toHaveBeenCalled() 100 | expect(element.dataset.routerLink).toEqual('true') 101 | expect(element.textContent).toEqual('hello') 102 | expect(vm.routerLinkTo).toEqual({ 103 | name: routeName, 104 | params: routeParams, 105 | }) 106 | }) 107 | 108 | it('renders a RouterLink when passed a `to` prop', () => { 109 | const routeName = 'home' 110 | const { element, vm } = mountBaseLink({ 111 | propsData: { 112 | to: { 113 | name: routeName, 114 | }, 115 | }, 116 | }) 117 | expect(console.warn).not.toHaveBeenCalled() 118 | expect(element.dataset.routerLink).toEqual('true') 119 | expect(element.textContent).toEqual('hello') 120 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} }) 121 | }) 122 | 123 | it('renders a RouterLink when passed a `to` prop with `params`', () => { 124 | const routeName = 'home' 125 | const routeParams = { foo: 'bar' } 126 | const { element, vm } = mountBaseLink({ 127 | propsData: { 128 | to: { 129 | name: routeName, 130 | params: routeParams, 131 | }, 132 | }, 133 | }) 134 | expect(console.warn).not.toHaveBeenCalled() 135 | expect(element.dataset.routerLink).toEqual('true') 136 | expect(element.textContent).toEqual('hello') 137 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: routeParams }) 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /server/service/fuellyImportService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "hammond/db" 11 | 12 | "github.com/leekchan/accounting" 13 | ) 14 | 15 | func FuellyParseAll(content []byte, userId string) ([]db.Fillup, []db.Expense, []string) { 16 | stream := bytes.NewReader(content) 17 | reader := csv.NewReader(stream) 18 | records, err := reader.ReadAll() 19 | 20 | var errors []string 21 | user, err := GetUserById(userId) 22 | if err != nil { 23 | errors = append(errors, err.Error()) 24 | return nil, nil, errors 25 | } 26 | 27 | vehicles, err := GetUserVehicles(userId) 28 | if err != nil { 29 | errors = append(errors, err.Error()) 30 | return nil, nil, errors 31 | } 32 | 33 | if err != nil { 34 | errors = append(errors, err.Error()) 35 | return nil, nil, errors 36 | } 37 | 38 | var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle) 39 | for _, vehicle := range *vehicles { 40 | vehicleMap[vehicle.Nickname] = vehicle 41 | } 42 | 43 | var fillups []db.Fillup 44 | var expenses []db.Expense 45 | layout := "2006-01-02 15:04" 46 | altLayout := "2006-01-02 3:04 PM" 47 | 48 | for index, record := range records { 49 | if index == 0 { 50 | continue 51 | } 52 | 53 | var vehicle db.Vehicle 54 | var ok bool 55 | if vehicle, ok = vehicleMap[record[4]]; !ok { 56 | errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1)) 57 | } 58 | dateStr := record[2] + " " + record[3] 59 | date, err := time.Parse(layout, dateStr) 60 | if err != nil { 61 | date, err = time.Parse(altLayout, dateStr) 62 | } 63 | if err != nil { 64 | errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1)) 65 | } 66 | 67 | totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency) 68 | totalCost64, err := strconv.ParseFloat(totalCostStr, 32) 69 | if err != nil { 70 | errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1)) 71 | } 72 | 73 | totalCost := float32(totalCost64) 74 | odoStr := accounting.UnformatNumber(record[5], 0, user.Currency) 75 | odoreading, err := strconv.Atoi(odoStr) 76 | if err != nil { 77 | errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1)) 78 | } 79 | location := record[12] 80 | 81 | //Create Fillup 82 | if record[0] == "Gas" { 83 | rateStr := accounting.UnformatNumber(record[7], 3, user.Currency) 84 | ratet64, err := strconv.ParseFloat(rateStr, 32) 85 | if err != nil { 86 | errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1)) 87 | } 88 | rate := float32(ratet64) 89 | 90 | quantity64, err := strconv.ParseFloat(record[8], 32) 91 | if err != nil { 92 | errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1)) 93 | } 94 | quantity := float32(quantity64) 95 | 96 | notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s", 97 | record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1], 98 | ) 99 | 100 | isTankFull := record[6] == "Full" 101 | fal := false 102 | fillups = append(fillups, db.Fillup{ 103 | VehicleID: vehicle.ID, 104 | FuelUnit: vehicle.FuelUnit, 105 | FuelQuantity: quantity, 106 | PerUnitPrice: rate, 107 | TotalAmount: totalCost, 108 | OdoReading: odoreading, 109 | IsTankFull: &isTankFull, 110 | Comments: notes, 111 | FillingStation: location, 112 | HasMissedFillup: &fal, 113 | UserID: userId, 114 | Date: date, 115 | Currency: user.Currency, 116 | DistanceUnit: user.DistanceUnit, 117 | Source: "Fuelly", 118 | }) 119 | 120 | } 121 | if record[0] == "Service" { 122 | notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s", 123 | record[13], record[14], record[16], 124 | ) 125 | expenses = append(expenses, db.Expense{ 126 | VehicleID: vehicle.ID, 127 | Amount: totalCost, 128 | OdoReading: odoreading, 129 | Comments: notes, 130 | ExpenseType: record[17], 131 | UserID: userId, 132 | Currency: user.Currency, 133 | Date: date, 134 | DistanceUnit: user.DistanceUnit, 135 | Source: "Fuelly", 136 | }) 137 | } 138 | 139 | } 140 | return fillups, expenses, errors 141 | } 142 | -------------------------------------------------------------------------------- /docs/ubuntu-install.md: -------------------------------------------------------------------------------- 1 | # Building from source / Ubuntu Installation Guide 2 | 3 | Although personally I feel that using the docker container is the best way of using and enjoying something like Hammond, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers. 4 | 5 | This guide has been written with Ubuntu in mind. If you are using any other flavour of Linux and are decently competent with using command line tools, it should be easy to figure out the steps for your specific distro. 6 | 7 | ## Install Go and Node 8 | 9 | Hammond is built using Go and VueJS which means GO and Node would be needed to compile and build the source code. Hammond is written with Go 1.15/ Node v14 so any version equal to or above this should be good to go. 10 | 11 | If you already have Go and Node installed on your machine, you can skip to the next step. 12 | 13 | Get precise Go installation process at the official link here - https://golang.org/doc/install 14 | 15 | Get precise Node installation process at the official link here - https://nodejs.org/en/ 16 | 17 | 18 | Following steps will only work if Go and Node are installed and configured properly. 19 | 20 | ## Install dependencies 21 | 22 | ``` bash 23 | sudo apt-get install -y git ca-certificates ufw gcc 24 | ``` 25 | 26 | ## Clone from Git 27 | 28 | ``` bash 29 | git clone --depth 1 https://github.com/alfhou/hammond 30 | ``` 31 | 32 | ## Build and Copy dependencies 33 | 34 | ``` bash 35 | cd hammond/server 36 | mkdir -p ./dist 37 | cp .env ./dist 38 | go build -o ./dist/hammond ./main.go 39 | ``` 40 | 41 | ## Create final destination and copy executable 42 | ``` bash 43 | sudo mkdir -p /usr/local/bin/hammond 44 | mv -v dist/* /usr/local/bin/hammond 45 | mv -v dist/.* /usr/local/bin/hammond 46 | ``` 47 | 48 | 49 | ## Building the UI 50 | 51 | Go back to the root of the hammond folder. 52 | 53 | ``` bash 54 | cd ui 55 | npm install 56 | npm run build 57 | mv dist /usr/local/bin/hammond 58 | ``` 59 | 60 | At this point theoretically the installation is complete. You can make the relevant changes in the ```.env``` file present at ```/usr/local/bin/hammond``` path and run the following command 61 | 62 | ``` bash 63 | cd /usr/local/bin/hammond && ./hammond 64 | ``` 65 | 66 | Point your browser to http://localhost:3000 (if trying on the same machine) or http://server-ip:3000 from other machines. 67 | 68 | If you are using ufw or some other firewall, you might have to make an exception for this port on that. 69 | 70 | ## Setup as service (Optional) 71 | 72 | If you want to run Hammond in the background as a service or auto-start whenever the server starts, follow the next steps. 73 | 74 | Create new file named ```hammond.service``` at ```/etc/systemd/system``` and add the following content. You will have to modify the content accordingly if you changed the installation path in the previous steps. 75 | 76 | 77 | ``` unit 78 | [Unit] 79 | Description=Hammond 80 | 81 | [Service] 82 | ExecStart=/usr/local/bin/hammond/hammond 83 | WorkingDirectory=/usr/local/bin/hammond/ 84 | [Install] 85 | WantedBy=multi-user.target 86 | ``` 87 | 88 | Run the following commands 89 | ``` bash 90 | sudo systemctl daemon-reload 91 | sudo systemctl enable hammond.service 92 | sudo systemctl start hammond.service 93 | ``` 94 | 95 | Run the following command to check the service status. 96 | 97 | ``` bash 98 | sudo systemctl status hammond.service 99 | ``` 100 | 101 | # Update Hammond 102 | 103 | In case you have installed Hammond and want to update the latest version (another area where Docker really shines) you need to repeat the steps from cloning to building and copying. 104 | 105 | Stop the running service (if using) 106 | ``` bash 107 | sudo systemctl stop hammond.service 108 | ``` 109 | 110 | ## Clone from Git 111 | 112 | ``` bash 113 | git clone --depth 1 https://github.com/alfhou/hammond 114 | ``` 115 | 116 | ## Build and Copy dependencies 117 | 118 | ``` bash 119 | cd hammond 120 | mkdir -p ./dist 121 | cp .env ./dist 122 | go build -o ./dist/hammond ./main.go 123 | ``` 124 | 125 | ## Create final destination and copy executable 126 | ``` bash 127 | sudo mkdir -p /usr/local/bin/hammond 128 | mv -v dist/* /usr/local/bin/hammond 129 | ``` 130 | 131 | Go back to the root of the hammond folder. 132 | 133 | ``` bash 134 | cd ui 135 | npm install 136 | npm run build 137 | mv dist /usr/local/bin/hammond 138 | ``` 139 | 140 | Restart the service (if using) 141 | ``` bash 142 | sudo systemctl start hammond.service 143 | ``` 144 | -------------------------------------------------------------------------------- /ui/src/state/modules/auth.unit.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as authModule from './auth' 3 | 4 | describe('@state/modules/auth', () => { 5 | it('exports a valid Vuex module', () => { 6 | expect(authModule).toBeAVuexModule() 7 | }) 8 | 9 | describe('in a store', () => { 10 | let store 11 | beforeEach(() => { 12 | store = createModuleStore(authModule) 13 | window.localStorage.clear() 14 | }) 15 | 16 | it('mutations.SET_CURRENT_USER correctly sets axios default authorization header', () => { 17 | axios.defaults.headers.common.Authorization = '' 18 | 19 | store.commit('SET_CURRENT_USER', { token: 'some-token' }) 20 | expect(axios.defaults.headers.common.Authorization).toEqual('some-token') 21 | 22 | store.commit('SET_CURRENT_USER', null) 23 | expect(axios.defaults.headers.common.Authorization).toEqual('') 24 | }) 25 | 26 | it('mutations.SET_CURRENT_USER correctly saves currentUser in localStorage', () => { 27 | let savedCurrentUser = JSON.parse( 28 | window.localStorage.getItem('auth.currentUser') 29 | ) 30 | expect(savedCurrentUser).toEqual(null) 31 | 32 | const expectedCurrentUser = { token: 'some-token' } 33 | store.commit('SET_CURRENT_USER', expectedCurrentUser) 34 | 35 | savedCurrentUser = JSON.parse( 36 | window.localStorage.getItem('auth.currentUser') 37 | ) 38 | expect(savedCurrentUser).toEqual(expectedCurrentUser) 39 | }) 40 | 41 | it('getters.loggedIn returns true when currentUser is an object', () => { 42 | store.commit('SET_CURRENT_USER', {}) 43 | expect(store.getters.loggedIn).toEqual(true) 44 | }) 45 | 46 | it('getters.loggedIn returns false when currentUser is null', () => { 47 | store.commit('SET_CURRENT_USER', null) 48 | expect(store.getters.loggedIn).toEqual(false) 49 | }) 50 | 51 | it('actions.logIn resolves to a refreshed currentUser when already logged in', () => { 52 | expect.assertions(2) 53 | 54 | store.commit('SET_CURRENT_USER', { token: validUserExample.token }) 55 | return store.dispatch('logIn').then((user) => { 56 | expect(user).toEqual(validUserExample) 57 | expect(store.state.currentUser).toEqual(validUserExample) 58 | }) 59 | }) 60 | 61 | it('actions.logIn commits the currentUser and resolves to the user when NOT already logged in and provided a correct username and password', () => { 62 | expect.assertions(2) 63 | 64 | return store 65 | .dispatch('logIn', { username: 'admin', password: 'password' }) 66 | .then((user) => { 67 | expect(user).toEqual(validUserExample) 68 | expect(store.state.currentUser).toEqual(validUserExample) 69 | }) 70 | }) 71 | 72 | it('actions.logIn rejects with 401 when NOT already logged in and provided an incorrect username and password', () => { 73 | expect.assertions(1) 74 | 75 | return store 76 | .dispatch('logIn', { 77 | username: 'bad username', 78 | password: 'bad password', 79 | }) 80 | .catch((error) => { 81 | expect(error.message).toEqual('Request failed with status code 401') 82 | }) 83 | }) 84 | 85 | it('actions.validate resolves to null when currentUser is null', () => { 86 | expect.assertions(1) 87 | 88 | store.commit('SET_CURRENT_USER', null) 89 | return store.dispatch('validate').then((user) => { 90 | expect(user).toEqual(null) 91 | }) 92 | }) 93 | 94 | it('actions.validate resolves to null when currentUser contains an invalid token', () => { 95 | expect.assertions(2) 96 | 97 | store.commit('SET_CURRENT_USER', { token: 'invalid-token' }) 98 | return store.dispatch('validate').then((user) => { 99 | expect(user).toEqual(null) 100 | expect(store.state.currentUser).toEqual(null) 101 | }) 102 | }) 103 | 104 | it('actions.validate resolves to a user when currentUser contains a valid token', () => { 105 | expect.assertions(2) 106 | 107 | store.commit('SET_CURRENT_USER', { token: validUserExample.token }) 108 | return store.dispatch('validate').then((user) => { 109 | expect(user).toEqual(validUserExample) 110 | expect(store.state.currentUser).toEqual(validUserExample) 111 | }) 112 | }) 113 | }) 114 | }) 115 | 116 | const validUserExample = { 117 | id: 1, 118 | username: 'admin', 119 | name: 'Vue Master', 120 | token: 'valid-token-for-admin', 121 | } 122 | -------------------------------------------------------------------------------- /server/service/drivvoImportService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "hammond/db" 12 | ) 13 | 14 | func DrivvoParseExpenses(content []byte, user *db.User, vehicle *db.Vehicle) ([]db.Expense, []string) { 15 | expenseReader := csv.NewReader(bytes.NewReader(content)) 16 | expenseReader.Comment = '#' 17 | // Read headers (there is a trailing comma at the end, that's why we have to read the first line) 18 | expenseReader.Read() 19 | expenseReader.FieldsPerRecord = 6 20 | expenseRecords, err := expenseReader.ReadAll() 21 | 22 | var errors []string 23 | if err != nil { 24 | errors = append(errors, err.Error()) 25 | println(err.Error()) 26 | return nil, errors 27 | } 28 | 29 | var expenses []db.Expense 30 | for index, record := range expenseRecords { 31 | date, err := time.Parse("2006-01-02 15:04:05", record[1]) 32 | if err != nil { 33 | errors = append(errors, "Found an invalid date/time at service/expense row "+strconv.Itoa(index+1)) 34 | } 35 | 36 | totalCost, err := strconv.ParseFloat(record[2], 32) 37 | if err != nil { 38 | errors = append(errors, "Found and invalid total cost at service/expense row "+strconv.Itoa(index+1)) 39 | } 40 | 41 | odometer, err := strconv.Atoi(record[0]) 42 | if err != nil { 43 | errors = append(errors, "Found an invalid odometer reading at service/expense row "+strconv.Itoa(index+1)) 44 | } 45 | 46 | notes := fmt.Sprintf("Location: %s\nNotes: %s\n", record[4], record[5]) 47 | 48 | expenses = append(expenses, db.Expense{ 49 | UserID: user.ID, 50 | VehicleID: vehicle.ID, 51 | Date: date, 52 | OdoReading: odometer, 53 | Amount: float32(totalCost), 54 | ExpenseType: record[3], 55 | Currency: user.Currency, 56 | DistanceUnit: user.DistanceUnit, 57 | Comments: notes, 58 | Source: "Drivvo", 59 | }) 60 | } 61 | 62 | return expenses, errors 63 | } 64 | 65 | func DrivvoParseRefuelings(content []byte, user *db.User, vehicle *db.Vehicle, importLocation bool) ([]db.Fillup, []string) { 66 | refuelingReader := csv.NewReader(bytes.NewReader(content)) 67 | refuelingReader.Comment = '#' 68 | refuelingRecords, err := refuelingReader.ReadAll() 69 | 70 | var errors []string 71 | if err != nil { 72 | errors = append(errors, err.Error()) 73 | println(err.Error()) 74 | return nil, errors 75 | } 76 | 77 | var fillups []db.Fillup 78 | for index, record := range refuelingRecords { 79 | // Skip column titles 80 | if index == 0 { 81 | continue 82 | } 83 | 84 | date, err := time.Parse("2006-01-02 15:04:05", record[1]) 85 | if err != nil { 86 | errors = append(errors, "Found an invalid date/time at refuel row "+strconv.Itoa(index+1)) 87 | } 88 | 89 | totalCost, err := strconv.ParseFloat(record[4], 32) 90 | if err != nil { 91 | errors = append(errors, "Found and invalid total cost at refuel row "+strconv.Itoa(index+1)) 92 | } 93 | 94 | odometer, err := strconv.Atoi(record[0]) 95 | if err != nil { 96 | errors = append(errors, "Found an invalid odometer reading at refuel row "+strconv.Itoa(index+1)) 97 | } 98 | 99 | location := "" 100 | if importLocation { 101 | location = record[17] 102 | } 103 | 104 | pricePerUnit, err := strconv.ParseFloat(record[3], 32) 105 | if err != nil { 106 | unit := strings.ToLower(db.FuelUnitDetails[vehicle.FuelUnit].Key) 107 | errors = append(errors, fmt.Sprintf("Found an invalid cost per %s at refuel row %d", unit, index+1)) 108 | } 109 | 110 | quantity, err := strconv.ParseFloat(record[5], 32) 111 | if err != nil { 112 | errors = append(errors, "Found an invalid quantity at refuel row "+strconv.Itoa(index+1)) 113 | } 114 | 115 | isTankFull := record[6] == "Yes" 116 | 117 | // Unfortunatly, drivvo doesn't expose this info in their export 118 | fal := false 119 | 120 | notes := fmt.Sprintf("Reason: %s\nNotes: %s\nFuel: %s\n", record[18], record[19], record[2]) 121 | 122 | fillups = append(fillups, db.Fillup{ 123 | VehicleID: vehicle.ID, 124 | UserID: user.ID, 125 | Date: date, 126 | HasMissedFillup: &fal, 127 | IsTankFull: &isTankFull, 128 | FuelQuantity: float32(quantity), 129 | PerUnitPrice: float32(pricePerUnit), 130 | FillingStation: location, 131 | OdoReading: odometer, 132 | TotalAmount: float32(totalCost), 133 | FuelUnit: vehicle.FuelUnit, 134 | Currency: user.Currency, 135 | DistanceUnit: user.DistanceUnit, 136 | Comments: notes, 137 | Source: "Drivvo", 138 | }) 139 | 140 | } 141 | return fillups, errors 142 | } 143 | -------------------------------------------------------------------------------- /ui/src/router/views/quickEntries.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 109 | 110 | 120 | -------------------------------------------------------------------------------- /ui/src/utils/dispatch-action-for-all-modules.unit.js: -------------------------------------------------------------------------------- 1 | describe('@utils/dispatch-action-for-all-modules', () => { 2 | beforeEach(() => { 3 | jest.resetModules() 4 | }) 5 | 6 | it('dispatches actions from NOT namespaced modules', () => { 7 | jest.doMock('@state/modules', () => ({ 8 | moduleA: { 9 | actions: { 10 | someAction: jest.fn(), 11 | otherAction: jest.fn(), 12 | }, 13 | }, 14 | moduleB: { 15 | actions: { 16 | someAction: jest.fn(), 17 | otherAction: jest.fn(), 18 | }, 19 | }, 20 | })) 21 | 22 | require('./dispatch-action-for-all-modules').default('someAction') 23 | 24 | const { moduleA, moduleB } = require('@state/modules') 25 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 26 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 27 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 28 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 29 | }) 30 | 31 | it('dispatches actions from namespaced modules', () => { 32 | jest.doMock('@state/modules', () => ({ 33 | moduleA: { 34 | namespaced: true, 35 | actions: { 36 | someAction: jest.fn(), 37 | otherAction: jest.fn(), 38 | }, 39 | }, 40 | moduleB: { 41 | namespaced: true, 42 | actions: { 43 | someAction: jest.fn(), 44 | otherAction: jest.fn(), 45 | }, 46 | }, 47 | })) 48 | 49 | require('./dispatch-action-for-all-modules').default('someAction') 50 | 51 | const { moduleA, moduleB } = require('@state/modules') 52 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 53 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 54 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 55 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 56 | }) 57 | 58 | it('dispatches actions from deeply nested NOT namespaced modules', () => { 59 | jest.doMock('@state/modules', () => ({ 60 | moduleA: { 61 | actions: { 62 | someAction: jest.fn(), 63 | otherAction: jest.fn(), 64 | }, 65 | modules: { 66 | moduleB: { 67 | actions: { 68 | someAction: jest.fn(), 69 | otherAction: jest.fn(), 70 | }, 71 | modules: { 72 | moduleC: { 73 | actions: { 74 | someAction: jest.fn(), 75 | otherAction: jest.fn(), 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | })) 83 | 84 | require('./dispatch-action-for-all-modules').default('someAction') 85 | 86 | const { moduleA } = require('@state/modules') 87 | const { moduleB } = moduleA.modules 88 | const { moduleC } = moduleB.modules 89 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 90 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 91 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1) 92 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 93 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 94 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled() 95 | }) 96 | 97 | it('dispatches actions from deeply nested namespaced modules', () => { 98 | jest.doMock('@state/modules', () => ({ 99 | moduleA: { 100 | namespaced: true, 101 | actions: { 102 | someAction: jest.fn(), 103 | otherAction: jest.fn(), 104 | }, 105 | modules: { 106 | moduleB: { 107 | namespaced: true, 108 | actions: { 109 | someAction: jest.fn(), 110 | otherAction: jest.fn(), 111 | }, 112 | modules: { 113 | moduleC: { 114 | namespaced: true, 115 | actions: { 116 | someAction: jest.fn(), 117 | otherAction: jest.fn(), 118 | }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | })) 125 | 126 | require('./dispatch-action-for-all-modules').default('someAction') 127 | 128 | const { moduleA } = require('@state/modules') 129 | const { moduleB } = moduleA.modules 130 | const { moduleC } = moduleB.modules 131 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 132 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 133 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1) 134 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 135 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 136 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled() 137 | }) 138 | }) 139 | --------------------------------------------------------------------------------