This commit is contained in:
Imbus 2024-02-28 10:36:36 +01:00
commit 523a211933
24 changed files with 490 additions and 55 deletions

View file

@ -19,6 +19,9 @@ build:
run: build
./bin/server
watch: build
watchexec -w . -r make run
# Clean target
clean:
$(GOCLEAN)
@ -26,8 +29,8 @@ clean:
rm -f db.sqlite3
# Test target
test:
$(GOTEST) ./...
test: db.sqlite3
$(GOTEST) ./... -count=1 -v
# Get dependencies target
deps:
@ -46,9 +49,19 @@ migrate:
sqlite3 $(DB_FILE) < $$file; \
done
# Target added primarily for CI/CD to ensure that the database is created before running tests
db.sqlite3:
make migrate
backup:
mkdir -p backups
sqlite3 $(DB_FILE) .dump | gzip -9 > ./backups/BACKUP_$(DB_FILE)_$(shell date +"%Y-%m-%d_%H:%M:%S").sql.gz
# Restore with:
# gzip -cd BACKUP_FILE.sql.gz | sqlite3 $(DB_FILE)
# Format
fmt:
$(GOCMD) fmt ./...
# Default target
default: build
default: build

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"ttime/internal/config"
"ttime/internal/database"
_ "github.com/mattn/go-sqlite3"
@ -12,15 +13,18 @@ import (
// The button state as represented in memory
type ButtonState struct {
pressCount int
PressCount int `json:"pressCount"`
}
// This is what a handler with a receiver looks like
// Keep in mind that concurrent state access is not (usually) safe
// And will in pracice be guarded by a mutex
// And will in practice be guarded by a mutex
func (b *ButtonState) pressHandler(w http.ResponseWriter, r *http.Request) {
b.pressCount++
response, err := json.Marshal(b.pressCount)
if r.Method == "POST" {
b.PressCount++
}
response, err := json.Marshal(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -37,21 +41,33 @@ func handler(w http.ResponseWriter, r *http.Request) {
}
func main() {
database.DbConnect()
b := &ButtonState{pressCount: 0}
conf, err := config.ReadConfigFromFile("config.toml")
if err != nil {
conf = config.NewConfig()
conf.WriteConfigToFile("config.toml")
}
// Pretty print the current config
str, _ := json.MarshalIndent(conf, "", " ")
fmt.Println(string(str))
database.DbConnect(conf.DbPath)
b := &ButtonState{PressCount: 0}
// Mounting the handlers
fs := http.FileServer(http.Dir("static"))
http.Handle("/", fs)
http.HandleFunc("/hello", handler)
http.HandleFunc("/button", b.pressHandler)
http.HandleFunc("/api/button", b.pressHandler)
// Start the server on port 8080
println("Currently listening on http://localhost:8080")
println("Visit http://localhost:8080/hello to see the hello handler in action")
println("Visit http://localhost:8080/button to see the button handler in action")
// Construct a server URL
server_url := fmt.Sprintf(":%d", conf.Port)
println("Server running on port", conf.Port)
println("Visit http://localhost" + server_url)
println("Press Ctrl+C to stop the server")
err := http.ListenAndServe(":8080", nil)
err = http.ListenAndServe(server_url, nil)
if err != nil {
panic(err)
}

View file

@ -5,4 +5,5 @@ go 1.21.1
require (
github.com/jmoiron/sqlx v1.3.5
github.com/mattn/go-sqlite3 v1.14.22
github.com/BurntSushi/toml v1.3.2
)

View file

@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=

View file

@ -0,0 +1,55 @@
package config
import (
"os"
"github.com/BurntSushi/toml"
)
// Config is the configuration for the application
// It defines how the TOML file should be structured
type Config struct {
// The port to listen on
Port int `toml:"port"`
// The path to the SQLite database
DbPath string `toml:"db_path"`
// The username to use for the database
DbUser string `toml:"db_user"`
// The password to use for the database
DbPass string `toml:"db_pass"`
// The name of the database
DbName string `toml:"db_name"`
}
// WriteConfigToFile writes a Config to a file
func (c *Config) WriteConfigToFile(filename string) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(c)
}
// ReadConfigFromFile reads a Config from a file
func ReadConfigFromFile(filename string) (*Config, error) {
c := NewConfig()
_, err := toml.DecodeFile(filename, c)
if err != nil {
return nil, err
}
return c, nil
}
// NewConfig returns a new Config
func NewConfig() *Config {
return &Config{
Port: 8080,
DbPath: "./db.sqlite3",
DbUser: "username",
DbPass: "password",
DbName: "ttime",
}
}

View file

@ -0,0 +1,68 @@
package config
import (
"os"
"testing"
)
func TestNewConfig(t *testing.T) {
c := NewConfig()
if c.Port != 8080 {
t.Errorf("Expected port to be 8080, got %d", c.Port)
}
if c.DbPath != "./db.sqlite3" {
t.Errorf("Expected db path to be ./db.sqlite3, got %s", c.DbPath)
}
if c.DbUser != "username" {
t.Errorf("Expected db user to be username, got %s", c.DbUser)
}
if c.DbPass != "password" {
t.Errorf("Expected db pass to be password, got %s", c.DbPass)
}
if c.DbName != "ttime" {
t.Errorf("Expected db name to be ttime, got %s", c.DbName)
}
}
func TestWriteConfig(t *testing.T) {
c := NewConfig()
err := c.WriteConfigToFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
// Remove the file after the test
_ = os.Remove("test.toml")
}
func TestReadConfig(t *testing.T) {
c := NewConfig()
err := c.WriteConfigToFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
c2, err := ReadConfigFromFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if c.Port != c2.Port {
t.Errorf("Expected port to be %d, got %d", c.Port, c2.Port)
}
if c.DbPath != c2.DbPath {
t.Errorf("Expected db path to be %s, got %s", c.DbPath, c2.DbPath)
}
if c.DbUser != c2.DbUser {
t.Errorf("Expected db user to be %s, got %s", c.DbUser, c2.DbUser)
}
if c.DbPass != c2.DbPass {
t.Errorf("Expected db pass to be %s, got %s", c.DbPass, c2.DbPass)
}
if c.DbName != c2.DbName {
t.Errorf("Expected db name to be %s, got %s", c.DbName, c2.DbName)
}
// Remove the file after the test
_ = os.Remove("test.toml")
}

View file

@ -1,32 +1,100 @@
package database
import (
"embed"
"log"
"os"
"path/filepath"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
func DbConnect() *sqlx.DB {
// Check for the environment variable
dbpath := os.Getenv("SQLITE_DB_PATH")
// This struct is a wrapper type that holds the database connection
// Internally DB holds a connection pool, so it's safe for concurrent use
type Db struct {
*sqlx.DB
}
// Default to something reasonable
if dbpath == "" {
dbpath = "./db.sqlite3"
}
//go:embed migrations
var scripts embed.FS
const userInsert = "INSERT INTO users (username, password) VALUES (?, ?)"
const projectInsert = "INSERT INTO projects (name, description, user_id) SELECT ?, ?, id FROM users WHERE username = ?"
// DbConnect connects to the database
func DbConnect(dbpath string) *Db {
// Open the database
// db, err := sqlx.Connect("sqlite3", ":memory:")
db, err := sqlx.Connect("sqlite3", dbpath)
if err != nil {
panic(err)
}
// Ping forces the connection to be established
err = db.Ping()
if err != nil {
panic(err)
}
return db
return &Db{db}
}
// AddUser adds a user to the database
func (d *Db) AddUser(username string, password string) error {
_, err := d.Exec(userInsert, username, password)
return err
}
// Removes a user from the database
func (d *Db) RemoveUser(username string) error {
_, err := d.Exec("DELETE FROM users WHERE username = ?", username)
return err
}
func (d *Db) GetUserId(username string) (int, error) {
var id int
err := d.Get(&id, "SELECT id FROM users WHERE username = ?", username)
return id, err
}
// Creates a new project in the database, associated with a user
func (d *Db) AddProject(name string, description string, username string) error {
_, err := d.Exec(projectInsert, name, description, username)
return err
}
// Reads a directory of migration files and applies them to the database.
// This will eventually be used on an embedded directory
func (d *Db) Migrate(dirname string) error {
// Read the embedded scripts directory
files, err := scripts.ReadDir("migrations")
if err != nil {
return err
}
tr := d.MustBegin()
// Iterate over each SQL file and execute it
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".sql" {
continue
}
// This is perhaps not the most elegant way to do this
sqlFile := filepath.Join("migrations", file.Name())
sqlBytes, err := os.ReadFile(sqlFile)
if err != nil {
return err
}
sqlQuery := string(sqlBytes)
_, err = tr.Exec(sqlQuery)
if err != nil {
return err
}
log.Println("Executed SQL file:", file.Name())
}
tr.Commit()
return nil
}

View file

@ -4,7 +4,71 @@ import (
"testing"
)
// Tests are not guaranteed to be sequential
func setupState() (*Db, error) {
db := DbConnect(":memory:")
err := db.Migrate("../../migrations")
if err != nil {
return nil, err
}
return db, nil
}
func TestDbConnect(t *testing.T) {
db := DbConnect()
db := DbConnect(":memory:")
_ = db
}
func TestDbAddUser(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.AddUser("test", "password")
if err != nil {
t.Error("AddUser failed:", err)
}
}
func TestDbGetUserId(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
db.AddUser("test", "password")
var id int
id, err = db.GetUserId("test")
if err != nil {
t.Error("GetUserId failed:", err)
}
if id != 1 {
t.Error("GetUserId failed: expected 1, got", id)
}
}
func TestDbAddProject(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.AddProject("test", "description", "test")
if err != nil {
t.Error("AddProject failed:", err)
}
}
func TestDbRemoveUser(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.RemoveUser("test")
if err != nil {
t.Error("RemoveUser failed:", err)
}
}

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
userId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL
);
CREATE INDEX IF NOT EXISTS users_username_index ON users (username);
CREATE INDEX IF NOT EXISTS users_userId_index ON users (userId);

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
projectId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
user_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX IF NOT EXISTS projects_projectId_index ON projects (projectId);
CREATE INDEX IF NOT EXISTS projects_user_id_index ON projects (user_id);

View file

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS time_reports (
id INTEGER PRIMARY KEY,
reportId TEXT DEFAULT (HEX(RANDOMBLOB(6))) NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
start DATETIME NOT NULL,
end DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
);
CREATE TRIGGER IF NOT EXISTS time_reports_start_before_end
BEFORE INSERT ON time_reports
FOR EACH ROW
BEGIN
SELECT
CASE
WHEN NEW.start >= NEW.end THEN
RAISE (ABORT, 'start must be before end')
END;
END;

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS report_collection (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
date DATE NOT NULL,
signed_by INTEGER, -- NULL if not signed
FOREIGN KEY (owner_id) REFERENCES users (id)
FOREIGN KEY (signed_by) REFERENCES users (id)
);

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
role STRING NOT NULL, -- 'admin' or 'member'
FOREIGN KEY (user_id) REFERENCES users (id)
FOREIGN KEY (project_id) REFERENCES projects (id)
);

View file

@ -0,0 +1,14 @@
# Database migrations
This directory contains all the database migrations for the backend.
[!WARNING]
Keep in mind that these migrations are **not yet stable**.
## Running migrations
In the root of the backend directory, run:
```bash
make migrate
```

View file

@ -1,7 +0,0 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);