first commit
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
*Before creating a bug report please make sure you are using the latest docker image / code base.*
|
||||
|
||||
**Please complete the following information**
|
||||
- Installation Type: [Docker/Native]
|
||||
- Have you tried using the latest docker image / code base [yes/no]
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
56
.github/workflows/hub.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: master
|
||||
|
||||
jobs:
|
||||
multi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
-
|
||||
name: Set up build cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Login to GitHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
#platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
tags: |
|
||||
akhilrex/hammond:latest
|
||||
akhilrex/hammond:1.0.0
|
||||
ghcr.io/akhilrex/hammond:latest
|
||||
ghcr.io/akhilrex/hammond:1.0.0
|
||||
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
ARG GO_VERSION=1.15.2
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/*
|
||||
RUN mkdir -p /api
|
||||
WORKDIR /api
|
||||
COPY ./server/go.mod .
|
||||
COPY ./server/go.sum .
|
||||
RUN go mod download
|
||||
COPY ./server .
|
||||
RUN go build -o ./app ./main.go
|
||||
|
||||
FROM node:latest as build-stage
|
||||
WORKDIR /app
|
||||
COPY ./ui/package*.json ./
|
||||
RUN npm install
|
||||
COPY ./ui .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM alpine:latest
|
||||
LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond"
|
||||
ENV CONFIG=/config
|
||||
ENV DATA=/assets
|
||||
ENV UID=998
|
||||
ENV PID=100
|
||||
ENV GIN_MODE=release
|
||||
VOLUME ["/config", "/assets"]
|
||||
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
|
||||
RUN mkdir -p /config; \
|
||||
mkdir -p /assets; \
|
||||
mkdir -p /api
|
||||
RUN chmod 777 /config; \
|
||||
chmod 777 /assets
|
||||
WORKDIR /api
|
||||
COPY --from=builder /api/app .
|
||||
#COPY dist ./dist
|
||||
COPY --from=build-stage /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["./app"]
|
||||
192
README.md
Normal file
@@ -0,0 +1,192 @@
|
||||
[![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] [![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<p align="center">
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">
|
||||
<img src="images/logo.png" alt="Logo" width="80" height="80">
|
||||
</a> -->
|
||||
|
||||
<h1 align="center" style="margin-bottom:0">Hammond</h1>
|
||||
<p align="center">Current Version - 2021.05.07</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted vehicle expense tracking system with support for multiple users.
|
||||
<br />
|
||||
<a href="https://github.com/akhilrex/hammond"><strong>Explore the docs »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">View Demo</a>
|
||||
· -->
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Request Feature</a>
|
||||
·
|
||||
<a href="Screenshots.md">Screenshots</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [About the Project](#about-the-project)
|
||||
- [Motivation](#motivation)
|
||||
- [Built With](#built-with)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [License](#license)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Contact](#contact)
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
|
||||
Hammond is a self hosted vehicle management system to track fuel and other expenses related to all of your vehicles. It supports multiple users sharing multiple vehicles. It is the logical successor to Clarkson which has not been updated for quite some time now.
|
||||
|
||||
_Developers Note: This project is under active development which means I release new updates very frequently. It is recommended that you use something like [watchtower](https://github.com/containrrr/watchtower) which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually._
|
||||
|
||||
### Motivation and Developer Notes
|
||||
|
||||
I was looking for a fuel tracking system and stumbled upon Clarkson. Although it did most of what I needed it has not been updated for quite a lot of time. Since I had some bandwidth available as my previous open source project [Podgrab](http://github.com/akhilrex/podgrab) had become quite stable now, my first thought was to contribute to the Clarkson project only. I soon realized that the architecture that Clarkson had used was not really be that extensible now and would warrant a complete rewrite only. So I decided to build Hammond - The successor to Clarkson.
|
||||
|
||||
The current version of Hammond is written using GO for backend and Vuejs for the front end. Originally I had thought of using the same tech stack for both frontend and the backend so that it became easier for users and other developers to use, deploy and contribute. Which is why the first version of Hammond has a NestJS backend complete with all the bells and whistles (GraphQL, Prisma and what nots). But I eventually decided to rebuild the backend in GO just to keep the container size small. No matter how much you can optimize the sheer size of the node_modules will always add bulk to your containers. I host all my tools on my Raspberry Pi. It only makes sense to keep the container size as small as possible.
|
||||
|
||||
Also I had initially thought of a 2 container approach (1 for backend and 1 for the frontend) so that they can be independently maintained and updated. I eventually decided against this idea for the sake of simplicity. Although it is safe to assume that most self-hosters are fairly tech capable it still is much better to have a single container that you can fire and forget.
|
||||
|
||||
![Product Name Screen Shot][product-screenshot] [More Screenshots](Screenshots.md)
|
||||
|
||||
### Built With
|
||||
|
||||
- [Go](https://golang.org/)
|
||||
- [Go-Gin](https://github.com/gin-gonic/gin)
|
||||
- [GORM](https://github.com/go-gorm/gorm)
|
||||
- [SQLite](https://www.sqlite.org/index.html)
|
||||
- [VueJS](https://vuejs.org/)
|
||||
- [Vuex](https://vuex.vuejs.org/)
|
||||
- [Buefy](https://buefy.org/)
|
||||
|
||||
### Features
|
||||
|
||||
- Migrate data from Clarkson
|
||||
- Add/Manage multiple vehicles
|
||||
- Add/Manage multiple users
|
||||
- Track fuel and other expenses
|
||||
- Share vehicles across multiple users
|
||||
- Save attachment against vehicles
|
||||
- Quick Entries (take a photo of a receipt or pump screen to make entry later)
|
||||
- Vehicle level and overall reporting
|
||||
|
||||
## Installation
|
||||
|
||||
The easiest way to run Hammond is to run it as a docker container.
|
||||
|
||||
### Using Docker
|
||||
|
||||
Simple setup without mounted volumes (for testing and evaluation)
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond akhilrex/hammond
|
||||
```
|
||||
|
||||
Binding local volumes to the container
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" akhilrex/hammond
|
||||
```
|
||||
|
||||
### Using Docker-Compose
|
||||
|
||||
Modify the docker compose file provided [here](https://github.com/akhilrex/hammond/blob/master/docker-compose.yml) to update the volume and port binding and run the following command
|
||||
|
||||
```yaml
|
||||
version: '2.1'
|
||||
services:
|
||||
hammond:
|
||||
image: akhilrex/hammond
|
||||
container_name: hammond
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/data:/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<!-- ### Build from Source / Ubuntu Installation
|
||||
|
||||
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. Follow the link below to get a guide on how to build hammond from source.
|
||||
|
||||
[Build from source / Ubuntu Guide](docs/ubuntu-install.md) -->
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Name | Description | Default |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| PORT | Change the internal port of the application. If you change this you might have to change your docker configuration as well | (empty) |
|
||||
|
||||
### Setup
|
||||
|
||||
When you open Hammond for the first time after a fresh install, you will be presented with the option to either import data from an existing Clarkson instance or setup a fresh instance.
|
||||
|
||||
#### Migration from Clarkson
|
||||
|
||||
You will have to ensure that the Clarkson database is accessible from the Hammond deployment. In case it is not directly possible, you can always take a backup of the Clarkson database and host it somewhere accessible to Hammond using a temporary container. If the access problem is sorted, you will have to enter the connection string the Clarkson database in the following format.
|
||||
|
||||
```
|
||||
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
You can check the connectivity from the screen as well.
|
||||
|
||||
Note: All the users migrated from Clarkson will have their passwords changed to `hammond`
|
||||
|
||||
#### Fresh setup
|
||||
|
||||
You will have to provide your name, email and password so that an admin user can be created for you.
|
||||
|
||||
Once done you will be taken to the login page.
|
||||
|
||||
Go through the settings page once and change relevant settings before you start adding vehicles and expenses.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the GPL-3.0 License. See `LICENSE` for more information.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] More reports
|
||||
- [ ] Vehicle specific reminders (servicing etc)
|
||||
- [ ] Native installer for Windows/Linux/MacOS
|
||||
|
||||
<!-- CONTACT -->
|
||||
|
||||
## Contact
|
||||
|
||||
Akhil Gupta - [@akhilrex](https://twitter.com/akhilrex)
|
||||
|
||||
Project Link: [https://github.com/akhilrex/hammond](https://github.com/akhilrex/hammond)
|
||||
|
||||
<a href="https://www.buymeacoffee.com/akhilrex" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="width: 217px !important;height: 60px !important;" ></a>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/akhilrex/hammond.svg?style=flat-square
|
||||
[contributors-url]: https://github.com/akhilrex/hammond/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/github/forks/akhilrex/hammond.svg?style=flat-square
|
||||
[forks-url]: https://github.com/akhilrex/hammond/network/members
|
||||
[stars-shield]: https://img.shields.io/github/stars/akhilrex/hammond.svg?style=flat-square
|
||||
[stars-url]: https://github.com/akhilrex/hammond/stargazers
|
||||
[issues-shield]: https://img.shields.io/github/issues/akhilrex/hammond.svg?style=flat-square
|
||||
[issues-url]: https://github.com/akhilrex/hammond/issues
|
||||
[license-shield]: https://img.shields.io/github/license/akhilrex/hammond.svg?style=flat-square
|
||||
[license-url]: https://github.com/akhilrex/hammond/blob/master/LICENSE.txt
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://linkedin.com/in/akhilrex
|
||||
[product-screenshot]: images/screenshot.jpg
|
||||
26
Shreenshots.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Home Page / Summary
|
||||
|
||||
![Product Name Screen Shot][product-screenshot]
|
||||
|
||||
## Add Podcast
|
||||
|
||||

|
||||
|
||||
## All Episodes Chronologically
|
||||
|
||||

|
||||
|
||||
## Podcast Episodes
|
||||
|
||||

|
||||
|
||||
|
||||
## Player
|
||||
|
||||

|
||||
|
||||
## Settings
|
||||
|
||||

|
||||
|
||||
[product-screenshot]: images/screenshot.jpg
|
||||
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: "2.1"
|
||||
services:
|
||||
hammond:
|
||||
image: akhilrex/hammond
|
||||
container_name: hammond
|
||||
environment:
|
||||
- CHECK_FREQUENCY=240
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/data:/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
BIN
images/create_expense.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/create_fillup.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
images/screenshot.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
images/settings.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
images/users.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
images/vehicle_detail.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
images/vehicles_add.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
3
server/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
CONFIG=.
|
||||
DATA=./assets
|
||||
JWT_SECRET="A super strong secret that needs to be changed"
|
||||
21
server/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
*.db
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
assets/*
|
||||
keys/*
|
||||
backups/*
|
||||
nodemon.json
|
||||
dist/*
|
||||
41
server/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
ARG GO_VERSION=1.15.2
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/*
|
||||
|
||||
RUN mkdir -p /api
|
||||
WORKDIR /api
|
||||
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -o ./app ./main.go
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond"
|
||||
|
||||
ENV CONFIG=/config
|
||||
ENV DATA=/assets
|
||||
ENV UID=998
|
||||
ENV PID=100
|
||||
ENV GIN_MODE=release
|
||||
VOLUME ["/config", "/assets"]
|
||||
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
|
||||
RUN mkdir -p /config; \
|
||||
mkdir -p /assets; \
|
||||
mkdir -p /api
|
||||
|
||||
RUN chmod 777 /config; \
|
||||
chmod 777 /assets
|
||||
|
||||
WORKDIR /api
|
||||
COPY --from=builder /api/app .
|
||||
COPY dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./app"]
|
||||
89
server/common/utils.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Common tools and helper functions
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
|
||||
// A helper function to generate random string
|
||||
func RandString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A Util function to generate jwt_token which can be used in the request header
|
||||
func GenToken(id string, role db.Role) (string, string) {
|
||||
jwt_token := jwt.New(jwt.GetSigningMethod("HS256"))
|
||||
// Set some claims
|
||||
jwt_token.Claims = jwt.MapClaims{
|
||||
"id": id,
|
||||
"role": role,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 3).Unix(),
|
||||
}
|
||||
// Sign and get the complete encoded token as a string
|
||||
token, _ := jwt_token.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
|
||||
refreshToken := jwt.New(jwt.SigningMethodHS256)
|
||||
rtClaims := refreshToken.Claims.(jwt.MapClaims)
|
||||
rtClaims["id"] = id
|
||||
rtClaims["exp"] = time.Now().Add(time.Hour * 24 * 20).Unix()
|
||||
|
||||
rt, _ := refreshToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
|
||||
return token, rt
|
||||
}
|
||||
|
||||
// My own Error type that will help return my customized Error info
|
||||
// {"database": {"hello":"no such table", error: "not_exists"}}
|
||||
type CommonError struct {
|
||||
Errors map[string]interface{} `json:"errors"`
|
||||
}
|
||||
|
||||
// To handle the error returned by c.Bind in gin framework
|
||||
// https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go
|
||||
func NewValidatorError(err error) CommonError {
|
||||
res := CommonError{}
|
||||
res.Errors = make(map[string]interface{})
|
||||
errs := err.(validator.ValidationErrors)
|
||||
for _, v := range errs {
|
||||
// can translate each error one at a time.
|
||||
//fmt.Println("gg",v.NameNamespace)
|
||||
if v.Param() != "" {
|
||||
res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param())
|
||||
} else {
|
||||
res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag())
|
||||
}
|
||||
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Warp the error info in a object
|
||||
func NewError(key string, err error) CommonError {
|
||||
res := CommonError{}
|
||||
res.Errors = make(map[string]interface{})
|
||||
res.Errors[key] = err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
// Changed the c.MustBindWith() -> c.ShouldBindWith().
|
||||
// I don't want to auto return 400 when error happened.
|
||||
// origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go
|
||||
func Bind(c *gin.Context, obj interface{}) error {
|
||||
b := binding.Default(c.Request.Method, c.ContentType())
|
||||
return c.ShouldBindWith(obj, b)
|
||||
}
|
||||
179
server/controllers/auth.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/akhilrex/hammond/common"
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterAnonController(router *gin.RouterGroup) {
|
||||
router.POST("/login", userLogin)
|
||||
router.POST("/auth/initialize", initializeSystem)
|
||||
|
||||
}
|
||||
func RegisterAuthController(router *gin.RouterGroup) {
|
||||
|
||||
router.POST("/refresh", refresh)
|
||||
router.GET("/me", me)
|
||||
router.POST("/register", ShouldBeAdmin(), userRegister)
|
||||
router.POST("/changePassword", changePassword)
|
||||
|
||||
}
|
||||
|
||||
func ShouldBeAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
model := c.MustGet("userModel").(db.User)
|
||||
if model.Role != db.ADMIN {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{})
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func me(c *gin.Context) {
|
||||
user, err := service.GetUserById(c.MustGet("userId").(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{})
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func userRegister(c *gin.Context) {
|
||||
var registerRequest models.RegisterRequest
|
||||
if err := c.ShouldBind(®isterRequest); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.CreateUser(®isterRequest, *registerRequest.Role); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true})
|
||||
}
|
||||
func initializeSystem(c *gin.Context) {
|
||||
|
||||
canInitialize, err := service.CanInitializeSystem()
|
||||
if !canInitialize {
|
||||
c.JSON(http.StatusUnprocessableEntity, err)
|
||||
}
|
||||
|
||||
var registerRequest models.RegisterRequest
|
||||
if err := c.ShouldBind(®isterRequest); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.CreateUser(®isterRequest, db.ADMIN); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("initializeSystem", err))
|
||||
return
|
||||
}
|
||||
|
||||
service.UpdateSettings(registerRequest.Currency, *registerRequest.DistanceUnit)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func userLogin(c *gin.Context) {
|
||||
var loginRequest models.LoginRequest
|
||||
if err := c.ShouldBind(&loginRequest); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
user, err := db.FindOneUser(&db.User{Email: loginRequest.Email})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
|
||||
if user.CheckPassword(loginRequest.Password) != nil {
|
||||
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
UpdateContextUserModel(c, user.ID)
|
||||
token, refreshToken := common.GenToken(user.ID, user.Role)
|
||||
response := models.LoginResponse{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
Role: user.RoleDetail().Long,
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func refresh(c *gin.Context) {
|
||||
type tokenReqBody struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
tokenReq := tokenReqBody{}
|
||||
c.Bind(&tokenReq)
|
||||
|
||||
token, _ := jwt.Parse(tokenReq.RefreshToken, func(token *jwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
// Get the user record from database or
|
||||
// run through your business logic to verify if the user can log in
|
||||
user, err := service.GetUserById(claims["id"].(string))
|
||||
if err == nil {
|
||||
|
||||
token, refreshToken := common.GenToken(user.ID, user.Role)
|
||||
|
||||
response := models.LoginResponse{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
Role: user.RoleDetail().Long,
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
} else {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{})
|
||||
}
|
||||
} else {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{})
|
||||
}
|
||||
}
|
||||
|
||||
func changePassword(c *gin.Context) {
|
||||
var request models.ChangePasswordRequest
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
user, err := service.GetUserById(c.GetString("userId"))
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
|
||||
if user.CheckPassword(request.OldPassword) != nil {
|
||||
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
|
||||
user.SetPassword(request.NewPassword)
|
||||
success, err := service.UpdatePassword(user.ID, request.NewPassword)
|
||||
c.JSON(http.StatusOK, success)
|
||||
}
|
||||
157
server/controllers/files.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/akhilrex/hammond/common"
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterFilesController(router *gin.RouterGroup) {
|
||||
router.POST("/upload", uploadFile)
|
||||
router.POST("/quickEntries", createQuickEntry)
|
||||
router.GET("/quickEntries", getAllQuickEntries)
|
||||
router.GET("/me/quickEntries", getMyQuickEntries)
|
||||
router.GET("/quickEntries/:id", getQuickEntryById)
|
||||
router.POST("/quickEntries/:id/process", setQuickEntryAsProcessed)
|
||||
router.GET("/attachments/:id/file", getAttachmentFile)
|
||||
}
|
||||
|
||||
func createQuickEntry(c *gin.Context) {
|
||||
var request models.CreateQuickEntryModel
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
attachment, err := saveUploadedFile(c, "file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
quickEntry, err := service.CreateQuickEntry(request, attachment.ID, c.MustGet("userId").(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createQuickEntry", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, quickEntry)
|
||||
}
|
||||
|
||||
func getAllQuickEntries(c *gin.Context) {
|
||||
quickEntries, err := service.GetAllQuickEntries("")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getAllQuickEntries", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, quickEntries)
|
||||
}
|
||||
func getMyQuickEntries(c *gin.Context) {
|
||||
quickEntries, err := service.GetQuickEntriesForUser(c.MustGet("userId").(string), "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getMyQuickEntries", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, quickEntries)
|
||||
}
|
||||
|
||||
func getQuickEntryById(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if c.ShouldBindUri(&searchByIdQuery) == nil {
|
||||
quickEntry, err := service.GetQuickEntryById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, quickEntry)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
}
|
||||
}
|
||||
func setQuickEntryAsProcessed(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if c.ShouldBindUri(&searchByIdQuery) == nil {
|
||||
err := service.SetQuickEntryAsProcessed(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
}
|
||||
}
|
||||
|
||||
func uploadFile(c *gin.Context) {
|
||||
attachment, err := saveMultipleUploadedFile(c, "file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, err)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, attachment)
|
||||
}
|
||||
}
|
||||
func getAttachmentFile(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if c.ShouldBindUri(&searchByIdQuery) == nil {
|
||||
|
||||
attachment, err := db.GetAttachmentById(searchByIdQuery.Id)
|
||||
if err == nil {
|
||||
if _, err = os.Stat(attachment.Path); os.IsNotExist(err) {
|
||||
c.Status(404)
|
||||
} else {
|
||||
c.File(attachment.Path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadedFile(c *gin.Context, fileVariable string) (*db.Attachment, error) {
|
||||
if fileVariable == "" {
|
||||
fileVariable = "file"
|
||||
}
|
||||
file, err := c.FormFile(fileVariable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath := service.GetFilePath(file.Filename)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service.CreateAttachment(filePath, file.Filename, file.Size, file.Header.Get("Content-Type"), c.MustGet("userId").(string))
|
||||
}
|
||||
func saveMultipleUploadedFile(c *gin.Context, fileVariable string) ([]*db.Attachment, error) {
|
||||
if fileVariable == "" {
|
||||
fileVariable = "files"
|
||||
}
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := form.File[fileVariable]
|
||||
var toReturn []*db.Attachment
|
||||
for _, file := range files {
|
||||
filePath := service.GetFilePath(file.Filename)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachment, err := service.CreateAttachment(filePath, file.Filename, file.Size, file.Header.Get("Content-Type"), c.MustGet("userId").(string))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toReturn = append(toReturn, attachment)
|
||||
}
|
||||
return toReturn, nil
|
||||
}
|
||||
64
server/controllers/masters.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/akhilrex/hammond/common"
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterAnonMasterConroller(router *gin.RouterGroup) {
|
||||
router.GET("/masters", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"fuelUnits": db.FuelUnitDetails,
|
||||
"fuelTypes": db.FuelTypeDetails,
|
||||
"distanceUnits": db.DistanceUnitDetails,
|
||||
"roles": db.RoleDetails,
|
||||
"currencies": models.GetCurrencyMasterList(),
|
||||
})
|
||||
})
|
||||
}
|
||||
func RegisterMastersController(router *gin.RouterGroup) {
|
||||
|
||||
router.GET("/settings", getSettings)
|
||||
router.POST("/settings", udpateSettings)
|
||||
router.POST("/me/settings", udpateMySettings)
|
||||
|
||||
}
|
||||
|
||||
func getSettings(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, service.GetSettings())
|
||||
}
|
||||
func udpateSettings(c *gin.Context) {
|
||||
var model models.UpdateSettingModel
|
||||
if err := c.ShouldBind(&model); err == nil {
|
||||
err := service.UpdateSettings(model.Currency, *model.DistanceUnit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateSettings", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func udpateMySettings(c *gin.Context) {
|
||||
var model models.UpdateSettingModel
|
||||
if err := c.ShouldBind(&model); err == nil {
|
||||
err := service.UpdateUserSettings(c.MustGet("userId").(string), model.Currency, *model.DistanceUnit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateMySettings", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
|
||||
}
|
||||
71
server/controllers/middlewares.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/dgrijalva/jwt-go/request"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Strips 'BEARER ' prefix from token string
|
||||
func stripBearerPrefixFromTokenString(tok string) (string, error) {
|
||||
// Should be a bearer token
|
||||
if len(tok) > 6 && strings.ToUpper(tok[0:6]) == "BEARER " {
|
||||
return tok[7:], nil
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
// Uses PostExtractionFilter to strip "TOKEN " prefix from header
|
||||
var AuthorizationHeaderExtractor = &request.PostExtractionFilter{
|
||||
request.HeaderExtractor{"Authorization"},
|
||||
stripBearerPrefixFromTokenString,
|
||||
}
|
||||
|
||||
// Extractor for OAuth2 access tokens. Looks in 'Authorization'
|
||||
// header then 'access_token' argument for a token.
|
||||
var MyAuth2Extractor = &request.MultiExtractor{
|
||||
AuthorizationHeaderExtractor,
|
||||
request.ArgumentExtractor{"access_token"},
|
||||
}
|
||||
|
||||
// A helper to write user_id and user_model to the context
|
||||
func UpdateContextUserModel(c *gin.Context, my_user_id string) {
|
||||
var myUserModel db.User
|
||||
if my_user_id != "" {
|
||||
|
||||
db.DB.First(&myUserModel, map[string]string{
|
||||
"ID": my_user_id,
|
||||
})
|
||||
}
|
||||
c.Set("userId", my_user_id)
|
||||
c.Set("userModel", myUserModel)
|
||||
}
|
||||
|
||||
// You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware
|
||||
// r.Use(AuthMiddleware(true))
|
||||
func AuthMiddleware(auto401 bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
UpdateContextUserModel(c, "")
|
||||
token, err := request.ParseFromRequest(c.Request, MyAuth2Extractor, func(token *jwt.Token) (interface{}, error) {
|
||||
b := ([]byte(os.Getenv("JWT_SECRET")))
|
||||
return b, nil
|
||||
})
|
||||
if err != nil {
|
||||
if auto401 {
|
||||
c.AbortWithError(http.StatusUnauthorized, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
my_user_id := claims["id"].(string)
|
||||
//fmt.Println(my_user_id,claims["id"])
|
||||
UpdateContextUserModel(c, my_user_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
server/controllers/setup.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/akhilrex/hammond/common"
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterSetupController(router *gin.RouterGroup) {
|
||||
router.POST("/clarkson/check", canMigrate)
|
||||
router.POST("/clarkson/migrate", migrate)
|
||||
router.GET("/system/status", appInitialized)
|
||||
}
|
||||
|
||||
func appInitialized(c *gin.Context) {
|
||||
canInitialize, err := service.CanInitializeSystem()
|
||||
message := ""
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"initialized": !canInitialize, "message": message})
|
||||
}
|
||||
|
||||
func canMigrate(c *gin.Context) {
|
||||
var request models.ClarksonMigrationModel
|
||||
if err := c.ShouldBind(&request); err == nil {
|
||||
canMigrate, data, errr := db.CanMigrate(request.Url)
|
||||
errorMessage := ""
|
||||
if errr != nil {
|
||||
errorMessage = errr.Error()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"canMigrate": canMigrate,
|
||||
"data": data,
|
||||
"message": errorMessage,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func migrate(c *gin.Context) {
|
||||
var request models.ClarksonMigrationModel
|
||||
if err := c.ShouldBind(&request); err == nil {
|
||||
canMigrate, _, _ := db.CanMigrate(request.Url)
|
||||
|
||||
if !canMigrate {
|
||||
c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string."))
|
||||
return
|
||||
}
|
||||
|
||||
success, err := db.MigrateClarkson(request.Url)
|
||||
if !success {
|
||||
c.JSON(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": success,
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
22
server/controllers/users.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterUserController(router *gin.RouterGroup) {
|
||||
router.GET("/users", allUsers)
|
||||
}
|
||||
|
||||
func allUsers(c *gin.Context) {
|
||||
users, err := db.GetAllUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
|
||||
}
|
||||
436
server/controllers/vehicle.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/akhilrex/hammond/common"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterVehicleController(router *gin.RouterGroup) {
|
||||
router.POST("/vehicles", createVehicle)
|
||||
router.GET("/vehicles", getAllVehicles)
|
||||
router.GET("/vehicles/:id", getVehicleById)
|
||||
router.PUT("/vehicles/:id", updateVehicle)
|
||||
router.GET("/vehicles/:id/stats", getVehicleStats)
|
||||
router.GET("/vehicles/:id/users", getVehicleUsers)
|
||||
router.POST("/vehicles/:id/users/:subId", shareVehicle)
|
||||
router.DELETE("/vehicles/:id/users/:subId", unshareVehicle)
|
||||
|
||||
router.GET("/me/vehicles", getMyVehicles)
|
||||
router.GET("/me/stats", getMystats)
|
||||
|
||||
router.GET("/vehicles/:id/fillups", getFillupsByVehicleId)
|
||||
router.POST("/vehicles/:id/fillups", createFillup)
|
||||
router.GET("/vehicles/:id/fillups/:subId", getFillupById)
|
||||
router.PUT("/vehicles/:id/fillups/:subId", updateFillup)
|
||||
router.DELETE("/vehicles/:id/fillups/:subId", deleteFillup)
|
||||
|
||||
router.GET("/vehicles/:id/expenses", getExpensesByVehicleId)
|
||||
router.POST("/vehicles/:id/expenses", createExpense)
|
||||
router.GET("/vehicles/:id/expenses/:subId", getExpenseById)
|
||||
router.PUT("/vehicles/:id/expenses/:subId", updateExpense)
|
||||
router.DELETE("/vehicles/:id/expenses/:subId", deleteExpense)
|
||||
|
||||
router.POST("/vehicles/:id/attachments", createVehicleAttachment)
|
||||
router.GET("/vehicles/:id/attachments", getVehicleAttachments)
|
||||
}
|
||||
|
||||
func createVehicle(c *gin.Context) {
|
||||
var request models.CreateVehicleRequest
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
vehicle, err := service.CreateVehicle(request, c.MustGet("userId").(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicle", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, vehicle)
|
||||
}
|
||||
func getVehicleById(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if c.ShouldBindUri(&searchByIdQuery) == nil {
|
||||
vehicle, err := service.GetVehicleById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, vehicle)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
}
|
||||
}
|
||||
func updateVehicle(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
var updateVehicleModel models.UpdateVehicleRequest
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&updateVehicleModel); err == nil {
|
||||
err := service.UpdateVehicle(searchByIdQuery.Id, updateVehicleModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func getAllVehicles(c *gin.Context) {
|
||||
vehicles, err := service.GetAllVehicles()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(200, vehicles)
|
||||
|
||||
}
|
||||
func getMyVehicles(c *gin.Context) {
|
||||
vehicles, err := service.GetUserVehicles(c.MustGet("userId").(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getMyVehicles", err))
|
||||
return
|
||||
}
|
||||
c.JSON(200, vehicles)
|
||||
|
||||
}
|
||||
|
||||
func getFillupsByVehicleId(c *gin.Context) {
|
||||
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
fillups, err := service.GetFillupsByVehicleId(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getFillupsByVehicleId", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, fillups)
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getExpensesByVehicleId(c *gin.Context) {
|
||||
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
data, err := service.GetExpensesByVehicleId(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getExpensesByVehicleId", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func createFillup(c *gin.Context) {
|
||||
var request models.CreateFillupRequest
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
fillup, err := service.CreateFillup(request)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createFillup", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, fillup)
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func createExpense(c *gin.Context) {
|
||||
var request models.CreateExpenseRequest
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&request); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
expense, err := service.CreateExpense(request)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createExpense", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, expense)
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func updateExpense(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
var updateExpenseModel models.UpdateExpenseRequest
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&updateExpenseModel); err == nil {
|
||||
err := service.UpdateExpense(searchByIdQuery.SubId, updateExpenseModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getExpenseById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func updateFillup(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
var updateFillupModel models.UpdateFillupRequest
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&updateFillupModel); err == nil {
|
||||
err := service.UpdateFillup(searchByIdQuery.SubId, updateFillupModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getFillupById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteExpense(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
err := service.DeleteExpenseById(searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getExpenseById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func deleteFillup(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
err := service.DeleteFillupById(searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getFillupById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getExpenseById(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
obj, err := service.GetExpenseById(searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getExpenseById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, obj)
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func getFillupById(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
obj, err := service.GetFillupById(searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getFillupById", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, obj)
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func createVehicleAttachment(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
var dataModel models.CreateVehicleAttachmentModel
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
if err := c.ShouldBind(&dataModel); err == nil {
|
||||
vehicle, err := service.GetVehicleById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicleAttachment", err))
|
||||
return
|
||||
}
|
||||
attachment, err := saveUploadedFile(c, "file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicleAttachment", err))
|
||||
return
|
||||
}
|
||||
err = service.CreateVehicleAttachment(vehicle.ID, attachment.ID, dataModel.Title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicleAttachment", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getVehicleAttachments(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
vehicle, err := service.GetVehicleById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicleAttachment", err))
|
||||
return
|
||||
}
|
||||
|
||||
attachments, err := service.GetVehicleAttachments(vehicle.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("createVehicleAttachment", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, attachments)
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getVehicleUsers(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
vehicle, err := service.GetVehicleById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleUsers", err))
|
||||
return
|
||||
}
|
||||
data, err := service.GetVehicleUsers(vehicle.ID)
|
||||
|
||||
var model []models.UserVehicleSimpleModel
|
||||
|
||||
for _, item := range *data {
|
||||
model = append(model, models.UserVehicleSimpleModel{
|
||||
ID: item.ID,
|
||||
UserID: item.UserID,
|
||||
VehicleID: item.VehicleID,
|
||||
IsOwner: item.IsOwner,
|
||||
Name: item.User.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleUsers", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model)
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func shareVehicle(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
err := service.ShareVehicle(searchByIdQuery.Id, searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
func unshareVehicle(c *gin.Context) {
|
||||
var searchByIdQuery models.SubItemQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
err := service.UnshareVehicle(searchByIdQuery.Id, searchByIdQuery.SubId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getVehicleStats(c *gin.Context) {
|
||||
var searchByIdQuery models.SearchByIdQuery
|
||||
|
||||
if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
|
||||
|
||||
vehicle, err := service.GetVehicleById(searchByIdQuery.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getVehicleState", err))
|
||||
return
|
||||
}
|
||||
|
||||
model := models.VehicleStatsModel{}
|
||||
|
||||
c.JSON(http.StatusOK, model.SetStats(&vehicle.Fillups, &vehicle.Expenses))
|
||||
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMystats(c *gin.Context) {
|
||||
var model models.UserStatsQueryModel
|
||||
if err := c.ShouldBind(&model); err == nil {
|
||||
|
||||
stats, err := service.GetUserStats(c.MustGet("userId").(string), model)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("getMyVehicles", err))
|
||||
return
|
||||
}
|
||||
c.JSON(200, stats)
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
}
|
||||
|
||||
}
|
||||
22
server/db/base.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//Base is
|
||||
type Base struct {
|
||||
ID string `sql:"type:uuid;primary_key" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
//BeforeCreate
|
||||
func (base *Base) BeforeCreate(tx *gorm.DB) error {
|
||||
tx.Statement.SetColumn("ID", uuid.NewV4().String())
|
||||
return nil
|
||||
}
|
||||
223
server/db/clarkson.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CanMigrate(connectionString string) (bool, interface{}, error) {
|
||||
|
||||
canInitialize, err := CanInitializeSystem()
|
||||
if !canInitialize {
|
||||
return canInitialize, nil, err
|
||||
}
|
||||
|
||||
cdb, err := gorm.Open(mysql.Open(connectionString), &gorm.Config{})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
var usersCount, vehiclesCount, fuelCount int64
|
||||
tx := cdb.Table("Users").Count(&usersCount)
|
||||
if tx.Error != nil {
|
||||
return false, nil, tx.Error
|
||||
}
|
||||
tx = cdb.Table("Vehicles").Count(&vehiclesCount)
|
||||
if tx.Error != nil {
|
||||
return false, nil, tx.Error
|
||||
}
|
||||
tx = cdb.Table("Fuel").Count(&fuelCount)
|
||||
if tx.Error != nil {
|
||||
return false, nil, tx.Error
|
||||
}
|
||||
data := struct {
|
||||
Users int64 `json:"users"`
|
||||
Vehicles int64 `json:"vehicles"`
|
||||
Fillups int64 `json:"fillups"`
|
||||
}{
|
||||
Vehicles: vehiclesCount,
|
||||
Users: usersCount,
|
||||
Fillups: fuelCount,
|
||||
}
|
||||
|
||||
return true, data, nil
|
||||
}
|
||||
|
||||
func MigrateClarkson(connectionString string) (bool, error) {
|
||||
canInitialize, err := CanInitializeSystem()
|
||||
if !canInitialize {
|
||||
return canInitialize, err
|
||||
}
|
||||
|
||||
//dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
cdb, err := gorm.Open(mysql.Open(connectionString), &gorm.Config{})
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
/////Models
|
||||
type CUser struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Email string `gorm:"column:email"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Admin bool `gorm:"column:admin"`
|
||||
FuelUnit int `gorm:"column:fuelUnit"`
|
||||
DistanceUnit int `gorm:"column:distanceUnit"`
|
||||
FuelConsumptionUnit int `gorm:"column:fuelConsumptionUnit"`
|
||||
CurrencyUnit int `gorm:"column:currencyUnit"`
|
||||
}
|
||||
|
||||
type CVehicle struct {
|
||||
ID string `gorm:"column:id"`
|
||||
User string
|
||||
Name string
|
||||
Registration string
|
||||
Make string
|
||||
Model string
|
||||
YearOfManufacture int `gorm:"column:yearOfManufacture"`
|
||||
Vin string
|
||||
EngineSizeCC int `gorm:"column:engineSizeCC"`
|
||||
FuelType int `gorm:"column:fuelType"`
|
||||
}
|
||||
|
||||
type CFuel struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Vehicle string `gorm:"column:vehicle"`
|
||||
Date time.Time `gorm:"column:date"`
|
||||
FuelAmount float32 `gorm:"column:fuelAmount"`
|
||||
TotalCost float32 `gorm:"column:totalCost"`
|
||||
FuelUnitCost float32 `gorm:"column:fuelUnitCost"`
|
||||
OdometerReading int `gorm:"column:odometerReading"`
|
||||
Notes string `gorm:"column:notes"`
|
||||
FullTank bool `gorm:"column:fullTank"`
|
||||
MissedFillup bool `gorm:"column:missedFillUp"`
|
||||
}
|
||||
|
||||
distanceUnitMap := map[int]DistanceUnit{
|
||||
1: MILES,
|
||||
2: KILOMETERS,
|
||||
}
|
||||
|
||||
fuelTypeMap := map[int]FuelType{
|
||||
1: PETROL,
|
||||
2: DIESEL,
|
||||
3: ETHANOL,
|
||||
4: LPG,
|
||||
}
|
||||
|
||||
fuelUnitsMap := map[int]FuelUnit{
|
||||
1: LITRE,
|
||||
2: GALLON,
|
||||
3: US_GALLON,
|
||||
}
|
||||
currencyMap := map[int]string{
|
||||
1: "GBP",
|
||||
2: "USD",
|
||||
3: "EUR",
|
||||
4: "AUD",
|
||||
5: "CAD",
|
||||
}
|
||||
|
||||
newUserIdsMap := make(map[string]User)
|
||||
oldUserIdsMap := make(map[string]CUser)
|
||||
|
||||
var allUsers []CUser
|
||||
cdb.Table("Users").Find(&allUsers)
|
||||
for _, v := range allUsers {
|
||||
role := USER
|
||||
if v.Admin {
|
||||
role = ADMIN
|
||||
}
|
||||
user := User{
|
||||
Email: v.Email,
|
||||
Currency: currencyMap[v.CurrencyUnit],
|
||||
DistanceUnit: distanceUnitMap[v.DistanceUnit],
|
||||
Role: role,
|
||||
Name: v.Username,
|
||||
}
|
||||
user.SetPassword("hammond")
|
||||
err = CreateUser(&user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
newUserIdsMap[v.ID] = user
|
||||
oldUserIdsMap[v.ID] = v
|
||||
|
||||
if v.Admin {
|
||||
setting := GetOrCreateSetting()
|
||||
setting.Currency = user.Currency
|
||||
setting.DistanceUnit = user.DistanceUnit
|
||||
UpdateSettings(setting)
|
||||
}
|
||||
}
|
||||
|
||||
newVehicleIdsMap := make(map[string]Vehicle)
|
||||
oldVehicleIdsMap := make(map[string]CVehicle)
|
||||
vehicleUserMap := make(map[string]User)
|
||||
var allVehicles []CVehicle
|
||||
cdb.Table("Vehicles").Find(&allVehicles)
|
||||
for _, model := range allVehicles {
|
||||
vehicle := Vehicle{
|
||||
Nickname: model.Name,
|
||||
Registration: model.Registration,
|
||||
Model: model.Model,
|
||||
Make: model.Make,
|
||||
YearOfManufacture: model.YearOfManufacture,
|
||||
EngineSize: float32(model.EngineSizeCC),
|
||||
FuelUnit: fuelUnitsMap[oldUserIdsMap[model.User].FuelUnit],
|
||||
FuelType: fuelTypeMap[model.FuelType],
|
||||
}
|
||||
|
||||
tx := DB.Create(&vehicle)
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
association := UserVehicle{
|
||||
UserID: newUserIdsMap[model.User].ID,
|
||||
VehicleID: vehicle.ID,
|
||||
IsOwner: true,
|
||||
}
|
||||
vehicleUserMap[vehicle.ID] = newUserIdsMap[model.User]
|
||||
tx = DB.Create(&association)
|
||||
|
||||
if tx.Error != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
newVehicleIdsMap[model.ID] = vehicle
|
||||
oldVehicleIdsMap[model.ID] = model
|
||||
}
|
||||
|
||||
var allFillups []CFuel
|
||||
cdb.Table("Fuel").Find(&allFillups)
|
||||
for _, model := range allFillups {
|
||||
fillup := Fillup{
|
||||
VehicleID: newVehicleIdsMap[model.Vehicle].ID,
|
||||
FuelUnit: newVehicleIdsMap[model.Vehicle].FuelUnit,
|
||||
FuelQuantity: model.FuelAmount,
|
||||
PerUnitPrice: model.FuelUnitCost,
|
||||
TotalAmount: model.TotalCost,
|
||||
OdoReading: model.OdometerReading,
|
||||
IsTankFull: &model.FullTank,
|
||||
HasMissedFillup: &model.MissedFillup,
|
||||
Comments: model.Notes,
|
||||
UserID: vehicleUserMap[newVehicleIdsMap[model.Vehicle].ID].ID,
|
||||
Date: model.Date,
|
||||
Currency: vehicleUserMap[newVehicleIdsMap[model.Vehicle].ID].Currency,
|
||||
DistanceUnit: vehicleUserMap[newVehicleIdsMap[model.Vehicle].ID].DistanceUnit,
|
||||
}
|
||||
|
||||
tx := DB.Create(&fillup)
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
58
server/db/db.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//DB is
|
||||
var DB *gorm.DB
|
||||
|
||||
//Init is used to Initialize Database
|
||||
func Init() (*gorm.DB, error) {
|
||||
// github.com/mattn/go-sqlite3
|
||||
configPath := os.Getenv("CONFIG")
|
||||
dbPath := path.Join(configPath, "hammond.db")
|
||||
log.Println(dbPath)
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("db err: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localDB, _ := db.DB()
|
||||
localDB.SetMaxIdleConns(10)
|
||||
//db.LogMode(true)
|
||||
DB = db
|
||||
return DB, nil
|
||||
}
|
||||
|
||||
//Migrate Database
|
||||
func Migrate() {
|
||||
err := DB.AutoMigrate(&Attachment{}, &QuickEntry{}, &User{}, &Vehicle{}, &UserVehicle{}, &VehicleAttachment{}, &Fillup{}, &Expense{}, &Setting{}, &JobLock{}, &Migration{})
|
||||
if err != nil {
|
||||
fmt.Println("1 " + err.Error())
|
||||
}
|
||||
err = DB.SetupJoinTable(&User{}, "Vehicles", &UserVehicle{})
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
err = DB.SetupJoinTable(&Vehicle{}, "Attachments", &VehicleAttachment{})
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
RunMigrations()
|
||||
}
|
||||
|
||||
// Using this function to get a connection, you can create your connection pool here.
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
192
server/db/dbModels.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
Email string `gorm:"unique" json:"email"`
|
||||
Password string `json:"-"`
|
||||
Currency string `json:"currency"`
|
||||
DistanceUnit DistanceUnit `json:"distanceUnit"`
|
||||
Role Role `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Vehicles []Vehicle `gorm:"many2many:user_vehicles;" json:"vehicles"`
|
||||
}
|
||||
|
||||
func (b *User) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
User
|
||||
RoleDetail EnumDetail `json:"roleDetail"`
|
||||
DistanceUnitDetail EnumDetail `json:"distanceUnitDetail"`
|
||||
}{
|
||||
User: *b,
|
||||
RoleDetail: b.RoleDetail(),
|
||||
DistanceUnitDetail: b.DistanceUnitDetail(),
|
||||
})
|
||||
}
|
||||
func (v *User) RoleDetail() EnumDetail {
|
||||
return RoleDetails[v.Role]
|
||||
}
|
||||
func (v *User) DistanceUnitDetail() EnumDetail {
|
||||
return DistanceUnitDetails[v.DistanceUnit]
|
||||
}
|
||||
|
||||
func (u *User) SetPassword(password string) error {
|
||||
if len(password) == 0 {
|
||||
return errors.New("password should not be empty")
|
||||
}
|
||||
bytePassword := []byte(password)
|
||||
// Make sure the second param `bcrypt generator cost` between [4, 32)
|
||||
passwordHash, _ := bcrypt.GenerateFromPassword(bytePassword, bcrypt.DefaultCost)
|
||||
u.Password = string(passwordHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) CheckPassword(password string) error {
|
||||
bytePassword := []byte(password)
|
||||
byteHashedPassword := []byte(u.Password)
|
||||
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
|
||||
}
|
||||
|
||||
type Vehicle struct {
|
||||
Base
|
||||
Nickname string `json:"nickname"`
|
||||
Registration string `json:"registration"`
|
||||
Make string `json:"make"`
|
||||
Model string `json:"model"`
|
||||
YearOfManufacture int `json:"yearOfManufacture"`
|
||||
EngineSize float32 `json:"engineSize"`
|
||||
FuelUnit FuelUnit `json:"fuelUnit"`
|
||||
FuelType FuelType `json:"fuelType"`
|
||||
Users []User `gorm:"many2many:user_vehicles;" json:"users"`
|
||||
Fillups []Fillup `json:"fillups"`
|
||||
Expenses []Expense `json:"expenses"`
|
||||
Attachments []Attachment `gorm:"many2many:vehicle_attachments;" json:"attachments"`
|
||||
IsOwner bool `gorm:"->" json:"isOwner"`
|
||||
}
|
||||
|
||||
func (b *Vehicle) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Vehicle
|
||||
FuelTypeDetail EnumDetail `json:"fuelTypeDetail"`
|
||||
FuelUnitDetail EnumDetail `json:"fuelUnitDetail"`
|
||||
}{
|
||||
Vehicle: *b,
|
||||
FuelTypeDetail: b.FuelTypeDetail(),
|
||||
FuelUnitDetail: b.FuelUnitDetail(),
|
||||
})
|
||||
}
|
||||
func (v *Vehicle) FuelTypeDetail() EnumDetail {
|
||||
return FuelTypeDetails[v.FuelType]
|
||||
}
|
||||
|
||||
func (v *Vehicle) FuelUnitDetail() EnumDetail {
|
||||
return FuelUnitDetails[v.FuelUnit]
|
||||
}
|
||||
|
||||
type UserVehicle struct {
|
||||
Base
|
||||
UserID string `gorm:"primaryKey"`
|
||||
User User `json:"user"`
|
||||
VehicleID string `gorm:"primaryKey"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
}
|
||||
|
||||
type Fillup struct {
|
||||
Base
|
||||
VehicleID string `json:"vehicleId"`
|
||||
Vehicle Vehicle `json:"-"`
|
||||
FuelUnit FuelUnit `json:"fuelUnit"`
|
||||
FuelQuantity float32 `json:"fuelQuantity"`
|
||||
PerUnitPrice float32 `json:"perUnitPrice"`
|
||||
TotalAmount float32 `json:"totalAmount"`
|
||||
OdoReading int `json:"odoReading"`
|
||||
IsTankFull *bool `json:"isTankFull"`
|
||||
HasMissedFillup *bool `json:"hasMissedFillup"`
|
||||
Comments string `json:"comments"`
|
||||
FillingStation string `json:"fillingStation"`
|
||||
UserID string `json:"userId"`
|
||||
User User `json:"user"`
|
||||
Date time.Time `json:"date"`
|
||||
Currency string `json:"currency"`
|
||||
DistanceUnit DistanceUnit `json:"distanceUnit"`
|
||||
}
|
||||
|
||||
func (v *Fillup) FuelUnitDetail() EnumDetail {
|
||||
return FuelUnitDetails[v.FuelUnit]
|
||||
}
|
||||
func (b *Fillup) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Fillup
|
||||
FuelUnitDetail EnumDetail `json:"fuelUnitDetail"`
|
||||
}{
|
||||
Fillup: *b,
|
||||
FuelUnitDetail: b.FuelUnitDetail(),
|
||||
})
|
||||
}
|
||||
|
||||
type Expense struct {
|
||||
Base
|
||||
VehicleID string `json:"vehicleId"`
|
||||
Vehicle Vehicle `json:"-"`
|
||||
Amount float32 `json:"amount"`
|
||||
OdoReading int `json:"odoReading"`
|
||||
Comments string `json:"comments"`
|
||||
ExpenseType string `json:"expenseType"`
|
||||
UserID string `json:"userId"`
|
||||
User User `json:"user"`
|
||||
Date time.Time `json:"date"`
|
||||
Currency string `json:"currency"`
|
||||
DistanceUnit DistanceUnit `json:"distanceUnit"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
Base
|
||||
Currency string `json:"currency" gorm:"default:INR"`
|
||||
DistanceUnit DistanceUnit `json:"distanceUnit" gorm:"default:1"`
|
||||
}
|
||||
type Migration struct {
|
||||
Base
|
||||
Date time.Time
|
||||
Name string
|
||||
}
|
||||
type JobLock struct {
|
||||
Base
|
||||
Date time.Time
|
||||
Name string
|
||||
Duration int
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
Base
|
||||
Path string `json:"path"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
Title string `gorm:"->" json:"title"`
|
||||
UserID string `json:"userId"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type QuickEntry struct {
|
||||
Base
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
Attachment Attachment `json:"attachment"`
|
||||
ProcessDate *time.Time `json:"processDate"`
|
||||
UserID string `json:"userId"`
|
||||
User User `json:"user"`
|
||||
Comments string `json:"comments"`
|
||||
}
|
||||
|
||||
type VehicleAttachment struct {
|
||||
Base
|
||||
AttachmentID string `gorm:"primaryKey" json:"attachmentId"`
|
||||
VehicleID string `gorm:"primaryKey" json:"vehicleId"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
304
server/db/dbfunctions.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func CanInitializeSystem() (bool, error) {
|
||||
users, _ := GetAllUsers()
|
||||
if len(*users) != 0 {
|
||||
// db.MigrateClarkson("root:password@tcp(192.168.0.117:3306)/clarkson?charset=utf8mb4&parseTime=True&loc=Local")
|
||||
return false,
|
||||
fmt.Errorf("there are already users in the database. Migration can only be done on an empty database")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func CreateUser(user *User) error {
|
||||
tx := DB.Create(&user)
|
||||
return tx.Error
|
||||
}
|
||||
func UpdateUser(user *User) error {
|
||||
tx := DB.Omit(clause.Associations).Save(&user)
|
||||
return tx.Error
|
||||
}
|
||||
func FindOneUser(condition interface{}) (User, error) {
|
||||
|
||||
var model User
|
||||
err := DB.Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
func GetAllUsers() (*[]User, error) {
|
||||
|
||||
sorting := "created_at desc"
|
||||
var users []User
|
||||
result := DB.Order(sorting).Find(&users)
|
||||
return &users, result.Error
|
||||
}
|
||||
|
||||
func GetAllVehicles(sorting string) (*[]Vehicle, error) {
|
||||
if sorting == "" {
|
||||
sorting = "created_at desc"
|
||||
}
|
||||
var vehicles []Vehicle
|
||||
result := DB.Preload("Fillups", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("fillups.date DESC")
|
||||
}).Preload("Expenses", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("expenses.date DESC")
|
||||
}).Order(sorting).Find(&vehicles)
|
||||
return &vehicles, result.Error
|
||||
}
|
||||
|
||||
func GetVehicleOwner(vehicleId string) (string, error) {
|
||||
var mapping UserVehicle
|
||||
|
||||
tx := DB.Where("vehicle_id = ? AND is_owner = 1", vehicleId).First(&mapping)
|
||||
|
||||
if tx.Error != nil {
|
||||
return "", tx.Error
|
||||
}
|
||||
return mapping.ID, nil
|
||||
}
|
||||
|
||||
func GetVehicleUsers(vehicleId string) (*[]UserVehicle, error) {
|
||||
var mapping []UserVehicle
|
||||
|
||||
tx := DB.Debug().Preload("User").Where("vehicle_id = ?", vehicleId).Find(&mapping)
|
||||
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return &mapping, nil
|
||||
}
|
||||
|
||||
func ShareVehicle(vehicleId, userId string) error {
|
||||
var mapping UserVehicle
|
||||
|
||||
tx := DB.Where("vehicle_id = ? AND user_id = ?", vehicleId, userId).First(&mapping)
|
||||
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
newMapping := UserVehicle{
|
||||
UserID: userId,
|
||||
VehicleID: vehicleId,
|
||||
IsOwner: false,
|
||||
}
|
||||
tx = DB.Create(&newMapping)
|
||||
return tx.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UnshareVehicle(vehicleId, userId string) error {
|
||||
var mapping UserVehicle
|
||||
|
||||
tx := DB.Where("vehicle_id = ? AND user_id = ?", vehicleId, userId).First(&mapping)
|
||||
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if mapping.IsOwner {
|
||||
return fmt.Errorf("Cannot unshare owner")
|
||||
}
|
||||
result := DB.Where("id=?", mapping.ID).Delete(&UserVehicle{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func GetUserVehicles(id string) (*[]Vehicle, error) {
|
||||
var toReturn []Vehicle
|
||||
user, err := GetUserById(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = DB.Preload("Fillups", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("fillups.date DESC")
|
||||
}).Preload("Expenses", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("expenses.date DESC")
|
||||
}).Model(user).Select("vehicles.*,user_vehicles.is_owner").Association("Vehicles").Find(&toReturn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &toReturn, nil
|
||||
}
|
||||
func GetUserById(id string) (*User, error) {
|
||||
var data User
|
||||
result := DB.Preload(clause.Associations).First(&data, "id=?", id)
|
||||
return &data, result.Error
|
||||
}
|
||||
func GetVehicleById(id string) (*Vehicle, error) {
|
||||
var vehicle Vehicle
|
||||
result := DB.Preload(clause.Associations).First(&vehicle, "id=?", id)
|
||||
return &vehicle, result.Error
|
||||
}
|
||||
func GetFillupById(id string) (*Fillup, error) {
|
||||
var obj Fillup
|
||||
result := DB.Preload(clause.Associations).First(&obj, "id=?", id)
|
||||
return &obj, result.Error
|
||||
}
|
||||
|
||||
func GetFillupsByVehicleId(id string) (*[]Fillup, error) {
|
||||
var obj []Fillup
|
||||
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Fillup{VehicleID: id})
|
||||
return &obj, result.Error
|
||||
}
|
||||
func FindFillups(condition interface{}) (*[]Fillup, error) {
|
||||
|
||||
var model []Fillup
|
||||
err := DB.Where(condition).Find(&model).Error
|
||||
return &model, err
|
||||
}
|
||||
|
||||
func FindFillupsForDateRange(vehicleIds []string, start, end time.Time) (*[]Fillup, error) {
|
||||
|
||||
var model []Fillup
|
||||
err := DB.Where("date <= ? AND date >= ? AND vehicle_id in ?", end, start, vehicleIds).Find(&model).Error
|
||||
return &model, err
|
||||
}
|
||||
func FindExpensesForDateRange(vehicleIds []string, start, end time.Time) (*[]Expense, error) {
|
||||
|
||||
var model []Expense
|
||||
err := DB.Where("date <= ? AND date >= ? AND vehicle_id in ?", end, start, vehicleIds).Find(&model).Error
|
||||
return &model, err
|
||||
}
|
||||
|
||||
func GetExpensesByVehicleId(id string) (*[]Expense, error) {
|
||||
var obj []Expense
|
||||
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Expense{VehicleID: id})
|
||||
return &obj, result.Error
|
||||
}
|
||||
func GetExpenseById(id string) (*Expense, error) {
|
||||
var obj Expense
|
||||
result := DB.Preload(clause.Associations).First(&obj, "id=?", id)
|
||||
return &obj, result.Error
|
||||
}
|
||||
|
||||
func DeleteFillupById(id string) error {
|
||||
|
||||
result := DB.Where("id=?", id).Delete(&Fillup{})
|
||||
return result.Error
|
||||
}
|
||||
func DeleteExpenseById(id string) error {
|
||||
result := DB.Where("id=?", id).Delete(&Expense{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func GetAllQuickEntries(sorting string) (*[]QuickEntry, error) {
|
||||
if sorting == "" {
|
||||
sorting = "created_at desc"
|
||||
}
|
||||
var quickEntries []QuickEntry
|
||||
result := DB.Preload(clause.Associations).Order(sorting).Find(&quickEntries)
|
||||
return &quickEntries, result.Error
|
||||
}
|
||||
func GetQuickEntriesForUser(userId, sorting string) (*[]QuickEntry, error) {
|
||||
if sorting == "" {
|
||||
sorting = "created_at desc"
|
||||
}
|
||||
var quickEntries []QuickEntry
|
||||
result := DB.Preload(clause.Associations).Where("user_id = ?", userId).Order(sorting).Find(&quickEntries)
|
||||
return &quickEntries, result.Error
|
||||
}
|
||||
func GetQuickEntryById(id string) (*QuickEntry, error) {
|
||||
var quickEntry QuickEntry
|
||||
result := DB.Preload(clause.Associations).First(&quickEntry, "id=?", id)
|
||||
return &quickEntry, result.Error
|
||||
}
|
||||
func UpdateQuickEntry(entry *QuickEntry) error {
|
||||
return DB.Save(entry).Error
|
||||
}
|
||||
func SetQuickEntryAsProcessed(id string, processDate time.Time) error {
|
||||
result := DB.Model(QuickEntry{}).Where("id=?", id).Update("process_date", processDate)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func GetAttachmentById(id string) (*Attachment, error) {
|
||||
var entry Attachment
|
||||
result := DB.Preload(clause.Associations).First(&entry, "id=?", id)
|
||||
return &entry, result.Error
|
||||
}
|
||||
func GetVehicleAttachments(vehicleId string) (*[]Attachment, error) {
|
||||
var attachments []Attachment
|
||||
vehicle, err := GetVehicleById(vehicleId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = DB.Debug().Model(vehicle).Select("attachments.*,vehicle_attachments.title").Preload("User").Association("Attachments").Find(&attachments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &attachments, nil
|
||||
}
|
||||
|
||||
func UpdateSettings(setting *Setting) error {
|
||||
tx := DB.Save(&setting)
|
||||
return tx.Error
|
||||
}
|
||||
func GetOrCreateSetting() *Setting {
|
||||
var setting Setting
|
||||
result := DB.First(&setting)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
DB.Save(&Setting{})
|
||||
DB.First(&setting)
|
||||
}
|
||||
return &setting
|
||||
}
|
||||
|
||||
func GetLock(name string) *JobLock {
|
||||
var jobLock JobLock
|
||||
result := DB.Where("name = ?", name).First(&jobLock)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return &JobLock{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
return &jobLock
|
||||
}
|
||||
func Lock(name string, duration int) {
|
||||
jobLock := GetLock(name)
|
||||
if jobLock == nil {
|
||||
jobLock = &JobLock{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
jobLock.Duration = duration
|
||||
jobLock.Date = time.Now()
|
||||
if jobLock.ID == "" {
|
||||
DB.Create(&jobLock)
|
||||
} else {
|
||||
DB.Save(&jobLock)
|
||||
}
|
||||
}
|
||||
func Unlock(name string) {
|
||||
jobLock := GetLock(name)
|
||||
if jobLock == nil {
|
||||
return
|
||||
}
|
||||
jobLock.Duration = 0
|
||||
jobLock.Date = time.Time{}
|
||||
DB.Save(&jobLock)
|
||||
}
|
||||
|
||||
func UnlockMissedJobs() {
|
||||
var jobLocks []JobLock
|
||||
|
||||
result := DB.Find(&jobLocks)
|
||||
if result.Error != nil {
|
||||
return
|
||||
}
|
||||
for _, job := range jobLocks {
|
||||
if (job.Date == time.Time{}) {
|
||||
continue
|
||||
}
|
||||
var duration time.Duration
|
||||
duration = time.Duration(job.Duration)
|
||||
d := job.Date.Add(time.Minute * duration)
|
||||
if d.Before(time.Now()) {
|
||||
fmt.Println(job.Name + " is unlocked")
|
||||
Unlock(job.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
server/db/enums.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package db
|
||||
|
||||
type FuelUnit int
|
||||
|
||||
const (
|
||||
LITRE FuelUnit = iota
|
||||
GALLON
|
||||
US_GALLON
|
||||
KILOGRAM
|
||||
KILOWATT_HOUR
|
||||
MINUTE
|
||||
)
|
||||
|
||||
type FuelType int
|
||||
|
||||
const (
|
||||
PETROL FuelType = iota
|
||||
DIESEL
|
||||
ETHANOL
|
||||
CNG
|
||||
ELECTRIC
|
||||
LPG
|
||||
)
|
||||
|
||||
type DistanceUnit int
|
||||
|
||||
const (
|
||||
MILES DistanceUnit = iota
|
||||
KILOMETERS
|
||||
)
|
||||
|
||||
type Role int
|
||||
|
||||
const (
|
||||
ADMIN Role = iota
|
||||
USER
|
||||
)
|
||||
|
||||
type EnumDetail struct {
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
var FuelUnitDetails map[FuelUnit]EnumDetail = map[FuelUnit]EnumDetail{
|
||||
LITRE: {
|
||||
Short: "Lt",
|
||||
Long: "Litre",
|
||||
},
|
||||
GALLON: {
|
||||
Short: "Gal",
|
||||
Long: "Gallon",
|
||||
}, KILOGRAM: {
|
||||
Short: "Kg",
|
||||
Long: "Kilogram",
|
||||
}, KILOWATT_HOUR: {
|
||||
Short: "KwH",
|
||||
Long: "Kilowatt Hour",
|
||||
}, US_GALLON: {
|
||||
Short: "US Gal",
|
||||
Long: "US Gallon",
|
||||
},
|
||||
MINUTE: {
|
||||
Short: "Mins",
|
||||
Long: "Minutes",
|
||||
},
|
||||
}
|
||||
|
||||
var FuelTypeDetails map[FuelType]EnumDetail = map[FuelType]EnumDetail{
|
||||
PETROL: {
|
||||
Short: "Petrol",
|
||||
Long: "Petrol",
|
||||
},
|
||||
DIESEL: {
|
||||
Short: "Diesel",
|
||||
Long: "Diesel",
|
||||
}, CNG: {
|
||||
Short: "CNG",
|
||||
Long: "CNG",
|
||||
}, LPG: {
|
||||
Short: "LPG",
|
||||
Long: "LPG",
|
||||
}, ELECTRIC: {
|
||||
Short: "Electric",
|
||||
Long: "Electric",
|
||||
}, ETHANOL: {
|
||||
Short: "Ethanol",
|
||||
Long: "Ethanol",
|
||||
},
|
||||
}
|
||||
|
||||
var DistanceUnitDetails map[DistanceUnit]EnumDetail = map[DistanceUnit]EnumDetail{
|
||||
KILOMETERS: {
|
||||
Short: "Km",
|
||||
Long: "Kilometers",
|
||||
},
|
||||
MILES: {
|
||||
Short: "Mi",
|
||||
Long: "Miles",
|
||||
},
|
||||
}
|
||||
|
||||
var RoleDetails map[Role]EnumDetail = map[Role]EnumDetail{
|
||||
ADMIN: {
|
||||
Short: "Admin",
|
||||
Long: "ADMIN",
|
||||
},
|
||||
USER: {
|
||||
Short: "User",
|
||||
Long: "USER",
|
||||
},
|
||||
}
|
||||
43
server/db/migrations.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type localMigration struct {
|
||||
Name string
|
||||
Query string
|
||||
}
|
||||
|
||||
var migrations = []localMigration{
|
||||
// {
|
||||
// Name: "2020_11_03_04_42_SetDefaultDownloadStatus",
|
||||
// Query: "update podcast_items set download_status=2 where download_path!='' and download_status=0",
|
||||
// },
|
||||
}
|
||||
|
||||
func RunMigrations() {
|
||||
for _, mig := range migrations {
|
||||
ExecuteAndSaveMigration(mig.Name, mig.Query)
|
||||
}
|
||||
}
|
||||
func ExecuteAndSaveMigration(name string, query string) error {
|
||||
var migration Migration
|
||||
result := DB.Where("name=?", name).First(&migration)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
fmt.Println(query)
|
||||
result = DB.Debug().Exec(query)
|
||||
if result.Error == nil {
|
||||
DB.Save(&Migration{
|
||||
Date: time.Now(),
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
server/go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module github.com/akhilrex/hammond
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/gin-contrib/location v0.0.2
|
||||
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 // indirect
|
||||
github.com/gin-gonic/gin v1.7.1
|
||||
github.com/go-playground/validator/v10 v10.4.1
|
||||
github.com/jasonlvhit/gocron v0.0.1
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gorm.io/driver/mysql v1.0.5
|
||||
gorm.io/driver/sqlite v1.1.4
|
||||
gorm.io/gorm v1.21.3
|
||||
)
|
||||
110
server/go.sum
Normal file
@@ -0,0 +1,110 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gin-contrib/location v0.0.2 h1:QZKh1+K/LLR4KG/61eIO3b7MLuKi8tytQhV6texLgP4=
|
||||
github.com/gin-contrib/location v0.0.2/go.mod h1:NGoidiRlf0BlA/VKSVp+g3cuSMeTmip/63PhEjRhUAc=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw=
|
||||
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/gin-gonic/gin v1.7.1 h1:qC89GU3p8TvKWMAVhEpmpB2CIb1hnqt2UdKZaP93mS8=
|
||||
github.com/gin-gonic/gin v1.7.1/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jasonlvhit/gocron v0.0.1 h1:qTt5qF3b3srDjeOIR4Le1LfeyvoYzJlYpqvG7tJX5YU=
|
||||
github.com/jasonlvhit/gocron v0.0.1/go.mod h1:k9a3TV8VcU73XZxfVHCHWMWF9SOqgoku0/QlY2yvlA4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gorm.io/driver/mysql v1.0.5 h1:WAAmvLK2rG0tCOqrf5XcLi2QUwugd4rcVJ/W3aoon9o=
|
||||
gorm.io/driver/mysql v1.0.5/go.mod h1:N1OIhHAIhx5SunkMGqWbGFVeh4yTNWKmMo1GOAsohLI=
|
||||
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
|
||||
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
|
||||
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.21.3 h1:qDFi55ZOsjZTwk5eN+uhAmHi8GysJ/qCTichM/yO7ME=
|
||||
gorm.io/gorm v1.21.3/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
22
server/internal/sanitize/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
27
server/internal/sanitize/LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
Copyright (c) 2017 Mechanism Design. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
62
server/internal/sanitize/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
sanitize [](https://godoc.org/github.com/kennygrant/sanitize) [](https://goreportcard.com/report/github.com/kennygrant/sanitize) [](https://circleci.com/gh/kennygrant/sanitize)
|
||||
========
|
||||
|
||||
Package sanitize provides functions to sanitize html and paths with go (golang).
|
||||
|
||||
FUNCTIONS
|
||||
|
||||
|
||||
```go
|
||||
sanitize.Accents(s string) string
|
||||
```
|
||||
|
||||
Accents replaces a set of accented characters with ascii equivalents.
|
||||
|
||||
```go
|
||||
sanitize.BaseName(s string) string
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```go
|
||||
sanitize.HTML(s string) string
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```go
|
||||
sanitize.HTMLAllowing(s string, args...[]string) (string, error)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```go
|
||||
sanitize.Name(s string) string
|
||||
```
|
||||
|
||||
Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters.
|
||||
|
||||
```go
|
||||
sanitize.Path(s string) string
|
||||
```
|
||||
|
||||
Path makes a string safe to use as an url path.
|
||||
|
||||
|
||||
Changes
|
||||
-------
|
||||
|
||||
Version 1.2
|
||||
|
||||
Adjusted HTML function to avoid linter warning
|
||||
Added more tests from https://githubengineering.com/githubs-post-csp-journey/
|
||||
Chnaged name of license file
|
||||
Added badges and change log to readme
|
||||
|
||||
Version 1.1
|
||||
Fixed type in comments.
|
||||
Merge pull request from Povilas Balzaravicius Pawka
|
||||
- replace br tags with newline even when they contain a space
|
||||
|
||||
Version 1.0
|
||||
First release
|
||||
388
server/internal/sanitize/sanitize.go
Normal file
@@ -0,0 +1,388 @@
|
||||
// Package sanitize provides functions for sanitizing text.
|
||||
package sanitize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
parser "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
ignoreTags = []string{"title", "script", "style", "iframe", "frame", "frameset", "noframes", "noembed", "embed", "applet", "object", "base"}
|
||||
|
||||
defaultTags = []string{"h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "hr", "p", "br", "b", "i", "strong", "em", "ol", "ul", "li", "a", "img", "pre", "code", "blockquote", "article", "section"}
|
||||
|
||||
defaultAttributes = []string{"id", "class", "src", "href", "title", "alt", "name", "rel"}
|
||||
)
|
||||
|
||||
// HTMLAllowing sanitizes html, allowing some tags.
|
||||
// Arrays of allowed tags and allowed attributes may optionally be passed as the second and third arguments.
|
||||
func HTMLAllowing(s string, args ...[]string) (string, error) {
|
||||
|
||||
allowedTags := defaultTags
|
||||
if len(args) > 0 {
|
||||
allowedTags = args[0]
|
||||
}
|
||||
allowedAttributes := defaultAttributes
|
||||
if len(args) > 1 {
|
||||
allowedAttributes = args[1]
|
||||
}
|
||||
|
||||
// Parse the html
|
||||
tokenizer := parser.NewTokenizer(strings.NewReader(s))
|
||||
|
||||
buffer := bytes.NewBufferString("")
|
||||
ignore := ""
|
||||
|
||||
for {
|
||||
tokenType := tokenizer.Next()
|
||||
token := tokenizer.Token()
|
||||
|
||||
switch tokenType {
|
||||
|
||||
case parser.ErrorToken:
|
||||
err := tokenizer.Err()
|
||||
if err == io.EOF {
|
||||
return buffer.String(), nil
|
||||
}
|
||||
return "", err
|
||||
|
||||
case parser.StartTagToken:
|
||||
|
||||
if len(ignore) == 0 && includes(allowedTags, token.Data) {
|
||||
token.Attr = cleanAttributes(token.Attr, allowedAttributes)
|
||||
buffer.WriteString(token.String())
|
||||
} else if includes(ignoreTags, token.Data) {
|
||||
ignore = token.Data
|
||||
}
|
||||
|
||||
case parser.SelfClosingTagToken:
|
||||
|
||||
if len(ignore) == 0 && includes(allowedTags, token.Data) {
|
||||
token.Attr = cleanAttributes(token.Attr, allowedAttributes)
|
||||
buffer.WriteString(token.String())
|
||||
} else if token.Data == ignore {
|
||||
ignore = ""
|
||||
}
|
||||
|
||||
case parser.EndTagToken:
|
||||
if len(ignore) == 0 && includes(allowedTags, token.Data) {
|
||||
token.Attr = []parser.Attribute{}
|
||||
buffer.WriteString(token.String())
|
||||
} else if token.Data == ignore {
|
||||
ignore = ""
|
||||
}
|
||||
|
||||
case parser.TextToken:
|
||||
// We allow text content through, unless ignoring this entire tag and its contents (including other tags)
|
||||
if ignore == "" {
|
||||
buffer.WriteString(token.String())
|
||||
}
|
||||
case parser.CommentToken:
|
||||
// We ignore comments by default
|
||||
case parser.DoctypeToken:
|
||||
// We ignore doctypes by default - html5 does not require them and this is intended for sanitizing snippets of text
|
||||
default:
|
||||
// We ignore unknown token types by default
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// HTML strips html tags, replace common entities, and escapes <>&;'" in the result.
|
||||
// Note the returned text may contain entities as it is escaped by HTMLEscapeString, and most entities are not translated.
|
||||
func HTML(s string) (output string) {
|
||||
|
||||
// Shortcut strings with no tags in them
|
||||
if !strings.ContainsAny(s, "<>") {
|
||||
output = s
|
||||
} else {
|
||||
|
||||
// First remove line breaks etc as these have no meaning outside html tags (except pre)
|
||||
// this means pre sections will lose formatting... but will result in less unintentional paras.
|
||||
s = strings.Replace(s, "\n", "", -1)
|
||||
|
||||
// Then replace line breaks with newlines, to preserve that formatting
|
||||
s = strings.Replace(s, "</p>", "\n", -1)
|
||||
s = strings.Replace(s, "<br>", "\n", -1)
|
||||
s = strings.Replace(s, "</br>", "\n", -1)
|
||||
s = strings.Replace(s, "<br/>", "\n", -1)
|
||||
s = strings.Replace(s, "<br />", "\n", -1)
|
||||
|
||||
// Walk through the string removing all tags
|
||||
b := bytes.NewBufferString("")
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '<':
|
||||
inTag = true
|
||||
case '>':
|
||||
inTag = false
|
||||
default:
|
||||
if !inTag {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
output = b.String()
|
||||
}
|
||||
|
||||
// Remove a few common harmless entities, to arrive at something more like plain text
|
||||
output = strings.Replace(output, "‘", "'", -1)
|
||||
output = strings.Replace(output, "’", "'", -1)
|
||||
output = strings.Replace(output, "“", "\"", -1)
|
||||
output = strings.Replace(output, "”", "\"", -1)
|
||||
output = strings.Replace(output, " ", " ", -1)
|
||||
output = strings.Replace(output, """, "\"", -1)
|
||||
output = strings.Replace(output, "'", "'", -1)
|
||||
|
||||
// Translate some entities into their plain text equivalent (for example accents, if encoded as entities)
|
||||
output = html.UnescapeString(output)
|
||||
|
||||
// In case we have missed any tags above, escape the text - removes <, >, &, ' and ".
|
||||
output = template.HTMLEscapeString(output)
|
||||
|
||||
// After processing, remove some harmless entities &, ' and " which are encoded by HTMLEscapeString
|
||||
output = strings.Replace(output, """, "\"", -1)
|
||||
output = strings.Replace(output, "'", "'", -1)
|
||||
output = strings.Replace(output, "& ", "& ", -1) // NB space after
|
||||
output = strings.Replace(output, "&amp; ", "& ", -1) // NB space after
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// We are very restrictive as this is intended for ascii url slugs
|
||||
var illegalPath = regexp.MustCompile(`[^[:alnum:]\~\-\./]`)
|
||||
|
||||
// Path makes a string safe to use as a URL path,
|
||||
// removing accents and replacing separators with -.
|
||||
// The path may still start at / and is not intended
|
||||
// for use as a file system path without prefix.
|
||||
func Path(s string) string {
|
||||
// Start with lowercase string
|
||||
filePath := strings.ToLower(s)
|
||||
filePath = strings.Replace(filePath, "..", "", -1)
|
||||
filePath = path.Clean(filePath)
|
||||
|
||||
// Remove illegal characters for paths, flattening accents
|
||||
// and replacing some common separators with -
|
||||
filePath = cleanString(filePath, illegalPath)
|
||||
|
||||
// NB this may be of length 0, caller must check
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Remove all other unrecognised characters apart from
|
||||
var illegalName = regexp.MustCompile(`[^[:alnum:]-.]`)
|
||||
|
||||
// Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters.
|
||||
func Name(s string) string {
|
||||
// Start with lowercase string
|
||||
fileName := s
|
||||
fileName = path.Clean(path.Base(fileName))
|
||||
|
||||
// Remove illegal characters for names, replacing some common separators with -
|
||||
fileName = cleanString(fileName, illegalName)
|
||||
|
||||
// NB this may be of length 0, caller must check
|
||||
return fileName
|
||||
}
|
||||
|
||||
// Replace these separators with -
|
||||
var baseNameSeparators = regexp.MustCompile(`[./]`)
|
||||
|
||||
// BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -.
|
||||
// No attempt is made to normalise a path or normalise case.
|
||||
func BaseName(s string) string {
|
||||
|
||||
// Replace certain joining characters with a dash
|
||||
baseName := baseNameSeparators.ReplaceAllString(s, "-")
|
||||
|
||||
// Remove illegal characters for names, replacing some common separators with -
|
||||
baseName = cleanString(baseName, illegalName)
|
||||
|
||||
// NB this may be of length 0, caller must check
|
||||
return baseName
|
||||
}
|
||||
|
||||
// A very limited list of transliterations to catch common european names translated to urls.
|
||||
// This set could be expanded with at least caps and many more characters.
|
||||
var transliterations = map[rune]string{
|
||||
'À': "A",
|
||||
'Á': "A",
|
||||
'Â': "A",
|
||||
'Ã': "A",
|
||||
'Ä': "A",
|
||||
'Å': "AA",
|
||||
'Æ': "AE",
|
||||
'Ç': "C",
|
||||
'È': "E",
|
||||
'É': "E",
|
||||
'Ê': "E",
|
||||
'Ë': "E",
|
||||
'Ì': "I",
|
||||
'Í': "I",
|
||||
'Î': "I",
|
||||
'Ï': "I",
|
||||
'Ð': "D",
|
||||
'Ł': "L",
|
||||
'Ñ': "N",
|
||||
'Ò': "O",
|
||||
'Ó': "O",
|
||||
'Ô': "O",
|
||||
'Õ': "O",
|
||||
'Ö': "OE",
|
||||
'Ø': "OE",
|
||||
'Œ': "OE",
|
||||
'Ù': "U",
|
||||
'Ú': "U",
|
||||
'Ü': "UE",
|
||||
'Û': "U",
|
||||
'Ý': "Y",
|
||||
'Þ': "TH",
|
||||
'ẞ': "SS",
|
||||
'à': "a",
|
||||
'á': "a",
|
||||
'â': "a",
|
||||
'ã': "a",
|
||||
'ä': "ae",
|
||||
'å': "aa",
|
||||
'æ': "ae",
|
||||
'ç': "c",
|
||||
'è': "e",
|
||||
'é': "e",
|
||||
'ê': "e",
|
||||
'ë': "e",
|
||||
'ì': "i",
|
||||
'í': "i",
|
||||
'î': "i",
|
||||
'ï': "i",
|
||||
'ð': "d",
|
||||
'ł': "l",
|
||||
'ñ': "n",
|
||||
'ń': "n",
|
||||
'ò': "o",
|
||||
'ó': "o",
|
||||
'ô': "o",
|
||||
'õ': "o",
|
||||
'ō': "o",
|
||||
'ö': "oe",
|
||||
'ø': "oe",
|
||||
'œ': "oe",
|
||||
'ś': "s",
|
||||
'ù': "u",
|
||||
'ú': "u",
|
||||
'û': "u",
|
||||
'ū': "u",
|
||||
'ü': "ue",
|
||||
'ý': "y",
|
||||
'ÿ': "y",
|
||||
'ż': "z",
|
||||
'þ': "th",
|
||||
'ß': "ss",
|
||||
}
|
||||
|
||||
// Accents replaces a set of accented characters with ascii equivalents.
|
||||
func Accents(s string) string {
|
||||
// Replace some common accent characters
|
||||
b := bytes.NewBufferString("")
|
||||
for _, c := range s {
|
||||
// Check transliterations first
|
||||
if val, ok := transliterations[c]; ok {
|
||||
b.WriteString(val)
|
||||
} else {
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var (
|
||||
// If the attribute contains data: or javascript: anywhere, ignore it
|
||||
// we don't allow this in attributes as it is so frequently used for xss
|
||||
// NB we allow spaces in the value, and lowercase.
|
||||
illegalAttr = regexp.MustCompile(`(d\s*a\s*t\s*a|j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*)\s*:`)
|
||||
|
||||
// We are far more restrictive with href attributes.
|
||||
legalHrefAttr = regexp.MustCompile(`\A[/#][^/\\]?|mailto:|http://|https://`)
|
||||
)
|
||||
|
||||
// cleanAttributes returns an array of attributes after removing malicious ones.
|
||||
func cleanAttributes(a []parser.Attribute, allowed []string) []parser.Attribute {
|
||||
if len(a) == 0 {
|
||||
return a
|
||||
}
|
||||
|
||||
var cleaned []parser.Attribute
|
||||
for _, attr := range a {
|
||||
if includes(allowed, attr.Key) {
|
||||
|
||||
val := strings.ToLower(attr.Val)
|
||||
|
||||
// Check for illegal attribute values
|
||||
if illegalAttr.FindString(val) != "" {
|
||||
attr.Val = ""
|
||||
}
|
||||
|
||||
// Check for legal href values - / mailto:// http:// or https://
|
||||
if attr.Key == "href" {
|
||||
if legalHrefAttr.FindString(val) == "" {
|
||||
attr.Val = ""
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have an attribute, append it to the array
|
||||
if attr.Val != "" {
|
||||
cleaned = append(cleaned, attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// A list of characters we consider separators in normal strings and replace with our canonical separator - rather than removing.
|
||||
var (
|
||||
separators = regexp.MustCompile(`[!&_="#|+?:]`)
|
||||
|
||||
dashes = regexp.MustCompile(`[\-]+`)
|
||||
)
|
||||
|
||||
// cleanString replaces separators with - and removes characters listed in the regexp provided from string.
|
||||
// Accents, spaces, and all characters not in A-Za-z0-9 are replaced.
|
||||
func cleanString(s string, r *regexp.Regexp) string {
|
||||
|
||||
// Remove any trailing space to avoid ending on -
|
||||
s = strings.Trim(s, " ")
|
||||
|
||||
// Flatten accents first so that if we remove non-ascii we still get a legible name
|
||||
s = Accents(s)
|
||||
|
||||
// Replace certain joining characters with a dash
|
||||
s = separators.ReplaceAllString(s, "-")
|
||||
|
||||
// Remove all other unrecognised characters - NB we do allow any printable characters
|
||||
//s = r.ReplaceAllString(s, "")
|
||||
|
||||
// Remove any multiple dashes caused by replacements above
|
||||
s = dashes.ReplaceAllString(s, "-")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// includes checks for inclusion of a string in a []string.
|
||||
func includes(a []string, s string) bool {
|
||||
for _, as := range a {
|
||||
if as == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
80
server/main.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/akhilrex/hammond/controllers"
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/service"
|
||||
"github.com/gin-contrib/location"
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jasonlvhit/gocron"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
db.DB, err = db.Init()
|
||||
if err != nil {
|
||||
fmt.Println("status: ", err)
|
||||
} else {
|
||||
db.Migrate()
|
||||
}
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(setupSettings())
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(location.Default())
|
||||
r.Use(static.Serve("/", static.LocalFile("./dist", true)))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
//fmt.'Println(c.Request.URL.Path)
|
||||
c.File("dist/index.html")
|
||||
})
|
||||
router := r.Group("/api")
|
||||
|
||||
dataPath := os.Getenv("DATA")
|
||||
|
||||
router.Static("/assets/", dataPath)
|
||||
|
||||
controllers.RegisterAnonController(router)
|
||||
controllers.RegisterAnonMasterConroller(router)
|
||||
controllers.RegisterSetupController(router)
|
||||
|
||||
router.Use(controllers.AuthMiddleware(true))
|
||||
controllers.RegisterUserController(router)
|
||||
controllers.RegisterMastersController(router)
|
||||
controllers.RegisterAuthController(router)
|
||||
controllers.RegisterVehicleController(router)
|
||||
controllers.RegisterFilesController(router)
|
||||
|
||||
go assetEnv()
|
||||
go intiCron()
|
||||
|
||||
r.Run(":3000") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
|
||||
|
||||
}
|
||||
func setupSettings() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
setting := db.GetOrCreateSetting()
|
||||
c.Set("setting", setting)
|
||||
c.Writer.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func intiCron() {
|
||||
|
||||
//gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingEpisodes)
|
||||
gocron.Every(2).Days().Do(service.CreateBackup)
|
||||
<-gocron.Start()
|
||||
}
|
||||
|
||||
func assetEnv() {
|
||||
log.Println("Config Dir: ", os.Getenv("CONFIG"))
|
||||
log.Println("Assets Dir: ", os.Getenv("DATA"))
|
||||
}
|
||||
30
server/models/auth.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "github.com/akhilrex/hammond/db"
|
||||
|
||||
type LoginResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `form:"email" json:"email" binding:"required,email"`
|
||||
Password string `form:"password" json:"password" binding:"required,min=6,max=255"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Email string `form:"email" json:"email" binding:"required,email"`
|
||||
Password string `form:"password" json:"password" binding:"required,min=8,max=255"`
|
||||
Currency string `json:"currency" form:"currency" query:"currency"`
|
||||
DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" `
|
||||
Role *db.Role `json:"role" form:"role" query:"role" `
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `form:"oldPassword" json:"oldPassword" binding:"required,min=8,max=255"`
|
||||
NewPassword string `form:"newPassword" json:"newPassword" binding:"required,min=8,max=255"`
|
||||
}
|
||||
849
server/models/currency.go
Normal file
@@ -0,0 +1,849 @@
|
||||
package models
|
||||
|
||||
type CurrencyModel struct {
|
||||
Symbol string `json:"symbol"`
|
||||
SymbolNative string `json:"symbolNative"`
|
||||
DecimalDigits string `json:"decimalDigits"`
|
||||
Rounding string `json:"rounding"`
|
||||
Code string `json:"code"`
|
||||
NamePlural string `json:"namePlural"`
|
||||
}
|
||||
|
||||
func GetCurrencyMasterList() []CurrencyModel {
|
||||
return []CurrencyModel{
|
||||
{
|
||||
Symbol: "$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "USD",
|
||||
NamePlural: "US dollars",
|
||||
}, {
|
||||
Symbol: "CA$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "CAD",
|
||||
NamePlural: "Canadian dollars",
|
||||
}, {
|
||||
Symbol: "€",
|
||||
SymbolNative: "€",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "EUR",
|
||||
NamePlural: "euros",
|
||||
}, {
|
||||
Symbol: "AED",
|
||||
SymbolNative: "د.إ.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "AED",
|
||||
NamePlural: "UAE dirhams",
|
||||
}, {
|
||||
Symbol: "Af",
|
||||
SymbolNative: "؋",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "AFN",
|
||||
NamePlural: "Afghan Afghanis",
|
||||
}, {
|
||||
Symbol: "ALL",
|
||||
SymbolNative: "Lek",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "ALL",
|
||||
NamePlural: "Albanian lekë",
|
||||
}, {
|
||||
Symbol: "AMD",
|
||||
SymbolNative: "դր.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "AMD",
|
||||
NamePlural: "Armenian drams",
|
||||
}, {
|
||||
Symbol: "AR$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "ARS",
|
||||
NamePlural: "Argentine pesos",
|
||||
}, {
|
||||
Symbol: "AU$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "AUD",
|
||||
NamePlural: "Australian dollars",
|
||||
}, {
|
||||
Symbol: "man.",
|
||||
SymbolNative: "ман.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "AZN",
|
||||
NamePlural: "Azerbaijani manats",
|
||||
}, {
|
||||
Symbol: "KM",
|
||||
SymbolNative: "KM",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BAM",
|
||||
NamePlural: "Bosnia-Herzegovina convertible marks",
|
||||
}, {
|
||||
Symbol: "Tk",
|
||||
SymbolNative: "৳",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BDT",
|
||||
NamePlural: "Bangladeshi takas",
|
||||
}, {
|
||||
Symbol: "BGN",
|
||||
SymbolNative: "лв.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BGN",
|
||||
NamePlural: "Bulgarian leva",
|
||||
}, {
|
||||
Symbol: "BD",
|
||||
SymbolNative: "د.ب.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "BHD",
|
||||
NamePlural: "Bahraini dinars",
|
||||
}, {
|
||||
Symbol: "FBu",
|
||||
SymbolNative: "FBu",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "BIF",
|
||||
NamePlural: "Burundian francs",
|
||||
}, {
|
||||
Symbol: "BN$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BND",
|
||||
NamePlural: "Brunei dollars",
|
||||
}, {
|
||||
Symbol: "Bs",
|
||||
SymbolNative: "Bs",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BOB",
|
||||
NamePlural: "Bolivian bolivianos",
|
||||
}, {
|
||||
Symbol: "R$",
|
||||
SymbolNative: "R$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BRL",
|
||||
NamePlural: "Brazilian reals",
|
||||
}, {
|
||||
Symbol: "BWP",
|
||||
SymbolNative: "P",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BWP",
|
||||
NamePlural: "Botswanan pulas",
|
||||
}, {
|
||||
Symbol: "Br",
|
||||
SymbolNative: "руб.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BYN",
|
||||
NamePlural: "Belarusian rubles",
|
||||
}, {
|
||||
Symbol: "BZ$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "BZD",
|
||||
NamePlural: "Belize dollars",
|
||||
}, {
|
||||
Symbol: "CDF",
|
||||
SymbolNative: "FrCD",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "CDF",
|
||||
NamePlural: "Congolese francs",
|
||||
}, {
|
||||
Symbol: "CHF",
|
||||
SymbolNative: "CHF",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0.05",
|
||||
Code: "CHF",
|
||||
NamePlural: "Swiss francs",
|
||||
}, {
|
||||
Symbol: "CL$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "CLP",
|
||||
NamePlural: "Chilean pesos",
|
||||
}, {
|
||||
Symbol: "CN¥",
|
||||
SymbolNative: "CN¥",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "CNY",
|
||||
NamePlural: "Chinese yuan",
|
||||
}, {
|
||||
Symbol: "CO$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "COP",
|
||||
NamePlural: "Colombian pesos",
|
||||
}, {
|
||||
Symbol: "₡",
|
||||
SymbolNative: "₡",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "CRC",
|
||||
NamePlural: "Costa Rican colóns",
|
||||
}, {
|
||||
Symbol: "CV$",
|
||||
SymbolNative: "CV$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "CVE",
|
||||
NamePlural: "Cape Verdean escudos",
|
||||
}, {
|
||||
Symbol: "Kč",
|
||||
SymbolNative: "Kč",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "CZK",
|
||||
NamePlural: "Czech Republic korunas",
|
||||
}, {
|
||||
Symbol: "Fdj",
|
||||
SymbolNative: "Fdj",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "DJF",
|
||||
NamePlural: "Djiboutian francs",
|
||||
}, {
|
||||
Symbol: "Dkr",
|
||||
SymbolNative: "kr",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "DKK",
|
||||
NamePlural: "Danish kroner",
|
||||
}, {
|
||||
Symbol: "RD$",
|
||||
SymbolNative: "RD$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "DOP",
|
||||
NamePlural: "Dominican pesos",
|
||||
}, {
|
||||
Symbol: "DA",
|
||||
SymbolNative: "د.ج.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "DZD",
|
||||
NamePlural: "Algerian dinars",
|
||||
}, {
|
||||
Symbol: "Ekr",
|
||||
SymbolNative: "kr",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "EEK",
|
||||
NamePlural: "Estonian kroons",
|
||||
}, {
|
||||
Symbol: "EGP",
|
||||
SymbolNative: "ج.م.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "EGP",
|
||||
NamePlural: "Egyptian pounds",
|
||||
}, {
|
||||
Symbol: "Nfk",
|
||||
SymbolNative: "Nfk",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "ERN",
|
||||
NamePlural: "Eritrean nakfas",
|
||||
}, {
|
||||
Symbol: "Br",
|
||||
SymbolNative: "Br",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "ETB",
|
||||
NamePlural: "Ethiopian birrs",
|
||||
}, {
|
||||
Symbol: "£",
|
||||
SymbolNative: "£",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "GBP",
|
||||
NamePlural: "British pounds sterling",
|
||||
}, {
|
||||
Symbol: "GEL",
|
||||
SymbolNative: "GEL",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "GEL",
|
||||
NamePlural: "Georgian laris",
|
||||
}, {
|
||||
Symbol: "GH₵",
|
||||
SymbolNative: "GH₵",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "GHS",
|
||||
NamePlural: "Ghanaian cedis",
|
||||
}, {
|
||||
Symbol: "FG",
|
||||
SymbolNative: "FG",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "GNF",
|
||||
NamePlural: "Guinean francs",
|
||||
}, {
|
||||
Symbol: "GTQ",
|
||||
SymbolNative: "Q",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "GTQ",
|
||||
NamePlural: "Guatemalan quetzals",
|
||||
}, {
|
||||
Symbol: "HK$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "HKD",
|
||||
NamePlural: "Hong Kong dollars",
|
||||
}, {
|
||||
Symbol: "HNL",
|
||||
SymbolNative: "L",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "HNL",
|
||||
NamePlural: "Honduran lempiras",
|
||||
}, {
|
||||
Symbol: "kn",
|
||||
SymbolNative: "kn",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "HRK",
|
||||
NamePlural: "Croatian kunas",
|
||||
}, {
|
||||
Symbol: "Ft",
|
||||
SymbolNative: "Ft",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "HUF",
|
||||
NamePlural: "Hungarian forints",
|
||||
}, {
|
||||
Symbol: "Rp",
|
||||
SymbolNative: "Rp",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "IDR",
|
||||
NamePlural: "Indonesian rupiahs",
|
||||
}, {
|
||||
Symbol: "₪",
|
||||
SymbolNative: "₪",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "ILS",
|
||||
NamePlural: "Israeli new sheqels",
|
||||
}, {
|
||||
Symbol: "Rs",
|
||||
SymbolNative: "টকা",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "INR",
|
||||
NamePlural: "Indian rupees",
|
||||
}, {
|
||||
Symbol: "IQD",
|
||||
SymbolNative: "د.ع.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "IQD",
|
||||
NamePlural: "Iraqi dinars",
|
||||
}, {
|
||||
Symbol: "IRR",
|
||||
SymbolNative: "﷼",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "IRR",
|
||||
NamePlural: "Iranian rials",
|
||||
}, {
|
||||
Symbol: "Ikr",
|
||||
SymbolNative: "kr",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "ISK",
|
||||
NamePlural: "Icelandic krónur",
|
||||
}, {
|
||||
Symbol: "J$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "JMD",
|
||||
NamePlural: "Jamaican dollars",
|
||||
}, {
|
||||
Symbol: "JD",
|
||||
SymbolNative: "د.أ.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "JOD",
|
||||
NamePlural: "Jordanian dinars",
|
||||
}, {
|
||||
Symbol: "¥",
|
||||
SymbolNative: "¥",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "JPY",
|
||||
NamePlural: "Japanese yen",
|
||||
}, {
|
||||
Symbol: "Ksh",
|
||||
SymbolNative: "Ksh",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "KES",
|
||||
NamePlural: "Kenyan shillings",
|
||||
}, {
|
||||
Symbol: "KHR",
|
||||
SymbolNative: "៛",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "KHR",
|
||||
NamePlural: "Cambodian riels",
|
||||
}, {
|
||||
Symbol: "CF",
|
||||
SymbolNative: "FC",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "KMF",
|
||||
NamePlural: "Comorian francs",
|
||||
}, {
|
||||
Symbol: "₩",
|
||||
SymbolNative: "₩",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "KRW",
|
||||
NamePlural: "South Korean won",
|
||||
}, {
|
||||
Symbol: "KD",
|
||||
SymbolNative: "د.ك.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "KWD",
|
||||
NamePlural: "Kuwaiti dinars",
|
||||
}, {
|
||||
Symbol: "KZT",
|
||||
SymbolNative: "тңг.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "KZT",
|
||||
NamePlural: "Kazakhstani tenges",
|
||||
}, {
|
||||
Symbol: "LB£",
|
||||
SymbolNative: "ل.ل.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "LBP",
|
||||
NamePlural: "Lebanese pounds",
|
||||
}, {
|
||||
Symbol: "SLRs",
|
||||
SymbolNative: "SL Re",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "LKR",
|
||||
NamePlural: "Sri Lankan rupees",
|
||||
}, {
|
||||
Symbol: "Lt",
|
||||
SymbolNative: "Lt",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "LTL",
|
||||
NamePlural: "Lithuanian litai",
|
||||
}, {
|
||||
Symbol: "Ls",
|
||||
SymbolNative: "Ls",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "LVL",
|
||||
NamePlural: "Latvian lati",
|
||||
}, {
|
||||
Symbol: "LD",
|
||||
SymbolNative: "د.ل.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "LYD",
|
||||
NamePlural: "Libyan dinars",
|
||||
}, {
|
||||
Symbol: "MAD",
|
||||
SymbolNative: "د.م.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MAD",
|
||||
NamePlural: "Moroccan dirhams",
|
||||
}, {
|
||||
Symbol: "MDL",
|
||||
SymbolNative: "MDL",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MDL",
|
||||
NamePlural: "Moldovan lei",
|
||||
}, {
|
||||
Symbol: "MGA",
|
||||
SymbolNative: "MGA",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "MGA",
|
||||
NamePlural: "Malagasy Ariaries",
|
||||
}, {
|
||||
Symbol: "MKD",
|
||||
SymbolNative: "MKD",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MKD",
|
||||
NamePlural: "Macedonian denari",
|
||||
}, {
|
||||
Symbol: "MMK",
|
||||
SymbolNative: "K",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "MMK",
|
||||
NamePlural: "Myanma kyats",
|
||||
}, {
|
||||
Symbol: "MOP$",
|
||||
SymbolNative: "MOP$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MOP",
|
||||
NamePlural: "Macanese patacas",
|
||||
}, {
|
||||
Symbol: "MURs",
|
||||
SymbolNative: "MURs",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "MUR",
|
||||
NamePlural: "Mauritian rupees",
|
||||
}, {
|
||||
Symbol: "MX$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MXN",
|
||||
NamePlural: "Mexican pesos",
|
||||
}, {
|
||||
Symbol: "RM",
|
||||
SymbolNative: "RM",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MYR",
|
||||
NamePlural: "Malaysian ringgits",
|
||||
}, {
|
||||
Symbol: "MTn",
|
||||
SymbolNative: "MTn",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "MZN",
|
||||
NamePlural: "Mozambican meticals",
|
||||
}, {
|
||||
Symbol: "N$",
|
||||
SymbolNative: "N$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NAD",
|
||||
NamePlural: "Namibian dollars",
|
||||
}, {
|
||||
Symbol: "₦",
|
||||
SymbolNative: "₦",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NGN",
|
||||
NamePlural: "Nigerian nairas",
|
||||
}, {
|
||||
Symbol: "C$",
|
||||
SymbolNative: "C$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NIO",
|
||||
NamePlural: "Nicaraguan córdobas",
|
||||
}, {
|
||||
Symbol: "Nkr",
|
||||
SymbolNative: "kr",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NOK",
|
||||
NamePlural: "Norwegian kroner",
|
||||
}, {
|
||||
Symbol: "NPRs",
|
||||
SymbolNative: "नेरू",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NPR",
|
||||
NamePlural: "Nepalese rupees",
|
||||
}, {
|
||||
Symbol: "NZ$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "NZD",
|
||||
NamePlural: "New Zealand dollars",
|
||||
}, {
|
||||
Symbol: "OMR",
|
||||
SymbolNative: "ر.ع.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "OMR",
|
||||
NamePlural: "Omani rials",
|
||||
}, {
|
||||
Symbol: "B/.",
|
||||
SymbolNative: "B/.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "PAB",
|
||||
NamePlural: "Panamanian balboas",
|
||||
}, {
|
||||
Symbol: "S/.",
|
||||
SymbolNative: "S/.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "PEN",
|
||||
NamePlural: "Peruvian nuevos soles",
|
||||
}, {
|
||||
Symbol: "₱",
|
||||
SymbolNative: "₱",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "PHP",
|
||||
NamePlural: "Philippine pesos",
|
||||
}, {
|
||||
Symbol: "PKRs",
|
||||
SymbolNative: "₨",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "PKR",
|
||||
NamePlural: "Pakistani rupees",
|
||||
}, {
|
||||
Symbol: "zł",
|
||||
SymbolNative: "zł",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "PLN",
|
||||
NamePlural: "Polish zlotys",
|
||||
}, {
|
||||
Symbol: "₲",
|
||||
SymbolNative: "₲",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "PYG",
|
||||
NamePlural: "Paraguayan guaranis",
|
||||
}, {
|
||||
Symbol: "QR",
|
||||
SymbolNative: "ر.ق.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "QAR",
|
||||
NamePlural: "Qatari rials",
|
||||
}, {
|
||||
Symbol: "RON",
|
||||
SymbolNative: "RON",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "RON",
|
||||
NamePlural: "Romanian lei",
|
||||
}, {
|
||||
Symbol: "din.",
|
||||
SymbolNative: "дин.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "RSD",
|
||||
NamePlural: "Serbian dinars",
|
||||
}, {
|
||||
Symbol: "RUB",
|
||||
SymbolNative: "₽.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "RUB",
|
||||
NamePlural: "Russian rubles",
|
||||
}, {
|
||||
Symbol: "RWF",
|
||||
SymbolNative: "FR",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "RWF",
|
||||
NamePlural: "Rwandan francs",
|
||||
}, {
|
||||
Symbol: "SR",
|
||||
SymbolNative: "ر.س.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "SAR",
|
||||
NamePlural: "Saudi riyals",
|
||||
}, {
|
||||
Symbol: "SDG",
|
||||
SymbolNative: "SDG",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "SDG",
|
||||
NamePlural: "Sudanese pounds",
|
||||
}, {
|
||||
Symbol: "Skr",
|
||||
SymbolNative: "kr",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "SEK",
|
||||
NamePlural: "Swedish kronor",
|
||||
}, {
|
||||
Symbol: "S$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "SGD",
|
||||
NamePlural: "Singapore dollars",
|
||||
}, {
|
||||
Symbol: "Ssh",
|
||||
SymbolNative: "Ssh",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "SOS",
|
||||
NamePlural: "Somali shillings",
|
||||
}, {
|
||||
Symbol: "SY£",
|
||||
SymbolNative: "ل.س.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "SYP",
|
||||
NamePlural: "Syrian pounds",
|
||||
}, {
|
||||
Symbol: "฿",
|
||||
SymbolNative: "฿",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "THB",
|
||||
NamePlural: "Thai baht",
|
||||
}, {
|
||||
Symbol: "DT",
|
||||
SymbolNative: "د.ت.",
|
||||
DecimalDigits: "3",
|
||||
Rounding: "0",
|
||||
Code: "TND",
|
||||
NamePlural: "Tunisian dinars",
|
||||
}, {
|
||||
Symbol: "T$",
|
||||
SymbolNative: "T$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "TOP",
|
||||
NamePlural: "Tongan paʻanga",
|
||||
}, {
|
||||
Symbol: "TL",
|
||||
SymbolNative: "TL",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "TRY",
|
||||
NamePlural: "Turkish Lira",
|
||||
}, {
|
||||
Symbol: "TT$",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "TTD",
|
||||
NamePlural: "Trinidad and Tobago dollars",
|
||||
}, {
|
||||
Symbol: "NT$",
|
||||
SymbolNative: "NT$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "TWD",
|
||||
NamePlural: "New Taiwan dollars",
|
||||
}, {
|
||||
Symbol: "TSh",
|
||||
SymbolNative: "TSh",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "TZS",
|
||||
NamePlural: "Tanzanian shillings",
|
||||
}, {
|
||||
Symbol: "₴",
|
||||
SymbolNative: "₴",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "UAH",
|
||||
NamePlural: "Ukrainian hryvnias",
|
||||
}, {
|
||||
Symbol: "USh",
|
||||
SymbolNative: "USh",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "UGX",
|
||||
NamePlural: "Ugandan shillings",
|
||||
}, {
|
||||
Symbol: "$U",
|
||||
SymbolNative: "$",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "UYU",
|
||||
NamePlural: "Uruguayan pesos",
|
||||
}, {
|
||||
Symbol: "UZS",
|
||||
SymbolNative: "UZS",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "UZS",
|
||||
NamePlural: "Uzbekistan som",
|
||||
}, {
|
||||
Symbol: "Bs.F.",
|
||||
SymbolNative: "Bs.F.",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "VEF",
|
||||
NamePlural: "Venezuelan bolívars",
|
||||
}, {
|
||||
Symbol: "₫",
|
||||
SymbolNative: "₫",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "VND",
|
||||
NamePlural: "Vietnamese dong",
|
||||
}, {
|
||||
Symbol: "FCFA",
|
||||
SymbolNative: "FCFA",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "XAF",
|
||||
NamePlural: "CFA francs BEAC",
|
||||
}, {
|
||||
Symbol: "CFA",
|
||||
SymbolNative: "CFA",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "XOF",
|
||||
NamePlural: "CFA francs BCEAO",
|
||||
}, {
|
||||
Symbol: "YR",
|
||||
SymbolNative: "ر.ي.",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "YER",
|
||||
NamePlural: "Yemeni rials",
|
||||
}, {
|
||||
Symbol: "R",
|
||||
SymbolNative: "R",
|
||||
DecimalDigits: "2",
|
||||
Rounding: "0",
|
||||
Code: "ZAR",
|
||||
NamePlural: "South African rand",
|
||||
}, {
|
||||
Symbol: "ZK",
|
||||
SymbolNative: "ZK",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "ZMK",
|
||||
NamePlural: "Zambian kwachas",
|
||||
}, {
|
||||
Symbol: "ZWL$",
|
||||
SymbolNative: "ZWL$",
|
||||
DecimalDigits: "0",
|
||||
Rounding: "0",
|
||||
Code: "ZWL",
|
||||
NamePlural: "Zimbabwean Dollar",
|
||||
},
|
||||
}
|
||||
}
|
||||
11
server/models/errors.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
type VehicleAlreadyExistsError struct {
|
||||
Registration string
|
||||
}
|
||||
|
||||
func (e *VehicleAlreadyExistsError) Error() string {
|
||||
return fmt.Sprintf("Vehicle with this url already exists")
|
||||
}
|
||||
5
server/models/files.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package models
|
||||
|
||||
type CreateQuickEntryModel struct {
|
||||
Comments string `json:"comments" form:"comments"`
|
||||
}
|
||||
12
server/models/misc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "github.com/akhilrex/hammond/db"
|
||||
|
||||
type UpdateSettingModel struct {
|
||||
Currency string `json:"currency" form:"currency" query:"currency"`
|
||||
DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" `
|
||||
}
|
||||
|
||||
type ClarksonMigrationModel struct {
|
||||
Url string `json:"url" form:"url" query:"url"`
|
||||
}
|
||||
150
server/models/vehicle.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/akhilrex/hammond/db"
|
||||
_ "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type SearchByIdQuery struct {
|
||||
Id string `binding:"required" uri:"id" json:"id" form:"id"`
|
||||
}
|
||||
type SubItemQuery struct {
|
||||
Id string `binding:"required" uri:"id" json:"id" form:"id"`
|
||||
SubId string `binding:"required" uri:"subId" json:"subId" form:"subId"`
|
||||
}
|
||||
type CreateVehicleRequest struct {
|
||||
Nickname string `form:"nickname" json:"nickname" binding:"required"`
|
||||
Registration string `form:"registration" json:"registration" binding:"required"`
|
||||
Make string `form:"make" json:"make" binding:"required"`
|
||||
Model string `form:"model" json:"model" binding:"required"`
|
||||
YearOfManufacture int `form:"yearOfManufacture" json:"yearOfManufacture"`
|
||||
EngineSize float32 `form:"engineSize" json:"engineSize"`
|
||||
FuelUnit *db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"`
|
||||
|
||||
FuelType *db.FuelType `form:"fuelType" json:"fuelType" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateVehicleRequest struct {
|
||||
CreateVehicleRequest
|
||||
}
|
||||
type UserVehicleSimpleModel struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
VehicleID string `json:"vehicleId"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CreateFillupRequest struct {
|
||||
VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"`
|
||||
FuelUnit *db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"`
|
||||
FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"`
|
||||
PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"`
|
||||
TotalAmount float32 `form:"totalAmount" json:"totalAmount" binding:"required"`
|
||||
OdoReading int `form:"odoReading" json:"odoReading" binding:"required"`
|
||||
IsTankFull *bool `form:"isTankFull" json:"isTankFull" binding:"required"`
|
||||
HasMissedFillup *bool `form:"hasMissedFillup" json:"HasMissedFillup"`
|
||||
Comments string `form:"comments" json:"comments" `
|
||||
FillingStation string `form:"fillingStation" json:"fillingStation"`
|
||||
UserID string `form:"userId" json:"userId" binding:"required"`
|
||||
Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"`
|
||||
}
|
||||
|
||||
type UpdateFillupRequest struct {
|
||||
CreateFillupRequest
|
||||
}
|
||||
|
||||
type UpdateExpenseRequest struct {
|
||||
CreateExpenseRequest
|
||||
}
|
||||
|
||||
type CreateExpenseRequest struct {
|
||||
VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"`
|
||||
|
||||
Amount float32 `form:"amount" json:"amount" binding:"required"`
|
||||
OdoReading int `form:"odoReading" json:"odoReading"`
|
||||
|
||||
Comments string `form:"comments" json:"comments" `
|
||||
ExpenseType string `form:"expenseType" json:"expenseType"`
|
||||
UserID string `form:"userId" json:"userId" binding:"required"`
|
||||
Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"`
|
||||
}
|
||||
|
||||
type CreateVehicleAttachmentModel struct {
|
||||
Title string `form:"title" json:"title" binding:"required"`
|
||||
}
|
||||
|
||||
type VehicleStatsModel struct {
|
||||
CountFillups int `json:"countFillups"`
|
||||
CountExpenses int `json:"countExpenses"`
|
||||
ExpenditureFillups float32 `json:"expenditureFillups"`
|
||||
ExpenditureExpenses float32 `json:"expenditureExpenses"`
|
||||
ExpenditureTotal float32 `json:"expenditureTotal"`
|
||||
AvgFillupCost float32 `json:"avgFillupCost"`
|
||||
AvgExpenseCost float32 `json:"avgExpenseCost"`
|
||||
AvgFuelQty float32 `json:"avgFuelQty"`
|
||||
AvgFuelPrice float32 `json:"avgFuelPrice"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
func (m *VehicleStatsModel) SetStats(fillups *[]db.Fillup, expenses *[]db.Expense) []VehicleStatsModel {
|
||||
|
||||
currencyMap := make(map[string]int)
|
||||
for _, v := range *fillups {
|
||||
currencyMap[v.Currency] = 1
|
||||
}
|
||||
for _, v := range *expenses {
|
||||
currencyMap[v.Currency] = 1
|
||||
}
|
||||
var toReturn []VehicleStatsModel
|
||||
for currency, _ := range currencyMap {
|
||||
model := VehicleStatsModel{}
|
||||
var totalExpenditure, fillupTotal, expenseTotal, totalFuel, averageFuelCost, averageFuelQty, averageFillup, averageExpense float32
|
||||
var countFillup, countExpense int
|
||||
for _, v := range *fillups {
|
||||
if v.Currency == currency {
|
||||
fillupTotal = fillupTotal + v.TotalAmount
|
||||
totalFuel = totalFuel + v.FuelQuantity
|
||||
countFillup++
|
||||
}
|
||||
}
|
||||
for _, v := range *expenses {
|
||||
if v.Currency == currency {
|
||||
expenseTotal = expenseTotal + v.Amount
|
||||
countExpense++
|
||||
}
|
||||
}
|
||||
|
||||
totalExpenditure = expenseTotal + fillupTotal
|
||||
|
||||
if countFillup > 0 {
|
||||
averageFillup = fillupTotal / float32(countFillup)
|
||||
averageFuelCost = fillupTotal / totalFuel
|
||||
averageFuelQty = totalFuel / float32(countFillup)
|
||||
}
|
||||
if countExpense > 0 {
|
||||
averageExpense = expenseTotal / float32(countExpense)
|
||||
}
|
||||
|
||||
model.CountFillups = countFillup
|
||||
model.CountExpenses = countExpense
|
||||
model.ExpenditureFillups = fillupTotal
|
||||
model.ExpenditureExpenses = expenseTotal
|
||||
model.ExpenditureTotal = totalExpenditure
|
||||
model.AvgFillupCost = averageFillup
|
||||
model.AvgExpenseCost = averageExpense
|
||||
model.AvgFuelPrice = averageFuelCost
|
||||
model.AvgFuelQty = averageFuelQty
|
||||
model.Currency = currency
|
||||
|
||||
toReturn = append(toReturn, model)
|
||||
}
|
||||
return toReturn
|
||||
}
|
||||
|
||||
type UserStatsQueryModel struct {
|
||||
Start time.Time `json:"start" query:"start" form:"start"`
|
||||
End time.Time `json:"end" query:"end" form:"end"`
|
||||
}
|
||||
236
server/service/fileService.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/internal/sanitize"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
func CreateAttachment(path, originalName string, size int64, contentType, userId string) (*db.Attachment, error) {
|
||||
model := &db.Attachment{
|
||||
Path: path,
|
||||
OriginalName: originalName,
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
UserID: userId,
|
||||
}
|
||||
tx := db.DB.Create(&model)
|
||||
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func CreateQuickEntry(model models.CreateQuickEntryModel, attachmentId, userId string) (*db.QuickEntry, error) {
|
||||
toCreate := &db.QuickEntry{
|
||||
AttachmentID: attachmentId,
|
||||
UserID: userId,
|
||||
Comments: model.Comments,
|
||||
}
|
||||
tx := db.DB.Create(&toCreate)
|
||||
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return toCreate, nil
|
||||
}
|
||||
func GetAllQuickEntries(sorting string) (*[]db.QuickEntry, error) {
|
||||
return db.GetAllQuickEntries(sorting)
|
||||
}
|
||||
func GetQuickEntriesForUser(userId, sorting string) (*[]db.QuickEntry, error) {
|
||||
return db.GetQuickEntriesForUser(userId, sorting)
|
||||
}
|
||||
func GetQuickEntryById(id string) (*db.QuickEntry, error) {
|
||||
return db.GetQuickEntryById(id)
|
||||
}
|
||||
func SetQuickEntryAsProcessed(id string) error {
|
||||
return db.SetQuickEntryAsProcessed(id, time.Now())
|
||||
|
||||
}
|
||||
|
||||
func GetAttachmentById(id string) (*db.Attachment, error) {
|
||||
return db.GetAttachmentById(id)
|
||||
}
|
||||
|
||||
func GetAllBackupFiles() ([]string, error) {
|
||||
var files []string
|
||||
folder := createConfigFolderIfNotExists("backups")
|
||||
err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error {
|
||||
if !info.IsDir() {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
return files, err
|
||||
}
|
||||
|
||||
func GetFileSize(path string) (int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func deleteOldBackup() {
|
||||
files, err := GetAllBackupFiles()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(files) <= 5 {
|
||||
return
|
||||
}
|
||||
|
||||
toDelete := files[5:]
|
||||
for _, file := range toDelete {
|
||||
fmt.Println(file)
|
||||
DeleteFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
func GetFilePath(originalName string) string {
|
||||
dataPath := os.Getenv("DATA")
|
||||
return path.Join(dataPath, getFileName(originalName))
|
||||
}
|
||||
func getFileName(orig string) string {
|
||||
|
||||
ext := filepath.Ext(orig)
|
||||
return uuid.NewV4().String() + ext
|
||||
|
||||
}
|
||||
|
||||
func CreateBackup() (string, error) {
|
||||
|
||||
backupFileName := "hammond_backup_" + time.Now().Format("2006.01.02_150405") + ".tar.gz"
|
||||
folder := createConfigFolderIfNotExists("backups")
|
||||
configPath := os.Getenv("CONFIG")
|
||||
tarballFilePath := path.Join(folder, backupFileName)
|
||||
file, err := os.Create(tarballFilePath)
|
||||
if err != nil {
|
||||
return "", errors.New(fmt.Sprintf("Could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error()))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dbPath := path.Join(configPath, "hammond.db")
|
||||
_, err = os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return "", errors.New(fmt.Sprintf("Could not find db file '%s', got error '%s'", dbPath, err.Error()))
|
||||
}
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
err = addFileToTarWriter(dbPath, tarWriter)
|
||||
if err == nil {
|
||||
deleteOldBackup()
|
||||
}
|
||||
return backupFileName, err
|
||||
}
|
||||
|
||||
func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error()))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()))
|
||||
}
|
||||
|
||||
header := &tar.Header{
|
||||
Name: filePath,
|
||||
Size: stat.Size(),
|
||||
Mode: int64(stat.Mode()),
|
||||
ModTime: stat.ModTime(),
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", filePath, err.Error()))
|
||||
}
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func httpClient() *http.Client {
|
||||
client := http.Client{
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
// r.URL.Opaque = r.URL.Path
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return &client
|
||||
}
|
||||
|
||||
func createFolder(folder string, parent string) string {
|
||||
folder = cleanFileName(folder)
|
||||
//str := stringy.New(folder)
|
||||
folderPath := path.Join(parent, folder)
|
||||
if _, err := os.Stat(folderPath); os.IsNotExist(err) {
|
||||
os.MkdirAll(folderPath, 0777)
|
||||
changeOwnership(folderPath)
|
||||
}
|
||||
return folderPath
|
||||
}
|
||||
|
||||
func createDataFolderIfNotExists(folder string) string {
|
||||
dataPath := os.Getenv("DATA")
|
||||
return createFolder(folder, dataPath)
|
||||
}
|
||||
func createConfigFolderIfNotExists(folder string) string {
|
||||
dataPath := os.Getenv("CONFIG")
|
||||
return createFolder(folder, dataPath)
|
||||
}
|
||||
|
||||
func cleanFileName(original string) string {
|
||||
return sanitize.Name(original)
|
||||
}
|
||||
func changeOwnership(path string) {
|
||||
uid, err1 := strconv.Atoi(os.Getenv("PUID"))
|
||||
gid, err2 := strconv.Atoi(os.Getenv("PGID"))
|
||||
fmt.Println(path)
|
||||
if err1 == nil && err2 == nil {
|
||||
fmt.Println(path + " : Attempting change")
|
||||
os.Chown(path, uid, gid)
|
||||
}
|
||||
|
||||
}
|
||||
func DeleteFile(filePath string) error {
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func checkError(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
30
server/service/miscService.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/akhilrex/hammond/db"
|
||||
)
|
||||
|
||||
func CanInitializeSystem() (bool, error) {
|
||||
return db.CanInitializeSystem()
|
||||
}
|
||||
|
||||
func UpdateSettings(currency string, distanceUnit db.DistanceUnit) error {
|
||||
setting := db.GetOrCreateSetting()
|
||||
setting.Currency = currency
|
||||
setting.DistanceUnit = distanceUnit
|
||||
return db.UpdateSettings(setting)
|
||||
}
|
||||
func UpdateUserSettings(userId, currency string, distanceUnit db.DistanceUnit) error {
|
||||
user, err := db.GetUserById(userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.Currency = currency
|
||||
user.DistanceUnit = distanceUnit
|
||||
return db.UpdateUser(user)
|
||||
}
|
||||
|
||||
func GetSettings() *db.Setting {
|
||||
return db.GetOrCreateSetting()
|
||||
}
|
||||
47
server/service/userService.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
)
|
||||
|
||||
func CreateUser(userModel *models.RegisterRequest, role db.Role) error {
|
||||
setting := db.GetOrCreateSetting()
|
||||
toCreate := db.User{
|
||||
Email: userModel.Email,
|
||||
Name: userModel.Name,
|
||||
Role: role,
|
||||
Currency: setting.Currency,
|
||||
DistanceUnit: setting.DistanceUnit,
|
||||
}
|
||||
|
||||
toCreate.SetPassword(userModel.Password)
|
||||
|
||||
return db.CreateUser(&toCreate)
|
||||
|
||||
}
|
||||
|
||||
func GetUserById(id string) (*db.User, error) {
|
||||
var myUserModel db.User
|
||||
tx := db.DB.Debug().Preload("Vehicles").First(&myUserModel, map[string]string{
|
||||
"ID": id,
|
||||
})
|
||||
return &myUserModel, tx.Error
|
||||
}
|
||||
|
||||
func GetAllUsers() (*[]db.User, error) {
|
||||
return db.GetAllUsers()
|
||||
}
|
||||
|
||||
func UpdatePassword(id, password string) (bool, error) {
|
||||
user, err := GetUserById(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
user.SetPassword(password)
|
||||
err = db.UpdateUser(user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
234
server/service/vehicleService.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/akhilrex/hammond/db"
|
||||
"github.com/akhilrex/hammond/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func CreateVehicle(model models.CreateVehicleRequest, userId string) (*db.Vehicle, error) {
|
||||
vehicle := db.Vehicle{
|
||||
Nickname: model.Nickname,
|
||||
Registration: model.Registration,
|
||||
Model: model.Model,
|
||||
Make: model.Make,
|
||||
YearOfManufacture: model.YearOfManufacture,
|
||||
EngineSize: model.EngineSize,
|
||||
FuelUnit: *model.FuelUnit,
|
||||
FuelType: *model.FuelType,
|
||||
}
|
||||
|
||||
tx := db.DB.Create(&vehicle)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
association := db.UserVehicle{
|
||||
UserID: userId,
|
||||
VehicleID: vehicle.ID,
|
||||
IsOwner: true,
|
||||
}
|
||||
tx = db.DB.Create(&association)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
return &vehicle, nil
|
||||
|
||||
}
|
||||
|
||||
func GetVehicleOwner(vehicleId string) (string, error) {
|
||||
return db.GetVehicleOwner(vehicleId)
|
||||
}
|
||||
|
||||
func GetVehicleUsers(vehicleId string) (*[]db.UserVehicle, error) {
|
||||
return db.GetVehicleUsers(vehicleId)
|
||||
}
|
||||
func ShareVehicle(vehicleId, userId string) error {
|
||||
return db.ShareVehicle(vehicleId, userId)
|
||||
}
|
||||
func UnshareVehicle(vehicleId, userId string) error {
|
||||
return db.UnshareVehicle(vehicleId, userId)
|
||||
}
|
||||
|
||||
func GetVehicleById(vehicleID string) (*db.Vehicle, error) {
|
||||
return db.GetVehicleById(vehicleID)
|
||||
}
|
||||
func GetFillupsByVehicleId(vehicleId string) (*[]db.Fillup, error) {
|
||||
return db.GetFillupsByVehicleId(vehicleId)
|
||||
}
|
||||
func GetExpensesByVehicleId(vehicleId string) (*[]db.Expense, error) {
|
||||
return db.GetExpensesByVehicleId(vehicleId)
|
||||
}
|
||||
func GetFillupById(fillupId string) (*db.Fillup, error) {
|
||||
return db.GetFillupById(fillupId)
|
||||
}
|
||||
func GetExpenseById(expenseId string) (*db.Expense, error) {
|
||||
return db.GetExpenseById(expenseId)
|
||||
}
|
||||
func UpdateVehicle(vehicleID string, model models.UpdateVehicleRequest) error {
|
||||
toUpdate, err := GetVehicleById(vehicleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//return db.DB.Model(&toUpdate).Updates(db.Vehicle{
|
||||
toUpdate.Nickname = model.Nickname
|
||||
toUpdate.Registration = model.Registration
|
||||
toUpdate.Model = model.Model
|
||||
toUpdate.Make = model.Make
|
||||
toUpdate.YearOfManufacture = model.YearOfManufacture
|
||||
toUpdate.EngineSize = model.EngineSize
|
||||
toUpdate.FuelUnit = *model.FuelUnit
|
||||
toUpdate.FuelType = *model.FuelType
|
||||
//}).Error
|
||||
|
||||
return db.DB.Omit(clause.Associations).Save(toUpdate).Error
|
||||
}
|
||||
func GetAllVehicles() (*[]db.Vehicle, error) {
|
||||
return db.GetAllVehicles("")
|
||||
}
|
||||
|
||||
func GetUserVehicles(id string) (*[]db.Vehicle, error) {
|
||||
return db.GetUserVehicles(id)
|
||||
}
|
||||
|
||||
func CreateFillup(model models.CreateFillupRequest) (*db.Fillup, error) {
|
||||
|
||||
user, err := db.GetUserById(model.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fillup := db.Fillup{
|
||||
VehicleID: model.VehicleID,
|
||||
FuelUnit: *model.FuelUnit,
|
||||
FuelQuantity: model.FuelQuantity,
|
||||
PerUnitPrice: model.PerUnitPrice,
|
||||
TotalAmount: model.TotalAmount,
|
||||
OdoReading: model.OdoReading,
|
||||
IsTankFull: model.IsTankFull,
|
||||
HasMissedFillup: model.HasMissedFillup,
|
||||
Comments: model.Comments,
|
||||
FillingStation: model.FillingStation,
|
||||
UserID: model.UserID,
|
||||
Date: model.Date,
|
||||
Currency: user.Currency,
|
||||
DistanceUnit: user.DistanceUnit,
|
||||
}
|
||||
|
||||
tx := db.DB.Create(&fillup)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return &fillup, nil
|
||||
|
||||
}
|
||||
|
||||
func CreateExpense(model models.CreateExpenseRequest) (*db.Expense, error) {
|
||||
user, err := db.GetUserById(model.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expense := db.Expense{
|
||||
VehicleID: model.VehicleID,
|
||||
Amount: model.Amount,
|
||||
OdoReading: model.OdoReading,
|
||||
ExpenseType: model.ExpenseType,
|
||||
Comments: model.Comments,
|
||||
UserID: model.UserID,
|
||||
Date: model.Date,
|
||||
Currency: user.Currency,
|
||||
DistanceUnit: user.DistanceUnit,
|
||||
}
|
||||
|
||||
tx := db.DB.Create(&expense)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return &expense, nil
|
||||
|
||||
}
|
||||
|
||||
func UpdateFillup(fillupId string, model models.UpdateFillupRequest) error {
|
||||
toUpdate, err := GetFillupById(fillupId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.DB.Model(&toUpdate).Updates(db.Fillup{
|
||||
VehicleID: model.VehicleID,
|
||||
FuelUnit: *model.FuelUnit,
|
||||
FuelQuantity: model.FuelQuantity,
|
||||
PerUnitPrice: model.PerUnitPrice,
|
||||
TotalAmount: model.TotalAmount,
|
||||
OdoReading: model.OdoReading,
|
||||
IsTankFull: model.IsTankFull,
|
||||
HasMissedFillup: model.HasMissedFillup,
|
||||
Comments: model.Comments,
|
||||
FillingStation: model.FillingStation,
|
||||
UserID: model.UserID,
|
||||
Date: model.Date,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func UpdateExpense(fillupId string, model models.UpdateExpenseRequest) error {
|
||||
toUpdate, err := GetExpenseById(fillupId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.DB.Model(&toUpdate).Updates(db.Expense{
|
||||
VehicleID: model.VehicleID,
|
||||
Amount: model.Amount,
|
||||
OdoReading: model.OdoReading,
|
||||
ExpenseType: model.ExpenseType,
|
||||
Comments: model.Comments,
|
||||
UserID: model.UserID,
|
||||
Date: model.Date,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func DeleteFillupById(fillupId string) error {
|
||||
return db.DeleteFillupById(fillupId)
|
||||
}
|
||||
func DeleteExpenseById(expenseId string) error {
|
||||
return db.DeleteExpenseById(expenseId)
|
||||
}
|
||||
|
||||
func CreateVehicleAttachment(vehicleId, attachmentId, title string) error {
|
||||
model := &db.VehicleAttachment{
|
||||
AttachmentID: attachmentId,
|
||||
VehicleID: vehicleId,
|
||||
Title: title,
|
||||
}
|
||||
return db.DB.Create(model).Error
|
||||
}
|
||||
func GetVehicleAttachments(vehicleId string) (*[]db.Attachment, error) {
|
||||
|
||||
return db.GetVehicleAttachments(vehicleId)
|
||||
}
|
||||
|
||||
func GetUserStats(userId string, model models.UserStatsQueryModel) ([]models.VehicleStatsModel, error) {
|
||||
|
||||
vehicles, err := GetUserVehicles(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var vehicleIds []string
|
||||
for _, v := range *vehicles {
|
||||
vehicleIds = append(vehicleIds, v.ID)
|
||||
}
|
||||
|
||||
expenses, err := db.FindExpensesForDateRange(vehicleIds, model.Start, model.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fillups, err := db.FindFillupsForDateRange(vehicleIds, model.Start, model.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toReturn := models.VehicleStatsModel{}
|
||||
stats := toReturn.SetStats(fillups, expenses)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
2
ui/.browserslistrc
Normal file
@@ -0,0 +1,2 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
2
ui/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
2
ui/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
/tests/unit/coverage/
|
||||
81
ui/.eslintrc.js
Normal file
@@ -0,0 +1,81 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
extends: [
|
||||
// https://github.com/vuejs/eslint-plugin-vue#bulb-rules
|
||||
'plugin:vue/recommended',
|
||||
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
|
||||
'standard',
|
||||
// https://github.com/prettier/eslint-config-prettier
|
||||
'prettier',
|
||||
'prettier/standard',
|
||||
'prettier/vue',
|
||||
],
|
||||
rules: {
|
||||
// Only allow debugger in development
|
||||
'no-debugger': process.env.PRE_COMMIT ? 'error' : 'off',
|
||||
// Only allow `console.log` in development
|
||||
'no-console': process.env.PRE_COMMIT
|
||||
? ['error', { allow: ['warn', 'error'] }]
|
||||
: 'off',
|
||||
'import/no-relative-parent-imports': 'error',
|
||||
'import/order': 'error',
|
||||
'vue/array-bracket-spacing': 'error',
|
||||
'vue/arrow-spacing': 'error',
|
||||
'vue/block-spacing': 'error',
|
||||
'vue/brace-style': 'error',
|
||||
'vue/camelcase': 'error',
|
||||
'vue/comma-dangle': ['error', 'always-multiline'],
|
||||
'vue/component-name-in-template-casing': 'error',
|
||||
'vue/dot-location': ['error', 'property'],
|
||||
'vue/eqeqeq': 'error',
|
||||
'vue/key-spacing': 'error',
|
||||
'vue/keyword-spacing': 'error',
|
||||
'vue/no-boolean-default': ['error', 'default-false'],
|
||||
'vue/no-deprecated-scope-attribute': 'error',
|
||||
'vue/no-empty-pattern': 'error',
|
||||
'vue/object-curly-spacing': ['error', 'always'],
|
||||
'vue/padding-line-between-blocks': 'error',
|
||||
'vue/space-infix-ops': 'error',
|
||||
'vue/space-unary-ops': 'error',
|
||||
'vue/v-on-function-call': 'error',
|
||||
'vue/v-slot-style': [
|
||||
'error',
|
||||
{
|
||||
atComponent: 'v-slot',
|
||||
default: 'v-slot',
|
||||
named: 'longform',
|
||||
},
|
||||
],
|
||||
'vue/valid-v-slot': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/**/*', 'tests/unit/**/*', 'tests/e2e/**/*'],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module',
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.unit.js'],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module',
|
||||
},
|
||||
env: { jest: true },
|
||||
globals: {
|
||||
mount: false,
|
||||
shallowMount: false,
|
||||
shallowMountView: false,
|
||||
createComponentMocks: false,
|
||||
createModuleStore: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
44
ui/.gitattributes
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Fix end-of-lines in Git versions older than 2.10
|
||||
# https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248
|
||||
* text=auto eol=lf
|
||||
|
||||
# ===
|
||||
# Binary Files (don't diff, don't fix line endings)
|
||||
# ===
|
||||
|
||||
# Images
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.tiff binary
|
||||
|
||||
# Fonts
|
||||
*.oft binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# Videos
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.ogg binary
|
||||
*.mpg binary
|
||||
*.3gp binary
|
||||
*.avi binary
|
||||
*.wmv binary
|
||||
*.flv binary
|
||||
*.asf binary
|
||||
|
||||
# Audio
|
||||
*.mp3 binary
|
||||
*.wav binary
|
||||
*.flac binary
|
||||
|
||||
# Compressed
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
31
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# OS Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Dev/Build Artifacts
|
||||
/dist/
|
||||
/tests/e2e/videos/
|
||||
/tests/e2e/screenshots/
|
||||
/tests/unit/coverage/
|
||||
jsconfig.json
|
||||
|
||||
# Local Env Files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Unconfigured Editors
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
17
ui/.markdownlint.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
default: true
|
||||
|
||||
# ===
|
||||
# Rule customizations for markdownlint go here
|
||||
# https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md
|
||||
# ===
|
||||
|
||||
# Disable line length restrictions, because editor soft-wrapping is being
|
||||
# used instead.
|
||||
line-length: false
|
||||
|
||||
# ===
|
||||
# Prettier overrides
|
||||
# ===
|
||||
|
||||
no-multiple-blanks: false
|
||||
list-marker-space: false
|
||||
5
ui/.postcssrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
3
ui/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules/**
|
||||
/dist/**
|
||||
/tests/unit/coverage/**
|
||||
17
ui/.prettierrc.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
arrowParens: 'always',
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'lf',
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
jsxBracketSameLine: false,
|
||||
jsxSingleQuote: true,
|
||||
printWidth: 150,
|
||||
proseWrap: 'never',
|
||||
quoteProps: 'as-needed',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'es5',
|
||||
useTabs: false,
|
||||
vueIndentScriptAndStyle: false,
|
||||
}
|
||||
30
ui/.vscode/_components.code-snippets
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"BaseButton": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseButton",
|
||||
"body": ["<BaseButton>", "\t${3}", "</BaseButton>"],
|
||||
"description": "<BaseButton>"
|
||||
},
|
||||
"BaseIcon": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseIcon",
|
||||
"body": ["<BaseIcon name=\"${1}\">", "\t${2}", "</BaseIcon>"],
|
||||
"description": "<BaseIcon>"
|
||||
},
|
||||
"BaseInputText": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseInputText",
|
||||
"body": ["<BaseInputText ${1}/>"],
|
||||
"description": "<BaseInputText>"
|
||||
},
|
||||
"BaseLink": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseLink",
|
||||
"body": [
|
||||
"<BaseLink ${1|name,:to,href|}=\"${2:route}\">",
|
||||
"\t${3}",
|
||||
"</BaseLink>"
|
||||
],
|
||||
"description": "<BaseLink>"
|
||||
}
|
||||
}
|
||||
26
ui/.vscode/_sfc-blocks.code-snippets
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"script": {
|
||||
"scope": "vue",
|
||||
"prefix": "script",
|
||||
"body": ["<script>", "export default {", "\t${0}", "}", "</script>"],
|
||||
"description": "<script>"
|
||||
},
|
||||
"template": {
|
||||
"scope": "vue",
|
||||
"prefix": "template",
|
||||
"body": ["<template>", "\t${0}", "</template>"],
|
||||
"description": "<template>"
|
||||
},
|
||||
"style": {
|
||||
"scope": "vue",
|
||||
"prefix": "style",
|
||||
"body": [
|
||||
"<style lang=\"scss\" module>",
|
||||
"@import '@design';",
|
||||
"",
|
||||
"${0}",
|
||||
"</style>"
|
||||
],
|
||||
"description": "<style lang=\"scss\" module>"
|
||||
}
|
||||
}
|
||||
37
ui/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
// Syntax highlighting and more for .vue files
|
||||
// https://github.com/vuejs/vetur
|
||||
"octref.vetur",
|
||||
|
||||
// Peek and go-to-definition for .vue files
|
||||
// https://github.com/fuzinato/vscode-vue-peek
|
||||
"dariofuzinato.vue-peek",
|
||||
|
||||
// Lint-on-save with ESLint
|
||||
// https://github.com/microsoft/vscode-eslint
|
||||
"dbaeumer.vscode-eslint",
|
||||
|
||||
// Lint-on-save with Stylelint
|
||||
// https://github.com/stylelint/vscode-stylelint
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
// Lint-on-save markdown in README files
|
||||
// https://github.com/DavidAnson/vscode-markdownlint
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
|
||||
// Format-on-save with Prettier
|
||||
// https://github.com/prettier/prettier-vscode
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
// SCSS intellisense
|
||||
// https://github.com/mrmlnc/vscode-scss
|
||||
"mrmlnc.vscode-scss",
|
||||
|
||||
// Test `.unit.js` files on save with Jest
|
||||
// https://github.com/jest-community/vscode-jest
|
||||
"Orta.vscode-jest"
|
||||
]
|
||||
}
|
||||
93
ui/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
// ===
|
||||
// Spacing
|
||||
// ===
|
||||
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.trimAutoWhitespace": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
|
||||
// ===
|
||||
// Files
|
||||
// ===
|
||||
|
||||
"files.exclude": {
|
||||
"**/*.log": true,
|
||||
"**/*.log*": true,
|
||||
"**/dist": true,
|
||||
"**/coverage": true
|
||||
},
|
||||
"files.associations": {
|
||||
".markdownlintrc": "jsonc"
|
||||
},
|
||||
|
||||
// ===
|
||||
// Event Triggers
|
||||
// ===
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": true,
|
||||
"source.fixAll.markdownlint": true
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"vue",
|
||||
"vue-html",
|
||||
"html"
|
||||
],
|
||||
"vetur.format.enable": false,
|
||||
"vetur.completion.scaffoldSnippetSources": {
|
||||
"user": "🗒️",
|
||||
"workspace": "💼",
|
||||
"vetur": ""
|
||||
},
|
||||
"prettier.disableLanguages": [],
|
||||
|
||||
// ===
|
||||
// HTML
|
||||
// ===
|
||||
|
||||
"html.format.enable": false,
|
||||
"vetur.validation.template": false,
|
||||
"emmet.triggerExpansionOnTab": true,
|
||||
"emmet.includeLanguages": {
|
||||
"vue-html": "html"
|
||||
},
|
||||
"vetur.completion.tagCasing": "initial",
|
||||
|
||||
// ===
|
||||
// JS(ON)
|
||||
// ===
|
||||
|
||||
"jest.autoEnable": false,
|
||||
"jest.enableCodeLens": false,
|
||||
"javascript.format.enable": false,
|
||||
"json.format.enable": false,
|
||||
"vetur.validation.script": false,
|
||||
|
||||
// ===
|
||||
// CSS
|
||||
// ===
|
||||
|
||||
"stylelint.enable": true,
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"vetur.validation.style": false,
|
||||
|
||||
// ===
|
||||
// MARKDOWN
|
||||
// ===
|
||||
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "wordWrapColumn",
|
||||
"editor.wordWrapColumn": 80
|
||||
}
|
||||
}
|
||||
21
ui/.vuepress/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const appConfig = require('../src/app.config')
|
||||
|
||||
module.exports = {
|
||||
title: appConfig.title + ' Docs',
|
||||
description: appConfig.description,
|
||||
themeConfig: {
|
||||
sidebar: [
|
||||
['/', 'Introduction'],
|
||||
'/docs/development',
|
||||
'/docs/architecture',
|
||||
'/docs/tech',
|
||||
'/docs/routing',
|
||||
'/docs/state',
|
||||
'/docs/tests',
|
||||
'/docs/linting',
|
||||
'/docs/editors',
|
||||
'/docs/production',
|
||||
'/docs/troubleshooting',
|
||||
],
|
||||
},
|
||||
}
|
||||
12
ui/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:latest as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
191
ui/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
[![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] [![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<p align="center">
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">
|
||||
<img src="images/logo.png" alt="Logo" width="80" height="80">
|
||||
</a> -->
|
||||
|
||||
<h1 align="center" style="margin-bottom:0">Hammond</h1>
|
||||
<p align="center">Current Version - 2021.05.07</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted vehicle expense tracking system with support for multiple users.
|
||||
<br />
|
||||
<a href="https://github.com/akhilrex/hammond"><strong>Explore the docs »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">View Demo</a>
|
||||
· -->
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Request Feature</a>
|
||||
·
|
||||
<a href="Screenshots.md">Screenshots</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [About the Project](#about-the-project)
|
||||
- [Motivation](#motivation)
|
||||
- [Built With](#built-with)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [License](#license)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Contact](#contact)
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
|
||||
Hammond is a self hosted vehicle management system to track fuel and other expenses related to all of your vehicles. It supports multiple users sharing multiple vehicles. It is the logical successor to Clarkson which has not been updated for quite some time now.
|
||||
|
||||
_Developers Note: This project is under active development which means I release new updates very frequently. It is recommended that you use something like [watchtower](https://github.com/containrrr/watchtower) which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually._
|
||||
|
||||
### Motivation and Developer Notes
|
||||
|
||||
I was looking for a fuel tracking system and stumbled upon Clarkson. Although it did most of what I needed it has not been updated for quite a lot of time. Since I had some bandwidth available as my previous open source project [Podgrab](http://github.com/akhilrex/podgrab) had become quite stable now, my first thought was to contribute to the Clarkson project only. I soon realized that the architecture that Clarkson had used was not really be that extensible now and would warrant a complete rewrite only. So I decided to build Hammond - The successor to Clarkson.
|
||||
|
||||
The current version of Hammond is written using GO for backend and Vuejs for the front end. Originally I had thought of using the same tech stack for both frontend and the backend so that it became easier for users and other developers to use, deploy and contribute. Which is why the first version of Hammond has a NestJS backend complete with all the bells and whistles (GraphQL, Prisma and what nots). But I eventually decided to rebuild the backend in GO just to keep the container size small. No matter how much you can optimize the sheer size of the node_modules will always add bulk to your containers. I host all my tools on my Raspberry Pi. It only makes sense to keep the container size as small as possible.
|
||||
|
||||
Also I had initially thought of a 2 container approach (1 for backend and 1 for the frontend) so that they can be independently maintained and updated. I eventually decided against this idea for the sake of simplicity. Although it is safe to assume that most self-hosters are fairly tech capable it still is much better to have a single container that you can fire and forget.
|
||||
|
||||
![Product Name Screen Shot][product-screenshot] [More Screenshots](Screenshots.md)
|
||||
|
||||
### Built With
|
||||
|
||||
- [Go](https://golang.org/)
|
||||
- [Go-Gin](https://github.com/gin-gonic/gin)
|
||||
- [GORM](https://github.com/go-gorm/gorm)
|
||||
- [SQLite](https://www.sqlite.org/index.html)
|
||||
- [VueJS](https://vuejs.org/)
|
||||
- [Vuex](https://vuex.vuejs.org/)
|
||||
- [Buefy](https://buefy.org/)
|
||||
|
||||
### Features
|
||||
|
||||
- Migrate data from Clarkson
|
||||
- Add/Manage multiple vehicles
|
||||
- Add/Manage multiple users
|
||||
- Track fuel and other expenses
|
||||
- Share vehicles across multiple users
|
||||
- Quick Entries (take a photo of a receipt or pump screen to make entry later)
|
||||
- Vehicle level and overall reporting
|
||||
|
||||
## Installation
|
||||
|
||||
The easiest way to run Hammond is to run it as a docker container.
|
||||
|
||||
### Using Docker
|
||||
|
||||
Simple setup without mounted volumes (for testing and evaluation)
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond akhilrex/hammond
|
||||
```
|
||||
|
||||
Binding local volumes to the container
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" akhilrex/hammond
|
||||
```
|
||||
|
||||
### Using Docker-Compose
|
||||
|
||||
Modify the docker compose file provided [here](https://github.com/akhilrex/hammond/blob/master/docker-compose.yml) to update the volume and port binding and run the following command
|
||||
|
||||
```yaml
|
||||
version: '2.1'
|
||||
services:
|
||||
hammond:
|
||||
image: akhilrex/hammond
|
||||
container_name: hammond
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/data:/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<!-- ### Build from Source / Ubuntu Installation
|
||||
|
||||
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. Follow the link below to get a guide on how to build hammond from source.
|
||||
|
||||
[Build from source / Ubuntu Guide](docs/ubuntu-install.md) -->
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Name | Description | Default |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| PORT | Change the internal port of the application. If you change this you might have to change your docker configuration as well | (empty) |
|
||||
|
||||
### Setup
|
||||
|
||||
When you open Hammond for the first time after a fresh install, you will be presented with the option to either import data from an existing Clarkson instance or setup a fresh instance.
|
||||
|
||||
#### Migration from Clarkson
|
||||
|
||||
You will have to ensure that the Clarkson database is accessible from the Hammond deployment. In case it is not directly possible, you can always take a backup of the Clarkson database and host it somewhere accessible to Hammond using a temporary container. If the access problem is sorted, you will have to enter the connection string the Clarkson database in the following format.
|
||||
|
||||
```
|
||||
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
You can check the connectivity from the screen as well.
|
||||
|
||||
Note: All the users migrated from Clarkson will have their passwords changed to `hammond`
|
||||
|
||||
#### Fresh setup
|
||||
|
||||
You will have to provide your name, email and password so that an admin user can be created for you.
|
||||
|
||||
Once done you will be taken to the login page.
|
||||
|
||||
Go through the settings page once and change relevant settings before you start adding vehicles and expenses.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the GPL-3.0 License. See `LICENSE` for more information.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] More reports
|
||||
- [ ] Vehicle specific reminders (servicing etc)
|
||||
- [ ] Native installer for Windows/Linux/MacOS
|
||||
|
||||
<!-- CONTACT -->
|
||||
|
||||
## Contact
|
||||
|
||||
Akhil Gupta - [@akhilrex](https://twitter.com/akhilrex)
|
||||
|
||||
Project Link: [https://github.com/akhilrex/hammond](https://github.com/akhilrex/hammond)
|
||||
|
||||
<a href="https://www.buymeacoffee.com/akhilrex" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="width: 217px !important;height: 60px !important;" ></a>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/akhilrex/hammond.svg?style=flat-square
|
||||
[contributors-url]: https://github.com/akhilrex/hammond/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/github/forks/akhilrex/hammond.svg?style=flat-square
|
||||
[forks-url]: https://github.com/akhilrex/hammond/network/members
|
||||
[stars-shield]: https://img.shields.io/github/stars/akhilrex/hammond.svg?style=flat-square
|
||||
[stars-url]: https://github.com/akhilrex/hammond/stargazers
|
||||
[issues-shield]: https://img.shields.io/github/issues/akhilrex/hammond.svg?style=flat-square
|
||||
[issues-url]: https://github.com/akhilrex/hammond/issues
|
||||
[license-shield]: https://img.shields.io/github/license/akhilrex/hammond.svg?style=flat-square
|
||||
[license-url]: https://github.com/akhilrex/hammond/blob/master/LICENSE.txt
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://linkedin.com/in/akhilrex
|
||||
[product-screenshot]: images/screenshot.jpg
|
||||
74
ui/aliases.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const prettier = require('prettier')
|
||||
|
||||
const aliases = {
|
||||
'@': '.',
|
||||
'@src': 'src',
|
||||
'@router': 'src/router',
|
||||
'@views': 'src/router/views',
|
||||
'@layouts': 'src/router/layouts',
|
||||
'@components': 'src/components',
|
||||
'@assets': 'src/assets',
|
||||
'@utils': 'src/utils',
|
||||
'@state': 'src/state',
|
||||
'@design': 'src/design/index.scss',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
webpack: {},
|
||||
jest: {},
|
||||
jsconfig: {},
|
||||
}
|
||||
|
||||
for (const alias in aliases) {
|
||||
const aliasTo = aliases[alias]
|
||||
module.exports.webpack[alias] = resolveSrc(aliasTo)
|
||||
const aliasHasExtension = /\.\w+$/.test(aliasTo)
|
||||
module.exports.jest[`^${alias}$`] = aliasHasExtension
|
||||
? `<rootDir>/${aliasTo}`
|
||||
: `<rootDir>/${aliasTo}/index.js`
|
||||
module.exports.jest[`^${alias}/(.*)$`] = `<rootDir>/${aliasTo}/$1`
|
||||
module.exports.jsconfig[alias + '/*'] = [aliasTo + '/*']
|
||||
module.exports.jsconfig[alias] = aliasTo.includes('/index.')
|
||||
? [aliasTo]
|
||||
: [
|
||||
aliasTo + '/index.js',
|
||||
aliasTo + '/index.json',
|
||||
aliasTo + '/index.vue',
|
||||
aliasTo + '/index.scss',
|
||||
aliasTo + '/index.css',
|
||||
]
|
||||
}
|
||||
|
||||
const jsconfigTemplate = require('./jsconfig.template') || {}
|
||||
const jsconfigPath = path.resolve(__dirname, 'jsconfig.json')
|
||||
|
||||
fs.writeFile(
|
||||
jsconfigPath,
|
||||
prettier.format(
|
||||
JSON.stringify({
|
||||
...jsconfigTemplate,
|
||||
compilerOptions: {
|
||||
...(jsconfigTemplate.compilerOptions || {}),
|
||||
paths: module.exports.jsconfig,
|
||||
},
|
||||
}),
|
||||
{
|
||||
...require('./.prettierrc'),
|
||||
parser: 'json',
|
||||
}
|
||||
),
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.error(
|
||||
'Error while creating jsconfig.json from aliases.config.js.'
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function resolveSrc(_path) {
|
||||
return path.resolve(__dirname, _path)
|
||||
}
|
||||
4
ui/babel.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
// https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
}
|
||||
3
ui/cypress.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pluginsFile": "tests/e2e/plugins/index.js"
|
||||
}
|
||||
16
ui/docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3.7'
|
||||
|
||||
volumes:
|
||||
dependencies:
|
||||
|
||||
services:
|
||||
dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker-dev.dockerfile
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- .:/app
|
||||
- dependencies:/app/node_modules
|
||||
tty: true
|
||||
15
ui/docker-dev.dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# https://github.com/cypress-io/cypress-docker-images/tree/master/base
|
||||
FROM cypress/base:10.16.0
|
||||
|
||||
# Make the `app` folder the current working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency-related files
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
# Install project dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Expose ports 8080, which the dev server will be bound to
|
||||
EXPOSE 8080
|
||||
93
ui/docs/architecture.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Architecture
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [`.circleci`](#circleci)
|
||||
- [`.vscode`](#vscode)
|
||||
- [`.vuepress`](#vuepress)
|
||||
- [`docs`](#docs)
|
||||
- [`generators`](#generators)
|
||||
- [`public`](#public)
|
||||
- [`index.html`](#indexhtml)
|
||||
- [`src`](#src)
|
||||
- [`assets`](#assets)
|
||||
- [`components`](#components)
|
||||
- [`design`](#design)
|
||||
- [`router`](#router)
|
||||
- [`state`](#state)
|
||||
- [`utils`](#utils)
|
||||
- [`app.config.json`](#appconfigjson)
|
||||
- [`app.vue`](#appvue)
|
||||
- [`main.js`](#mainjs)
|
||||
- [`tests`](#tests)
|
||||
|
||||
## `.circleci`
|
||||
|
||||
Configuration for continuous integration with [Circle CI](https://circleci.com/). See [the production doc](production.md#from-circle-ci) for more.
|
||||
|
||||
## `.vscode`
|
||||
|
||||
Settings and extensions specific to this project, for Visual Studio Code. See [the editors doc](editors.md#visual-studio-code) for more.
|
||||
|
||||
## `.vuepress`
|
||||
|
||||
[VuePress](https://vuepress.vuejs.org/) configuration for docs static site generation.
|
||||
|
||||
## `docs`
|
||||
|
||||
You found me! :wink:
|
||||
|
||||
## `generators`
|
||||
|
||||
Generator templates to speed up development. See [the development doc](development.md#generators) for more.
|
||||
|
||||
## `public`
|
||||
|
||||
Where you'll keep any static assets, to be added to the `dist` directory without processing from our build system.
|
||||
|
||||
### `index.html`
|
||||
|
||||
This one file actually _does_ get processed by our build system, allowing us to inject some information from Webpack with [EJS](http://ejs.co/), such as the title, then add our JS and CSS.
|
||||
|
||||
## `src`
|
||||
|
||||
Where we keep all our source files.
|
||||
|
||||
### `assets`
|
||||
|
||||
This project manages assets via Vue CLI. Learn more about [its asset handling here](https://cli.vuejs.org/guide/html-and-static-assets.html).
|
||||
|
||||
### `components`
|
||||
|
||||
Where most of the components in our app will live, including our [global base components](development.md#base-components).
|
||||
|
||||
### `design`
|
||||
|
||||
Where we keep our [design variables and tooling](tech.md#design-variables-and-tooling).
|
||||
|
||||
### `router`
|
||||
|
||||
Where the router, routes, and any routing-related components live. See [the routing doc](routing.md) for more.
|
||||
|
||||
### `state`
|
||||
|
||||
Where all our global state management lives. See [the state management doc](state.md) for more.
|
||||
|
||||
### `utils`
|
||||
|
||||
These are utility functions you may want to share between many files in your application. They will always be pure and never have side effects, meaning if you provide a function the same arguments, it will always return the same result. These should also never directly affect the DOM or interface with our Vuex state.
|
||||
|
||||
### `app.config.json`
|
||||
|
||||
Contains app-specific metadata.
|
||||
|
||||
### `app.vue`
|
||||
|
||||
The root Vue component that simply delegates to the router view. This is typically the only component to contain global CSS.
|
||||
|
||||
### `main.js`
|
||||
|
||||
The entry point to our app, were we create our Vue instance and mount it to the DOM.
|
||||
|
||||
## `tests`
|
||||
|
||||
Where all our tests go. See [the tests doc](tests.md) for more.
|
||||
146
ui/docs/development.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Setup and development
|
||||
|
||||
- [Setup and development](#setup-and-development)
|
||||
- [First-time setup](#first-time-setup)
|
||||
- [Installation](#installation)
|
||||
- [Dev server](#dev-server)
|
||||
- [Developing with the production API](#developing-with-the-production-api)
|
||||
- [Generators](#generators)
|
||||
- [Aliases](#aliases)
|
||||
- [Globals](#globals)
|
||||
- [Base components](#base-components)
|
||||
- [Docker (optional)](#docker-optional)
|
||||
|
||||
## First-time setup
|
||||
|
||||
Make sure you have the following installed:
|
||||
|
||||
- [Node](https://nodejs.org/en/) (at least the latest LTS)
|
||||
- [Yarn](https://yarnpkg.com/lang/en/docs/install/) (at least 1.0)
|
||||
|
||||
Then update the following files to suit your application:
|
||||
|
||||
- `src/app.config.json` (provides metadata about your app)
|
||||
- `.circleci/config.yml` (assuming you want to automatically [deploy to production](production.md) with continuous integration)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies from package.json
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Dev server
|
||||
|
||||
> Note: If you're on Linux and see an `ENOSPC` error when running the commands below, you must [increase the number of available file watchers](https://stackoverflow.com/questions/22475849/node-js-error-enospc#answer-32600959).
|
||||
|
||||
```bash
|
||||
# Launch the dev server
|
||||
yarn dev
|
||||
|
||||
# Launch the dev server and automatically open it in
|
||||
# your default browser when ready
|
||||
yarn dev --open
|
||||
|
||||
# Launch the dev server with the Cypress client for
|
||||
# test-driven development in a friendly interface
|
||||
yarn dev:e2e
|
||||
```
|
||||
|
||||
### Developing with the production API
|
||||
|
||||
By default, dev and tests filter requests through [the mock API](/docs/tests.md#the-mock-api) in `tests/mock-api`. To test directly against a local/live API instead, run dev and test commands with the `API_BASE_URL` environment variable set. For example:
|
||||
|
||||
```bash
|
||||
# To develop against a local backend server
|
||||
API_BASE_URL=http://localhost:3000 yarn dev
|
||||
|
||||
# To test and develop against a production server
|
||||
API_BASE_URL=https://example.io yarn dev:e2e
|
||||
```
|
||||
|
||||
## Generators
|
||||
|
||||
This project includes generators to speed up common development tasks. Commands include:
|
||||
|
||||
```bash
|
||||
# Generate a new component with adjacent unit test
|
||||
yarn new component
|
||||
|
||||
# Generate a new view component with adjacent unit test
|
||||
yarn new view
|
||||
|
||||
# Generate a new layout component with adjacent unit test
|
||||
yarn new layout
|
||||
|
||||
# Generate a new Vuex module with adjacent unit test
|
||||
yarn new module
|
||||
|
||||
# Generate a new utility function with adjacent unit test
|
||||
yarn new util
|
||||
|
||||
# Generate a new end-to-end test
|
||||
yarn new e2e
|
||||
```
|
||||
|
||||
Update existing or create new generators in the `generators` folder, with help from the [Hygen docs](http://www.hygen.io/).
|
||||
|
||||
## Aliases
|
||||
|
||||
To simplify referencing local modules and refactoring, you can set aliases to be shared between dev and unit tests in `aliases.config.js`. As a convention, this project uses an `@` prefix to denote aliases.
|
||||
|
||||
## Globals
|
||||
|
||||
### Base components
|
||||
|
||||
[Base components](https://vuejs.org/v2/style-guide/#Base-component-names-strongly-recommended) (a.k.a. presentational, dumb, or pure components) that apply app-specific styling and conventions should all begin with the `_base-` prefix. Since these components are typically used in place of raw HTML element (and thus used as frequently), they're automatically globally registered for convenience. This means you don't have to import and locally register them to use them in templates.
|
||||
|
||||
## Docker (optional)
|
||||
|
||||
If you'd prefer to use Docker for development, it's recommended to install and run [Docker Desktop](https://www.docker.com/products/docker-desktop). Once the app is started, you'll be able to run commands like:
|
||||
|
||||
```bash
|
||||
# Build and run a containerized version of your app in the background
|
||||
docker-compose up --detach
|
||||
```
|
||||
|
||||
Once your container has started, you can run any script from `package.json` inside the container by prefixing the command with `yarn docker` instead of just `yarn`. For example:
|
||||
|
||||
```bash
|
||||
# Install dependencies in the container
|
||||
yarn docker install
|
||||
|
||||
# Run the dev environment in the container
|
||||
yarn docker dev
|
||||
|
||||
# Run tests in the container
|
||||
yarn docker test
|
||||
```
|
||||
|
||||
To list your containers and their statuses, you can run:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
To stop your running containers, run:
|
||||
|
||||
```bash
|
||||
docker-compose stop
|
||||
```
|
||||
|
||||
If ever update the following files:
|
||||
|
||||
- `.dockerignore`
|
||||
- `docker-compose.yml`
|
||||
- `docker-dev.dockerfile`
|
||||
|
||||
Then you'll want to stop and remove all containers, networks, volumes, and images created for your app with:
|
||||
|
||||
```bash
|
||||
docker-compose down --volumes --rmi all --remove-orphans
|
||||
```
|
||||
|
||||
This command can also be useful in case something goes wrong with a container and you'd like to start over. All containers, networks, volumes, and images defined in `docker-compose.yml` will be rebuilt the next time you run `docker-compose up`.
|
||||
|
||||
See the docs for [Docker](https://docs.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) for more information on how to use and configure Docker tooling.
|
||||
32
ui/docs/editors.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Editor integration
|
||||
|
||||
- [Visual Studio Code](#visual-studio-code)
|
||||
- [Configuration](#configuration)
|
||||
- [FAQ](#faq)
|
||||
|
||||
## Visual Studio Code
|
||||
|
||||
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:
|
||||
|
||||
- Syntax highlighting for all files
|
||||
- Intellisense for all files
|
||||
- Lint-on-save for all files
|
||||
- In-editor results on save for unit tests
|
||||
|
||||
### Configuration
|
||||
|
||||
To configure
|
||||
|
||||
- `.vscode/extensions.json`
|
||||
- `.vscode/settings.json`
|
||||
|
||||
## FAQ
|
||||
|
||||
**What kinds of editor settings and extensions should be added to the project?**
|
||||
|
||||
All additions must:
|
||||
|
||||
- be specific to this project
|
||||
- not interfere with any team member's workflow
|
||||
|
||||
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.
|
||||
63
ui/docs/linting.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Linting & formatting
|
||||
|
||||
- [Languages](#languages)
|
||||
- [Scripts](#scripts)
|
||||
- [Terminal](#terminal)
|
||||
- [Pre-commit](#pre-commit)
|
||||
- [Editor](#editor)
|
||||
- [Configuration](#configuration)
|
||||
- [FAQ](#faq)
|
||||
|
||||
This project uses ESLint, Stylelint, Markdownlint, and Prettier to catch errors and avoid bikeshedding by enforcing a common code style.
|
||||
|
||||
## Languages
|
||||
|
||||
- **JavaScript** is linted by ESLint and formatted by Prettier
|
||||
- **HTML** (in templates and JSX) is linted by ESLint
|
||||
- **CSS** is linted by Stylelint and formatted by Prettier
|
||||
- **Markdown** is linted by Markdownlint and formatted by Prettier
|
||||
- **JSON** is formatted by Prettier
|
||||
- **Images** are minified by `imagemin-lint-staged` (only on pre-commit)
|
||||
|
||||
## Scripts
|
||||
|
||||
There are a few different contexts in which the linters run.
|
||||
|
||||
### Terminal
|
||||
|
||||
```bash
|
||||
# Lint all files, fixing many violations automatically
|
||||
yarn lint
|
||||
```
|
||||
|
||||
See `package.json` to update.
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Staged files are automatically linted and tested before each commit. See `lint-staged.config.js` to update. [Yorkie](https://github.com/yyx990803/yorkie) is used by `@vue/cli-plugin-eslint` to install the pre-commit hook.
|
||||
|
||||
### Editor
|
||||
|
||||
In supported editors, all files will be linted and formatted on-save. See [editors.md](editors.md) for details.
|
||||
|
||||
## Configuration
|
||||
|
||||
This boilerplate ships with opinionated defaults, but you can edit each tools configuration in the following config files:
|
||||
|
||||
- [ESLint](https://eslint.org/docs/user-guide/configuring)
|
||||
- `.eslintrc.js`
|
||||
- `.eslintignore`
|
||||
- [Stylelint](https://stylelint.io/user-guide/configuration/)
|
||||
- `stylelint.config.js`
|
||||
- [Markdownlint](https://github.com/markdownlint/markdownlint/blob/master/docs/configuration.md)
|
||||
- `.markdownlintrc`
|
||||
- [Prettier](https://prettier.io/docs/en/configuration.html)
|
||||
- `.prettierrc.js`
|
||||
- `.prettierignore`
|
||||
|
||||
## FAQ
|
||||
|
||||
**So many configuration files! Why not move more of this to `package.json`?**
|
||||
|
||||
- Moving all possible configs to `package.json` can make it _really_ packed, so that quickly navigating to a specific config becomes difficult.
|
||||
- When split out into their own file, many tools provide the option of exporting a config from JS. I do this wherever possible, because dynamic configurations are simply more powerful, able to respond to environment variables and much more.
|
||||
17
ui/docs/production.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Building and deploying to production
|
||||
|
||||
- [From the terminal](#from-the-terminal)
|
||||
- [From Circle CI](#from-circle-ci)
|
||||
|
||||
## From the terminal
|
||||
|
||||
```bash
|
||||
# Build for production with minification
|
||||
yarn build
|
||||
```
|
||||
|
||||
This results in your compiled application in a `dist` directory.
|
||||
|
||||
## From Circle CI
|
||||
|
||||
Update `.circleci/config.yml` to automatically deploy to staging and/or production on a successful build. See comments in that file for details.
|
||||
17
ui/docs/routing.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Routing, layouts, and views
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Layouts](#layouts)
|
||||
- [Views](#views)
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses [Vue Router](tech.md#vue-router), which we initialize in `src/router/index.js`, with routes defined in `src/router/routes.js`. Inside the `src/router` folder, there are also two sub-folders, both containing route-specific components: `layouts` and `views`.
|
||||
|
||||
## Layouts
|
||||
|
||||
Every view component must use a layout component as its base and register it as `Layout`, as this convention helps us mock out layout components when testing views. Layouts usually aren't very complex, often containing only shared HTML like headers, footers, and navigation to surround the main content in the view.
|
||||
|
||||
## Views
|
||||
|
||||
Each view component will be used by at least one route in `src/router/routes.js`, to provide a template for the page. They can technically include some additional properties from Vue Router [to control navigation](https://router.vuejs.org/guide/advanced/navigation-guards.html), for example to [fetch data](https://router.vuejs.org/guide/advanced/data-fetching.html#fetching-before-navigation) before creating the component, but I recommend adding these guards to `src/router/routes.js` instead, as that behavior typically has much more to do with the route (and will sometimes be shared between routes) than it does the view component.
|
||||
66
ui/docs/state.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# State management
|
||||
|
||||
- [State management](#state-management)
|
||||
- [Modules](#modules)
|
||||
- [Helpers](#helpers)
|
||||
- [Module Nesting](#module-nesting)
|
||||
|
||||
## Modules
|
||||
|
||||
The `src/state/modules` directory is where all shared application state lives. Any JS file added here (apart from unit tests) will be automatically registered in the store as a [namespaced module](https://vuex.vuejs.org/en/modules.html#namespacing).
|
||||
|
||||
Read more in the [Vuex modules](https://vuex.vuejs.org/en/modules.html) docs.
|
||||
|
||||
## Helpers
|
||||
|
||||
The state helpers in `helpers.js` are the components' interface to the Vuex store. Depending on a component's concerns, we can import a subset of these helpers to quickly bring in the data and actions we need.
|
||||
|
||||
You might be thinking, "Why not just automatically inject all of these into every component?" Well, then it would be difficult to figure out where a particular part of state is coming from. As our state becomes increasingly complex, the risk would also increase of accidentally using the same names for internal component state. This way, each component remains traceable, as the necessary `import` will provide a thread back to our helpers file if we ever don't understand where something is coming from.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```js
|
||||
import { authComputed } from '@state/helpers'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...authComputed,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Module Nesting
|
||||
|
||||
Vuex modules can be nested, which sometimes makes sense for organizational purposes. For example, if you created these files:
|
||||
|
||||
```js
|
||||
// @file src/state/modules/dashboard.js
|
||||
|
||||
export const state = {
|
||||
role: 'project-manager',
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// @file src/state/modules/dashboard/videos.js
|
||||
|
||||
export const state = {
|
||||
all: [],
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
favorited(state) {
|
||||
return state.all.filter((video) => video.favorited)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then you'd be able to access those modules with:
|
||||
|
||||
```js
|
||||
store.state.dashboard.role
|
||||
store.state.dashboard.videos.all
|
||||
store.getters['dashboard/videos/favorited']
|
||||
```
|
||||
|
||||
As you can see, placing the `videos` module in a folder called `dashboard` automatically nests it underneath the `dashboard` namespace. This works even if a `dashboard.js` file doesn't exist. You can also have as many levels of nesting as you want.
|
||||
289
ui/docs/tech.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Languages and technologies
|
||||
|
||||
- [Languages and technologies](#languages-and-technologies)
|
||||
- [JavaScript](#javascript)
|
||||
- [Polyfills](#polyfills)
|
||||
- [Vue](#vue)
|
||||
- [Vue Router](#vue-router)
|
||||
- [Vuex (state management)](#vuex-state-management)
|
||||
- [JavaScript FAQ](#javascript-faq)
|
||||
- [HTML](#html)
|
||||
- [Templates](#templates)
|
||||
- [Render functions](#render-functions)
|
||||
- [HTML FAQ](#html-faq)
|
||||
- [CSS](#css)
|
||||
- [SCSS](#scss)
|
||||
- [Importing global modules](#importing-global-modules)
|
||||
- [Referencing aliased asset URLs](#referencing-aliased-asset-urls)
|
||||
- [Design variables and tooling](#design-variables-and-tooling)
|
||||
- [CSS modules](#css-modules)
|
||||
- [Styling subcomponents](#styling-subcomponents)
|
||||
- [Sharing SCSS variables with JavaScript](#sharing-scss-variables-with-javascript)
|
||||
- [Global CSS](#global-css)
|
||||
- [CSS FAQ](#css-faq)
|
||||
|
||||
## JavaScript
|
||||
|
||||
Our JavaScript is compiled by Babel, using the [`@vue/babel-preset-app`](https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/babel-preset-app) as a base configuration. You can update this configuration in `.babelrc.js`.
|
||||
|
||||
If you're new to features such as `const`, `let`, and `=>` (arrow functions), take some time to read about the following features in Babel's ES2015 guide:
|
||||
|
||||
- [Arrow functions](https://babeljs.io/docs/en/learn/#arrows-and-lexical-this)
|
||||
- [Template literals](https://babeljs.io/docs/en/learn/#template-strings)
|
||||
- [Destructuring](https://babeljs.io/docs/en/learn/#destructuring)
|
||||
- [Spread operator](https://babeljs.io/docs/en/learn/#default-rest-spread)
|
||||
- [`let`/`const`](https://babeljs.io/docs/en/learn/#let-const)
|
||||
- [`for`...`of`](https://babeljs.io/docs/en/learn/#iterators-forof)
|
||||
|
||||
Reading these sections alone will get you 99% of the way to mastering Babel code. It's also a good idea to read about Promises, if you don't yet feel comfortable with them. Here's a [good intro](https://developers.google.com/web/fundamentals/getting-started/primers/promises).
|
||||
|
||||
### Polyfills
|
||||
|
||||
This project uses Vue CLI's [modern mode](https://cli.vuejs.org/guide/browser-compatibility.html#modern-mode), which creates two bundles: one modern bundle targeting modern browsers that support [ES modules](https://jakearchibald.com/2017/es-modules-in-browsers/), and one legacy bundle targeting older browsers that do not.
|
||||
|
||||
For each bundle, polyfills for any JavaScript features you use are included based on the target bundle and supported browsers defined by `browserslist` in `package.json`.
|
||||
|
||||
### Vue
|
||||
|
||||
Since Vue is such a huge part of our app, I strongly recommend everyone read through at least the _Essentials_ of [Vue's guide](https://vuejs.org/v2/guide/).
|
||||
|
||||
### Vue Router
|
||||
|
||||
To understand how to manage pages with Vue Router, I recommend reading through the _Essentials_ of [those docs](https://router.vuejs.org/en/essentials/getting-started.html). Then you can read more about [routing in this application](routing.md).
|
||||
|
||||
### Vuex (state management)
|
||||
|
||||
To wrap your head around our state management, I recommend reading through [those docs](https://vuex.vuejs.org/guide), starting at _What is Vuex?_ and stopping before _Application Architecture_. Then skip down and read [_Form Handling_](https://vuex.vuejs.org/en/forms.html) and [_Testing_](https://vuex.vuejs.org/en/testing.html). Finally, read about [state management in this application](state.md).
|
||||
|
||||
### JavaScript FAQ
|
||||
|
||||
**Why not use TypeScript instead of JavaScript? Isn't that more appropriate for enterprise environments?**
|
||||
|
||||
At its current rate of development, I think TypeScript will eventually _become_ the standard, but I don't think it's there yet for application development. Here's my reasoning:
|
||||
|
||||
- The vast majority of bugs I encounter are _not_ due to type violations. The most powerful tools against bugs remain linting, tests, and code reviews - none of which are made easier by TypeScript.
|
||||
- TypeScript doesn't guarantee type safety - that still requires discipline. You can still use hundreds of `any` annotations and libraries without any type definitions.
|
||||
- In Visual Studio Code, users can already get a lot of useful intellisense (including type information) without having to use TypeScript. [JSDoc comments](https://jsdoc.app/about-getting-started.html) can also be added to [serve the same purpose](https://blog.usejournal.com/type-vue-without-typescript-b2b49210f0b) on an as-needed basis.
|
||||
- Despite most bugs having nothing to do with type violations, developers can spend _a lot_ of time working towards full type safety, meaning teams unaccustomed to strongly typed languages may face significant drops in productivity. As I mentioned earlier, I think that time would be better spent on tests and code reviews.
|
||||
- While the next version of Vuex will be designed with TypeScript in mind, the current version can be particularly painful with TypeScript.
|
||||
|
||||
## HTML
|
||||
|
||||
All HTML will exist within [`.vue` files](https://vuejs.org/v2/guide/single-file-components.html), either:
|
||||
|
||||
- in a `<template>`, or
|
||||
- in a [`render` function](https://vuejs.org/v2/guide/render-function.html), optionally using [JSX](https://vuejs.org/v2/guide/render-function.html#JSX).
|
||||
|
||||
### [Templates](https://vuejs.org/v2/guide/syntax.html)
|
||||
|
||||
~95% of HTML will be in `.vue` files. Since Vue has a chance to parse it before the browser does, we can also do a few extra things that normally aren't possible in a browser.
|
||||
|
||||
For example, any element or component can be self-closing:
|
||||
|
||||
```html
|
||||
<span class="fa fa-comment" />
|
||||
```
|
||||
|
||||
The above simply compiles to:
|
||||
|
||||
```html
|
||||
<span class="fa fa-comment"></span>
|
||||
```
|
||||
|
||||
This feature is especially useful when writing components with long names, but no content:
|
||||
|
||||
```html
|
||||
<FileUploader
|
||||
title="Upload any relevant legal documents"
|
||||
description="PDFs or scanned images are preferred"
|
||||
icon="folder-open"
|
||||
/>
|
||||
```
|
||||
|
||||
### [Render functions](https://vuejs.org/v2/guide/render-function.html)
|
||||
|
||||
Render functions are _alternatives_ to templates. Components using render functions will be relatively rare, written only when we need either:
|
||||
|
||||
- the full expressive power of JavaScript, or
|
||||
- better rendering performance through stateless, [functional components](https://vuejs.org/v2/guide/render-function.html#Functional-Components)
|
||||
|
||||
These components can optionally be written using an HTML-like syntax within JavaScript called [JSX](https://vuejs.org/v2/guide/render-function.html#JSX), including support for [some template features](https://github.com/vuejs/babel-preset-vue#supports-event-modifiers).
|
||||
|
||||
### HTML FAQ
|
||||
|
||||
**Why not use a preprocessor like Jade instead of HTML?**
|
||||
|
||||
Jade offers too little convenience (no new features we'd want, just simpler syntax) and would break `eslint-plugin-vue`'s template linting.
|
||||
|
||||
**If using a render function instead of a template, why not use a `.js(x)` file instead of a `.vue` file?**
|
||||
|
||||
There are no advantages to using a JS(X) file, other than not having to use a `<script>` tag. By sticking to `.vue` files, you can:
|
||||
|
||||
- leave out components' `name` property, because `vue-loader` adds a `__filename` property to exported objects as a fallback for Vue's devtools
|
||||
- easily add styles if you later decide to
|
||||
- easily refactor to a template if you later decide to
|
||||
|
||||
## CSS
|
||||
|
||||
For our styles, we're using SCSS and CSS modules, which you can activate by adding the `lang="scss"` and `module` attributes to style tags in Vue components:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
/* Styles go here */
|
||||
</style>
|
||||
```
|
||||
|
||||
### SCSS
|
||||
|
||||
SCSS is a superset of CSS, meaning any valid CSS is _also_ valid SCSS. This allows you to easily copy properties from other sources, without having to reformat it. It also means you can stick to writing the CSS you're still comfortable with while you're learning to use more advanced SCSS features.
|
||||
|
||||
I specifically recommend reading about:
|
||||
|
||||
- [Variables](http://sass-lang.com/guide#topic-2)
|
||||
- [Nesting](http://sass-lang.com/guide#topic-3)
|
||||
- [Operators](http://sass-lang.com/guide#topic-8)
|
||||
|
||||
Just those features cover at least 95% of use cases.
|
||||
|
||||
### Importing global modules
|
||||
|
||||
To import files from `node_modules`, Webpack's [css-loader](https://github.com/webpack-contrib/css-loader) requires adding `~` to the beginning of a module name to denote that it's a global (not relative) file reference. For example:
|
||||
|
||||
```scss
|
||||
@import '~nprogress/nprogress.css';
|
||||
```
|
||||
|
||||
### Referencing aliased asset URLs
|
||||
|
||||
Similarly to importing global modules, referencing aliased assets in _non_-module CSS also requires the `~` at the beginning of the name. For example:
|
||||
|
||||
```scss
|
||||
background: url('~@assets/images/background.png');
|
||||
```
|
||||
|
||||
### Design variables and tooling
|
||||
|
||||
All our [variables](https://sass-lang.com/guide#topic-2), [placeholder classes](https://sass-lang.com/guide#topic-7), [mixins](https://sass-lang.com/guide#topic-6), and other design tooling are in the `src/design` folder. Each of these files define variables, prefixed with the name of the file, then combined in `src/design/index.scss`. This combined file is aliased as `@design` for convenience and can be imported into SCSS using:
|
||||
|
||||
```scss
|
||||
@import '@design';
|
||||
```
|
||||
|
||||
This makes all our design variables available in your component or SCSS file.
|
||||
|
||||
> NOTE: The `src/design` folder should never contain code that compiles to actual CSS, as that CSS would be duplicated across every component the file is imported into.
|
||||
|
||||
### CSS modules
|
||||
|
||||
As mentioned earlier, every Vue component should be a CSS module. That means the classes you define are not _actually_ classes. When you write:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
.inputLabel {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
.input {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
You're actually defining values on a `$style` property of the Vue instance such as:
|
||||
|
||||
```js
|
||||
$style: {
|
||||
inputLabel: 'base-input_inputLabel_dsRsJ',
|
||||
input: 'base-input_input_dsRsJ'
|
||||
}
|
||||
```
|
||||
|
||||
These values contain automatically generated classes with:
|
||||
|
||||
- the file name of the component
|
||||
- the name of the class
|
||||
- a random hash
|
||||
|
||||
Do you know what that means?! You can _never_ accidentally write styles that interfere with another component. You also don't have to come up with clever class names, unique across the entire project. You can use class names like `.input`, `.container`, `.checkbox`, or whatever else makes sense within the isolated scope of the component - just like you would with JavaScript variables.
|
||||
|
||||
#### Styling subcomponents
|
||||
|
||||
To pass a class to a child component, it's usually best to do so as a prop:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BaseInputText :labelClass="$style.label">
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.label {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
In some cases however, you may want to style a component arbitrarily deep. This should generally be avoided, because overuse can make your CSS very brittle and difficult to maintain, but sometimes it's unavoidable.
|
||||
|
||||
In these cases, you can use an [attribute selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) to take advantage of the fact that generated class names will always _start_ with the same characters:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="$style.container"><SomeOtherComponentContainingAnInput /></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container [class^='base-input_inputLabel'] {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
In the above example, we're applying styles to the `inputLabel` class inside a `base-input` component, but only when inside the element with the `container` class.
|
||||
|
||||
#### Sharing SCSS variables with JavaScript
|
||||
|
||||
If you ever need to expose the value of an SCSS variable to your JavaScript, you _can_ with CSS module exports! For example, assuming you have this variable defined:
|
||||
|
||||
```scss
|
||||
$size-grid-padding: 1.3rem;
|
||||
```
|
||||
|
||||
You could import our design tooling, then use CSS modules' `:export` it:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
:export {
|
||||
grid-padding: $size-grid-padding;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Then you can access the value using `this.$style['grid-padding']`.
|
||||
|
||||
If you need access from outside a Vue component (e.g. in a Vuex module), you can do so in `src/design/index.scss`. See that file for specific instructions.
|
||||
|
||||
### Global CSS
|
||||
|
||||
Typically, only [`src/app.vue`](../src/app.vue) should ever contain global CSS and even that should only include base element styles and utility classes (e.g. for grid management).
|
||||
|
||||
### CSS FAQ
|
||||
|
||||
**Why use SCSS instead of plain CSS or another CSS preprocessor?**
|
||||
|
||||
CSS preprocessors offer a lot of additional power - just having a browser-independent way to use variables is invaluable. But SCSS has some other advantages over competing preprocessors:
|
||||
|
||||
- SCSS it a superset of CSS, which means:
|
||||
- You can copy and paste valid CSS into SCSS and it will always be valid.
|
||||
- There's a gentler learning curve, as devs can write the same CSS they're used to, gradually incorporating more SCSS features as they're needed.
|
||||
- It's well-supported by both Stylelint and Prettier, eliminating nearly all arguments over code style.
|
||||
|
||||
**Why use CSS modules for scoping, instead of [Vue's `scoped` attribute](https://vue-loader.vuejs.org/en/features/scoped-css.html)?**
|
||||
|
||||
While a little more complex to begin with, CSS modules offer:
|
||||
|
||||
- Universality. The same scoping strategy can be used anywhere in our app, regardless of whether it's in a `.vue` file or `.scss` file.
|
||||
- True protection from collisions. Using the `scoped` attribute, vendor CSS could still affect your own classes, if you both use the same names.
|
||||
- Improved performance. Generated class selectors like `.base-input_inputLabel__3EAebB_0` are faster than attribute selectors, especially on an element selector like `input[data-v-3EAebB]`.
|
||||
- Increased versatility. There are cases the `scoped` attribute just can't handle, such as passing a scoped class to a child component that does _not_ render HTML directly. This is fairly common for component wrappers of views driven by WebGL or Canvas, that often inject HTML overlays such as tooltips at the root of the `<body>`.
|
||||
219
ui/docs/tests.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Tests and mocking the API
|
||||
|
||||
- [Tests and mocking the API](#tests-and-mocking-the-api)
|
||||
- [Running all tests](#running-all-tests)
|
||||
- [Unit tests with Jest](#unit-tests-with-jest)
|
||||
- [Running unit tests](#running-unit-tests)
|
||||
- [Introduction to Jest](#introduction-to-jest)
|
||||
- [Unit test files](#unit-test-files)
|
||||
- [Unit test helpers](#unit-test-helpers)
|
||||
- [Unit test mocks](#unit-test-mocks)
|
||||
- [End-to-end tests with Cypress](#end-to-end-tests-with-cypress)
|
||||
- [Running end-to-end tests](#running-end-to-end-tests)
|
||||
- [Introduction to Cypress](#introduction-to-cypress)
|
||||
- [Accessibility-driven end-to-end tests](#accessibility-driven-end-to-end-tests)
|
||||
- [The mock API](#the-mock-api)
|
||||
- [Mock authentication](#mock-authentication)
|
||||
- [Testing/developing against a real server](#testingdeveloping-against-a-real-server)
|
||||
|
||||
## Running all tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
yarn test
|
||||
```
|
||||
|
||||
## Unit tests with Jest
|
||||
|
||||
### Running unit tests
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
yarn test:unit
|
||||
|
||||
# Run unit tests in watch mode
|
||||
yarn test:unit:watch
|
||||
```
|
||||
|
||||
### Introduction to Jest
|
||||
|
||||
For unit tests, we use Jest with the `describe`/`expect` syntax. If you're not familiar with Jest, I recommend first browsing through the existing tests to get a sense for them.
|
||||
|
||||
Then at the very least, read about:
|
||||
|
||||
- [Jest's matchers](https://facebook.github.io/jest/docs/en/expect.html) for examples of other assertions you can make
|
||||
- [Testing async code](https://facebook.github.io/jest/docs/en/asynchronous.html)
|
||||
- [Setup and teardown](https://facebook.github.io/jest/docs/en/setup-teardown.html)
|
||||
|
||||
### Unit test files
|
||||
|
||||
Configuration for Jest is in `jest.config.js`, support files are in `tests/unit`, but as for the tests themselves - they're first-class citizens. That means they live alongside our source files, using the same name as the file they test, but with the extension `.unit.js`.
|
||||
|
||||
This may seem strange at first, but it makes poor test coverage obvious from a glance, even for those less familiar with the project. It also lowers the barrier to adding tests before creating a new file, adding a new feature, or fixing a bug.
|
||||
|
||||
### Unit test helpers
|
||||
|
||||
See [`tests/unit/setup.js`](../tests/unit/setup.js) for a list of helpers, including documentation in comments.
|
||||
|
||||
### Unit test mocks
|
||||
|
||||
Jest offers many tools for mocks, including:
|
||||
|
||||
- [For a function](https://facebook.github.io/jest/docs/en/mock-functions.html), use `jest.fn()`.
|
||||
- [For a source file](https://facebook.github.io/jest/docs/en/manual-mocks.html#mocking-user-modules), add the mock to a `__mocks__` directory adjacent to the file.
|
||||
- [For a dependency in `node_modules`](https://facebook.github.io/jest/docs/en/manual-mocks.html#mocking-node-modules), add the mock to `tests/unit/__mocks__`. You can see an example of this with the `axios` mock, which intercepts requests with relative URLs to either [our mock API](#the-mock-api) or a local/live API if the `API_BASE_URL` environment variable is set.
|
||||
|
||||
## End-to-end tests with Cypress
|
||||
|
||||
### Running end-to-end tests
|
||||
|
||||
```bash
|
||||
# Run end to end tests
|
||||
yarn test:e2e
|
||||
|
||||
# Run the dev server with the Cypress client
|
||||
yarn dev:e2e
|
||||
```
|
||||
|
||||
### Introduction to Cypress
|
||||
|
||||
Cypress offers many advantages over other test frameworks, including the abilities to:
|
||||
|
||||
- Travel through time to dissect the source of a problem when a test fails
|
||||
- Automatically record video and screenshots of your tests
|
||||
- Easily test in a wide range of screen sizes
|
||||
|
||||
And much more! I recommend checking out our Cypress tests in `tests/e2e/specs`, then reading through at least these sections of the excellent Cypress docs:
|
||||
|
||||
- [Core Concepts](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Is-Simple)
|
||||
- [Best Practices](https://docs.cypress.io/guides/references/best-practices.html)
|
||||
|
||||
Beyond that, also know that you can access our app in Cypress on the `window`. For example, to dispatch a Vuex action that sets up some state:
|
||||
|
||||
```js
|
||||
cy.window().then((window) => {
|
||||
return window.__app__.$store.dispatch('someModule/someAction')
|
||||
})
|
||||
```
|
||||
|
||||
### Accessibility-driven end-to-end tests
|
||||
|
||||
Ideally, tests should only fail when either:
|
||||
|
||||
- something is actually broken, or
|
||||
- the requirements have changed
|
||||
|
||||
Unfortunately, there are _a lot_ of ways to get this wrong. For example, when creating a selector for a login link:
|
||||
|
||||
```js
|
||||
cy.get('a')
|
||||
// Too general, as there could be many links
|
||||
|
||||
cy.get('.login-link')
|
||||
// Tied to implementation detail of CSS
|
||||
|
||||
cy.get('#login-link')
|
||||
// Tied to implementation detail of JS and prevents component reusability
|
||||
|
||||
cy.contains('Log in')
|
||||
// Assumes the text only appears in one context
|
||||
```
|
||||
|
||||
To create the right selector, think from the perspective of the user. What _exactly_ are they looking for? They're not looking for:
|
||||
|
||||
```js
|
||||
cy.get('a')
|
||||
// Any link
|
||||
|
||||
cy.get('.login-link')
|
||||
// An element with a specific class
|
||||
|
||||
cy.get('#login-link')
|
||||
// An element with a specific id
|
||||
|
||||
cy.contains('Log in')
|
||||
// Specific text anywhere on the page
|
||||
```
|
||||
|
||||
But rather:
|
||||
|
||||
```js
|
||||
cy.contains('a', 'Log in')
|
||||
// A link containing the text "Log in"
|
||||
```
|
||||
|
||||
Note that we're targeting a **semantic element**, meaning that it tells the web browser (and users) something about the element's role within the page. Also note that we're trying to be **as general as possible**. We're not looking for the link in a specific place, like a navbar or sidebar (unless that's part of the requirements), and we're not overly specific with the content. The link may also contain other content, like an icon, but that won't break the test, because we only care that _some link_ contains the text "Log in" _somewhere_ inside it.
|
||||
|
||||
Now, some will be thinking:
|
||||
|
||||
> "But isn't this brittle? Wouldn't it be better to add another attribute to the link, like `data-testid="login-link`? Then we could target that attribute and even if the element or content changes, the test won't break."
|
||||
|
||||
I would argue that if the link's semantic element or content changes so drastically that it's no longer an anchor and doesn't even contain the text "Log in" anymore, the requirements _have_ changed, so the test _should_ break. And from an accessibility perspective, the app might indeed be broken.
|
||||
|
||||
For example, let's imagine you replaced "Log in" with an icon:
|
||||
|
||||
```html
|
||||
<a href="/login">
|
||||
<span class="icon icon-login"></span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Now users browsing your page with a screen reader will have no way to find the login link. From their perspective, this is just a link with no content. You may be tempted to try to fix the test with something like:
|
||||
|
||||
```js
|
||||
cy.get('a[href="/login"]')
|
||||
// A link going to "/login"
|
||||
```
|
||||
|
||||
But when you're trying to find a login link as a user, you don't just inspect the destination of unlabeled links until you find one that looks like it's possibly a login page. That would be a very slow and painful experience!
|
||||
|
||||
Instead, thinking from a user's perspective forces you to stay accessible, perhaps updating your generated HTML to:
|
||||
|
||||
```html
|
||||
<a aria-label="Log in" href="/login">
|
||||
<span aria-hidden="true" class="icon icon-login"></span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Then the selector in your test can update as well:
|
||||
|
||||
```js
|
||||
cy.get('a[aria-label*="Log in"]')
|
||||
// A link with a label containing the text "Log in"
|
||||
```
|
||||
|
||||
And the app now works for everyone:
|
||||
|
||||
- Sighted users will see an icon that they'll (hopefully) have the cultural context to interpret as "Log in".
|
||||
- Non-sighted users get a label with the text "Log in" read to them.
|
||||
|
||||
This strategy could be called **accessibility-driven end-to-end tests**, because you're parsing your own app with the same mindset as your users. It happens to be great for accessibility, but also helps to ensure that your app always breaks when requirements change, but never when you've just changed the implementation.
|
||||
|
||||
## The mock API
|
||||
|
||||
Working against the production API can be useful sometimes, but it also has some disadvantages:
|
||||
|
||||
- Networks requests are slow, which slows down both development and testing.
|
||||
- Development and testing become dependent on a stable network connection.
|
||||
- Hitting the production API often means modifying the production database, which you typically don't want to do during automated tests.
|
||||
- To work on a frontend feature, the backend for it must already be complete.
|
||||
|
||||
The mock API is an [Express](https://expressjs.com/) server in `tests/mock-api` you can extend to - you guessed it - mock what the real API would do, solving all the problems listed above. This solution is also backend-agnostic, making it ideal for a wide variety of projects.
|
||||
|
||||
### Mock authentication
|
||||
|
||||
See the [`users` resource](../tests/mock-api/resources/users.js) in the mock API for a list of usernames and passwords you can use in development.
|
||||
|
||||
### Testing/developing against a real server
|
||||
|
||||
In some situations, you might prefer to test against a local server while developing, or maybe just during continuous integration. To do so, you can run any development or test command with the `API_BASE_URL` environment variable. For example:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:3000 yarn test
|
||||
```
|
||||
|
||||
Or similarly, with a live server:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=https://staging.example.io yarn test
|
||||
```
|
||||
44
ui/docs/troubleshooting.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Troubleshooting
|
||||
|
||||
These are some troubleshooting tips for more common issues people might run into while developing, including more information on what might be happening and how to fix the problem.
|
||||
|
||||
- [Errors running scripts (e.g. `yarn dev`)](#errors-running-scripts-eg-yarn-dev)
|
||||
- [Visual Studio (VS) Code formatting issues](#visual-studio-vs-code-formatting-issues)
|
||||
|
||||
## Errors running scripts (e.g. `yarn dev`)
|
||||
|
||||
Make sure you've followed the instructions for [Setup and development](development.md). If you already have, try deleting the `node_modules` folder and installing fresh:
|
||||
|
||||
```bash
|
||||
# 1. Delete all previously-installed dependencies.
|
||||
rm -rf node_modules
|
||||
|
||||
# 2. Install dependencies fresh.
|
||||
yarn install
|
||||
```
|
||||
|
||||
If that doesn't work, it's possible that a newer version of a dependency is creating a problem. If this is the problem, you can work around it by installing dependencies from the `yarn.lock` file of a previously working branch or commit.
|
||||
|
||||
```bash
|
||||
# 1. Delete all previously-installed dependencies.
|
||||
rm -rf node_modules
|
||||
|
||||
# 2. Use the same yarn.lock as the `origin/master` branch. If the problem
|
||||
# exists on the `origin/master` as well, instead use the last-known
|
||||
# working branch or commit.
|
||||
git checkout origin/master -- yarn.lock
|
||||
|
||||
# 2. Install dependencies fresh, using only the exact versions specified
|
||||
# in the `yarn.lock` file.
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
If this solves your problem, you can use `yarn outdated` to see the packages that may have received updates, then upgrade them one at a time with `yarn upgrade the-package-name` to see which upgrade introduces the problem.
|
||||
|
||||
## Visual Studio (VS) Code formatting issues
|
||||
|
||||
If you're using VS Code and notice that some files are being formatted incorrectly on save, the source is probably a formatter extension you've installed. The reason you're seeing it now is that this project enables the `editor.formatOnSave` setting. Previously, that extension was probably just doing nothing. To fix the problem, you'll need to either properly configure the extension or, if it's simply broken, uninstall it.
|
||||
|
||||
Extensions with known issues include:
|
||||
|
||||
- [Visual Studio Code Format](https://marketplace.visualstudio.com/items?itemName=ryannaddy.vscode-format#review-details)
|
||||
31
ui/generators/new/component/component.ejs.t
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<%
|
||||
if (blocks.indexOf('script') !== -1) {
|
||||
%><script>
|
||||
export default {
|
||||
<% if (blocks.indexOf('template') === -1) {
|
||||
%>render(h) {
|
||||
return <div/>
|
||||
}<% } %>
|
||||
}
|
||||
</script>
|
||||
<%
|
||||
}
|
||||
|
||||
if (blocks.indexOf('template') !== -1) {
|
||||
%>
|
||||
<template>
|
||||
<div/>
|
||||
</template>
|
||||
<%
|
||||
}
|
||||
|
||||
if (blocks.indexOf('style') !== -1) {
|
||||
%>
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
</style><%
|
||||
}
|
||||
%>
|
||||
45
ui/generators/new/component/prompt.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Components must have a name.'
|
||||
}
|
||||
const fileName = _.kebabCase(value)
|
||||
if (fileName.indexOf('-') === -1) {
|
||||
return 'Component names should contain at least two words to avoid conflicts with existing and future HTML elements.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'multiselect',
|
||||
name: 'blocks',
|
||||
message: 'Blocks:',
|
||||
initial: ['script', 'template', 'style'],
|
||||
choices: [
|
||||
{
|
||||
name: 'script',
|
||||
message: '<script>',
|
||||
},
|
||||
{
|
||||
name: 'template',
|
||||
message: '<template>',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
message: '<style>',
|
||||
},
|
||||
],
|
||||
validate(value) {
|
||||
if (value.indexOf('script') === -1 && value.indexOf('template') === -1) {
|
||||
return 'Components require at least a <script> or <template> tag.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
16
ui/generators/new/component/unit.ejs.t
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
let fileName = h.changeCase.kebab(name).toLowerCase()
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
if (fileName.slice(0, 5) === 'base-') {
|
||||
fileName = '_' + fileName
|
||||
}
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@components/<%= fileName %>', () => {
|
||||
it('exports a valid component', () => {
|
||||
expect(<%= importName %>).toBeAComponent()
|
||||
})
|
||||
})
|
||||
6
ui/generators/new/e2e/e2e.ejs.t
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
to: tests/e2e/specs/<%= h.changeCase.kebab(name) %>.e2e.js
|
||||
---
|
||||
describe('<%= h.changeCase.pascal(name) %>', () => {
|
||||
|
||||
})
|
||||
13
ui/generators/new/e2e/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
18
ui/generators/new/layout/layout.ejs.t
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
.container {
|
||||
min-width: $size-content-width-min;
|
||||
max-width: $size-content-width-max;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
13
ui/generators/new/layout/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Layout components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
19
ui/generators/new/layout/unit.ejs.t
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName) + 'Layout'
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@layouts/<%= fileName %>', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<p>Hello!</p>'
|
||||
const { element } = shallowMount(<%= importName %>, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
10
ui/generators/new/module/module.ejs.t
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
to: src/state/modules/<%= h.changeCase.kebab(name) %>.js
|
||||
---
|
||||
export const state = {}
|
||||
|
||||
export const getters = {}
|
||||
|
||||
export const mutations = {}
|
||||
|
||||
export const actions = {}
|
||||
13
ui/generators/new/module/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Vuex modules must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
13
ui/generators/new/module/unit.ejs.t
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
to: src/state/modules/<%= h.changeCase.kebab(name) %>.unit.js
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName) + 'Module'
|
||||
%>import * as <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@state/modules/<%= fileName %>', () => {
|
||||
it('exports a valid Vuex module', () => {
|
||||
expect(<%= importName %>).toBeAVuexModule()
|
||||
})
|
||||
})
|
||||
13
ui/generators/new/util/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Utility functions must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
14
ui/generators/new/util/unit.ejs.t
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
to: "src/utils/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName)
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@utils/<%= fileName %>', () => {
|
||||
it('says hello', () => {
|
||||
const result = <%= importName %>()
|
||||
expect(result).toEqual('hello')
|
||||
})
|
||||
})
|
||||
9
ui/generators/new/util/util.ejs.t
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
to: "src/utils/<%= h.changeCase.kebab(name) %>.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName)
|
||||
%>export default function <%= importName %>() {
|
||||
return 'hello'
|
||||
}
|
||||
18
ui/generators/new/view/prompt.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'View components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useStyles',
|
||||
message: 'Add <style> block?',
|
||||
},
|
||||
]
|
||||
13
ui/generators/new/view/unit.ejs.t
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
to: "src/router/views/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@views/<%= fileName %>', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(<%= importName %>).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
31
ui/generators/new/view/view.ejs.t
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
to: "src/router/views/<%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
const titleName = h.changeCase.title(name)
|
||||
%><script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: '<%= titleName %>',
|
||||
meta: [{ name: 'description', content: 'The <%= titleName %> page.' }],
|
||||
},
|
||||
components: { Layout }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<%= titleName %>
|
||||
</Layout>
|
||||
</template>
|
||||
<%
|
||||
|
||||
if (useStyles) { %>
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
</style>
|
||||
<% } %>
|
||||
54
ui/jest.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const _ = require('lodash')
|
||||
// Use a random port number for the mock API by default,
|
||||
// to support multiple instances of Jest running
|
||||
// simultaneously, like during pre-commit lint.
|
||||
process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999)
|
||||
|
||||
module.exports = {
|
||||
setupFiles: ['<rootDir>/tests/unit/setup'],
|
||||
globalSetup: '<rootDir>/tests/unit/global-setup',
|
||||
globalTeardown: '<rootDir>/tests/unit/global-teardown',
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/unit/matchers'],
|
||||
testMatch: ['**/(*.)unit.js'],
|
||||
moduleFileExtensions: ['js', 'json', 'vue'],
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'^.+\\.js$': 'babel-jest',
|
||||
'.+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$':
|
||||
'jest-transform-stub',
|
||||
},
|
||||
moduleNameMapper: require('./aliases.config').jest,
|
||||
snapshotSerializers: ['jest-serializer-vue'],
|
||||
coverageDirectory: '<rootDir>/tests/unit/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,vue}',
|
||||
'!**/node_modules/**',
|
||||
'!src/main.js',
|
||||
'!src/app.vue',
|
||||
'!src/router/index.js',
|
||||
'!src/router/routes.js',
|
||||
'!src/state/store.js',
|
||||
'!src/state/helpers.js',
|
||||
'!src/state/modules/index.js',
|
||||
'!src/components/_globals.js',
|
||||
],
|
||||
// https://facebook.github.io/jest/docs/en/configuration.html#testurl-string
|
||||
// Set the `testURL` to a provided base URL if one exists, or the mock API base URL
|
||||
// Solves: https://stackoverflow.com/questions/42677387/jest-returns-network-error-when-doing-an-authenticated-request-with-axios
|
||||
testURL:
|
||||
process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`,
|
||||
// https://github.com/jest-community/jest-watch-typeahead
|
||||
watchPlugins: [
|
||||
'jest-watch-typeahead/filename',
|
||||
'jest-watch-typeahead/testname',
|
||||
],
|
||||
globals: {
|
||||
'vue-jest': {
|
||||
// Compilation errors in the <style> tags of Vue components will
|
||||
// already result in failing builds, so compiling CSS during unit
|
||||
// tests doesn't protect us from anything. It only complicates
|
||||
// and slows down our unit tests.
|
||||
experimentalCSSCompile: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
15
ui/jsconfig.template.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// This is a template for a jsconfig.json file which will be
|
||||
// generated when starting the dev server or a build.
|
||||
|
||||
module.exports = {
|
||||
baseUrl: '.',
|
||||
include: ['src/**/*', 'tests/**/*'],
|
||||
compilerOptions: {
|
||||
baseUrl: '.',
|
||||
target: 'esnext',
|
||||
module: 'es2015',
|
||||
// ...
|
||||
// `paths` will be automatically generated using aliases.config.js
|
||||
// ...
|
||||
},
|
||||
}
|
||||