Merge
This commit is contained in:
commit
523a211933
24 changed files with 490 additions and 55 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
||||
|
|
55
backend/internal/config/config.go
Normal file
55
backend/internal/config/config.go
Normal 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",
|
||||
}
|
||||
}
|
68
backend/internal/config/config_test.go
Normal file
68
backend/internal/config/config_test.go
Normal 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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
9
backend/internal/database/migrations/0010_users.sql
Normal file
9
backend/internal/database/migrations/0010_users.sql
Normal 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);
|
11
backend/internal/database/migrations/0020_projects.sql
Normal file
11
backend/internal/database/migrations/0020_projects.sql
Normal 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);
|
19
backend/internal/database/migrations/0030_time_reports.sql
Normal file
19
backend/internal/database/migrations/0030_time_reports.sql
Normal 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;
|
|
@ -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)
|
||||
);
|
7
backend/internal/database/migrations/0050_user_roles.sql
Normal file
7
backend/internal/database/migrations/0050_user_roles.sql
Normal 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)
|
||||
);
|
14
backend/internal/database/migrations/README.md
Normal file
14
backend/internal/database/migrations/README.md
Normal 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
|
||||
```
|
|
@ -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
|
||||
);
|
Loading…
Add table
Add a link
Reference in a new issue